---
name: parasut-api
description: Paraşüt API v4 (api.parasut.com) ile entegrasyon kodu yazarken, incelerken veya hata ayıklarken kullanın. OAuth2 + PKCE token akışı, JSON:API istek kuralları, sayfalama limitleri, cari/ürün/satış faturası/tahsilat/alış faturası akışları, idempotent fatura oluşturma, e-Fatura/e-Arşiv (202 + trackable job), hata kodları ve webhook alıcısı (X-Client-Key, event_id, sıralama) kurallarını içerir.
---

# Paraşüt API — AI Geliştirme Kuralları

> Bu dosya, Paraşüt API'si ile entegrasyon geliştiren geliştiricilere yönelik AI asistan kurallarını içerir.
> `.claude/`, `.cursor/` veya benzeri dizinlere ekleyerek AI destekli geliştirme süreçlerinizde kullanabilirsiniz.

---

## Kurulum

Dosyayı kullandığınız araca göre şu yola kaydedin:

| Araç | Yol | Not |
|------|-----|-----|
| Claude Code | `.claude/skills/parasut-api/SKILL.md` | Kullanıcı geneli için `~/.claude/skills/parasut-api/SKILL.md`. Klasör adı frontmatter'daki `name` ile aynı kalmalı. |
| Cursor | `.cursor/rules/parasut-api.mdc` | Cursor frontmatter'daki `description` alanını kullanır. Kuralın her istekte yüklenmesini istiyorsanız frontmatter'a `alwaysApply: true` satırını ekleyin. |
| Diğer araçlar | Aracın kural/talimat dosyası (ör. `AGENTS.md`) | İçeriği kopyalayın ya da o dosyadan bu dosyaya bağlantı verin. |

```bash
mkdir -p .claude/skills/parasut-api
curl -fsSL https://devhub.parasut.com/parasut-skill.md -o .claude/skills/parasut-api/SKILL.md

mkdir -p .cursor/rules
curl -fsSL https://devhub.parasut.com/parasut-skill.md -o .cursor/rules/parasut-api.mdc
```

---

## Kaynak önceliği

1. **https://devhub.parasut.com/openapi.json** — makine tarafından okunabilir OpenAPI belgesi (Paraşüt API v4, `info.version` 4.1.0). Alan adı, enum, `include`, filtre ve durum kodu için **tek doğru kaynak** budur.
2. **https://devhub.parasut.com/api-dokumantasyonu** — aynı belgenin okunabilir hâli.
3. Rehberler: `https://devhub.parasut.com/oauth-rehberi`, `https://devhub.parasut.com/fatura-irsaliye-rehberi`, `https://devhub.parasut.com/webhook-rehberi`.
4. Bu dosya — yukarıdakilerin AI için özeti. Çelişki olursa 1 ve 2 kazanır.

Bir alanın, enum değerinin ya da `include` değerinin var olduğundan emin değilsen `openapi.json` içinde ilgili şemayı ara; **tahmin etme, alan uydurma**. Bu dosyanın "Doğrulanmamış" bölümündeki maddeler için kullanıcıya sor ya da staging'de dene.

---

## Kesin kurallar (YAP / ASLA)

**YAP**
- Her API isteğinde `Authorization: Bearer <access_token>` ve `Content-Type: application/vnd.api+json` (veya `application/json`) gönder.
- Yolları `https://api.parasut.com/v4/{company_id}/...` olarak kur. Tek istisna `GET /me` (firma öneki yok).
- Kayıt oluştururken `data.id`'yi boş bırak; ilişkilerdeki mevcut kayıtların `id` değerlerini **string** olarak doldur (`"id": "123"`).
- `data.type` değerini **çoğul** yaz (`contacts`, `sales_invoices`, `transactions`). İstek gövdelerinde tek istisna webhook: `"type": "webhook"`.
- Listelerde `page[size]` değerini uç noktanın limitinde tut (çoğu uçta en fazla **25**, bkz. İstek kuralları).
- Satış faturasını `relationships.external_reference_key` ile idempotent oluştur; UUID'yi istekten **önce** kalıcı olarak sakla ve tekrar denemede aynı anahtarı kullan.
- e-Fatura ve e-Arşiv oluşturma **202 + trackable job** döner; `GET /{company_id}/trackable_jobs/{id}` ile `done` ya da `error` olana kadar sorgula.
- 401 alınca token'ı **bir kez** yenile ve isteği **bir kez** tekrarla; yine 401 ise dur ve kullanıcıya göster.
- Hataları HTTP durum kodu ve `errors[].source.pointer` ile ayırt et; hata metinleri değişebilir.
- Yanıtlarda ve webhook isteklerinde tanımadığın alanları, enum değerlerini ve header'ları yok say.
- Webhook alıcısında `X-Client-Key` değerini sabit zamanlı karşılaştır, `event_id` ile tekrarları ayıkla, `data.updated_at` ile sırala, işi kuyruğa at ve hemen `2xx` dön.
- Token yenilemeyi tek noktada (kilit/kuyruk ile) yap; aynı `refresh_token` ile paralel iki yenileme başlatma.

**ASLA**
- `CLIENT_SECRET`'ı tarayıcıya, frontend koduna, mobil uygulama paketine, depoya veya loglara koyma.
- `code_challenge` olmadan ya da `S256` dışında bir yöntemle yetkilendirme URL'i üretme.
- `readOnly` alanları gönderme: ör. satış faturasında `invoice_no`, `net_total`, `gross_total`, `total_vat`, `remaining`, `payment_status`; caride `balance`. Satış faturası numarası için `invoice_series` + `invoice_id` gönder.
- `#basic` / `#detailed` ekini URL'ye yazma. Bunlar yalnızca dokümantasyondaki işlem ayrımıdır; gerçek istek `POST /{company_id}/purchase_bills`.
- Spec listesinde olmayan `include`, `filter` veya `sort` değeri uydurma (ör. `purchase_bills` için `supplier`, `products` için `item_category` geçersizdir).
- Rate limit için header adı uydurma; spec header adı vermez.
- e-belge oluşturma yanıtını (202) belgenin kendisi sanma; 202 başarı değildir, iş `done` olana kadar belge yoktur.
- 403'ü otomatik yeniden deneme; yetki/paket sorunudur.
- Fatura tahsilatını `DELETE /payments/{id}` ile geri almaya çalışma; doğru yol `DELETE /transactions/{id}`.
- Webhook `uri` için `http://`, `localhost`, özel ağ ya da link-local adres önerme.
- Para birimi için `TRY` yazma; API `TRL | USD | EUR | GBP` kullanır.

---

## Sözlük

| Türkçe terim | Uç nokta | `data.type` |
|--------------|----------|-------------|
| Müşteri / Tedarikçi (cari) | `/contacts` | `contacts` |
| Ürün / Hizmet | `/products` | `products` |
| Kategori · Etiket | `/item_categories` · `/tags` | `item_categories` · `tags` |
| Satış faturası (proforma = `item_type: estimate`) · kalemi | `/sales_invoices` · `relationships.details` | `sales_invoices` · `sales_invoice_details` |
| Teklif | `/sales_offers` | `sales_offers` |
| Fiş / Fatura (alış, gider faturası) · kalemi | `/purchase_bills` · `relationships.details` | `purchase_bills` · `purchase_bill_details` |
| Banka gideri · Maaş · Vergi | `/bank_fees` · `/salaries` · `/taxes` | `bank_fees` · `salaries` · `taxes` |
| Çalışan | `/employees` | `employees` |
| Kasa / Banka hesabı | `/accounts` | `accounts` |
| İşlem (para hareketi) | `/transactions` | `transactions` |
| Belgeye tahsilat / ödeme | `/{belge}/{id}/payments` | `payments` |
| Cariden tahsilat · cariye ödeme | `/contacts/{id}/contact_debit_transactions` · `contact_credit_transactions` | `transactions` |
| Hesap ekstresi (cari, çalışan, kasa/banka) | `/{contacts\|employees\|accounts}/{id}/transaction_history_items` | `transaction_history_items` |
| Çek / Senet | `/checks` | `checks` |
| Açılış bakiyesi | `/opening_balances/{id}` | `opening_balances` |
| Fiyat listesi | `/price_lists` | `price_lists` |
| İrsaliye | `/shipment_documents` | `shipment_documents` |
| İrsaliye kalemi / stok hareketi | `/stock_movements` | `stock_movements` |
| Stok güncelleme | `/stock_updates` | `stock_updates` |
| Depo · Depo transferi | `/warehouses` · `/warehouse_transfers` | `warehouses` · `warehouse_transfers` |
| e-Fatura · e-Fatura gelen kutusu | `/e_invoices` · `/e_invoice_inboxes` | `e_invoices` · `e_invoice_inboxes` |
| e-Arşiv | `/e_archives` | `e_archives` |
| e-SMM (e-Makbuz) | `/e_smms` | `e_smms` |
| Gelen e-İrsaliye | `/inbound_e_despatches` | `inbound_e_despatches` |
| Yorum | `/comments` | `comments` |
| Döviz kuru | `/exchange_rates` | `exchange_rates` |
| Arka plan işi | `/trackable_jobs/{id}` | `trackable_jobs` |
| İdempotans anahtarı | `relationships.external_reference_key` | `external_reference_keys` |
| Webhook aboneliği | `/webhooks` | **`webhook`** (tekil) |

---

## İstek kuralları

- **Base URL:** `https://api.parasut.com/v4`. Spec: "`company-id` parametresi erişilmek istenen firmanın Paraşüt kayıt numarasıdır ve Paraşüt hesabında profil alanından veya uygulama adresinden elde edilebilir." Örnek: `https://api.parasut.com/v4/115/contacts`.
- **Kullanıcı ve firmaları:** `GET /me?include=companies` (`include` değerleri: `user_roles`, `companies`, `profile`). Yolunda `company_id` yoktur.
- **Header'lar:** `Authorization: Bearer <access_token>`, `Content-Type: application/vnd.api+json` veya `application/json`.
- **Gövde (JSON:API):** `{"data": {"type": "...", "attributes": {...}, "relationships": {...}}}`. Oluştururken `data.id` boş bırakılır; ilişkilerde mevcut kayıtların `id`'si dolu, yeni kalemlerin `id`'si boş olur.
- **Kimlik tipleri:** JSON:API `id` değerleri string, yol parametreleri (`company_id`, `{id}`) integer'dır.
- **Veri tipleri:** Şemada `number` olan alanları JSON sayısı olarak gönder (`"unit_price": 1500`). Tarihler `YYYY-MM-DD`. Para birimi `TRL | USD | EUR | GBP`.
- **Güncelleme:** `contacts`, `products`, `sales_invoices` için `PUT /{company_id}/{kaynak}/{id}`; fiş/fatura için de `PUT /{company_id}/purchase_bills/{id}`. Webhook, yorum, fiyat listesi ve depo transferi `PATCH` kullanır.

**Sayfalama** (`page[number]`, `page[size]`):

| Uç noktalar | Varsayılan | En fazla |
|-------------|-----------|----------|
| Diğer tüm listeler (`contacts`, `products`, `sales_invoices`, `purchase_bills`, `accounts`, `shipment_documents` …) | 15 | 25 |
| `transaction_history_items` | 10 | 25 |
| `{belge}/{id}/payments` listeleri (`opening_balances` hariç: sayfalanmaz), `checks`, `price_lists`, `price_lists/{id}/details` | 15 | 10000 |
| `webhooks`, `exchange_rates`, `products/by_ids`, `sales_invoices/{id}/details`, `purchase_bills/{id}/details`, `price_lists/{id}/product_discounts`, `e_despatch_inboxes` | sayfalama yok | — |

`sales_offers` listesinde spec yalnızca `page[number]`, `sales_offers/{id}/details` ucunda yalnızca `page[size]` (varsayılan/üst sınır belirtilmemiş) belgeler.

Limiti aşma: en az `comments` ve `inbound_e_despatches` uçlarında `page[size]` 25'ten büyükse **422** döner. Liste yanıtının `meta` alanı: `current_page`, `total_pages`, `total_count`, `per_page`; `current_page < total_pages` iken `page[number]` artır.

**Filtre, sıralama, include:**
- Filtre: `filter[alan]=değer`, yalnızca uç noktada listelenen alanlarla (ör. `contacts`: `name`, `email`, `tax_number`, `tax_office`, `city`, `account_type`).
- Bazı tarih filtreleri operatör alır: `filter[issue_date][gt]=2026-01-01` (`eq|lt|gt|gteq|lteq|not_eq`; `checks`, `inbound_e_despatches`, `warehouse_transfers` uçlarında belgelenmiştir).
- `filter[...]` **kullanmayan** istisnalar: `comments` (`commentable_type`, `commentable_id` zorunlu düz parametre), `transaction_history_items` (`transaction_type_filter`, `transaction_from_date_filter`, `transaction_to_date_filter`, `tag_ids_filter`), `e_despatch_inboxes` (`tax_number` zorunlu), `products/by_ids` (`ids=1,2,3`).
- Sıralama: `sort=alan`, azalan için `-` öneki (`sort=-issue_date`). Varsayılanlar uca göre değişir (ör. `contacts` için `-balance`).
- `include`: virgülle ayrılır, yalnızca uç noktadaki listeden:

| Kaynak | Geçerli `include` değerleri |
|--------|------------------------------|
| `sales_invoices` | `category, contact, details, details.product, details.warehouse, payments, payments.transaction, tags, sharings, recurrence_plan, active_e_document` |
| `purchase_bills` | `category, spender, details, details.product, details.warehouse, payments, payments.transaction, tags, recurrence_plan, active_e_document, pay_to` (`supplier` **yok**) |
| `contacts` | `category, contact_portal, contact_people` |
| `products` | `inventory_levels, category` |
| `shipment_documents` | `contact, stock_movements, stock_movements.product, tags, invoices` |

En az `inbound_e_despatches` ucunda bilinmeyen bir filtre, sıralama ya da `include` değeri **400** döner.

---

## Kimlik Doğrulama (OAuth2 — Authorization Code Flow)

Paraşüt, kimlik doğrulama için OAuth 2.0 Authorization Code akışını
PKCE ile kullanır. Her yetkilendirme isteğinde PKCE gönder; PKCE zorunludur
ve yalnızca `S256` desteklenir.

**İstemci türü — varsayılan confidential client:** Yeni API istemcileri
varsayılan olarak **confidential client** olarak oluşturulur. Token ve
yenileme isteklerinde `CLIENT_ID` ile birlikte `CLIENT_SECRET` gönderilir;
`REDIRECT_URI` başvuruda kayıtlı adrestir (en fazla 3; başvurudan sonra
değiştirilemez). `CLIENT_SECRET` Geliştirici Portalı'ndaki API Bilgilerim
sayfasından alınır ve orada yenilenir; secret e-posta ile gönderilmez.
Yenilendiği anda eski secret geçersiz olur, geçiş süresi yoktur: eski secret
ile yapılan token ve yenileme istekleri reddedilir, bu yüzden backend'deki
değeri hemen güncelle. Daha önce alınmış access token'lar süreleri dolana
kadar geçerli kalır.

**Kural — secret yalnızca backend'de:** `CLIENT_SECRET` yalnızca sunucu
tarafında (ortam değişkeni veya secret yöneticisi) tutulur. Tarayıcıya,
frontend koduna, mobil uygulama paketine, kaynak kod deposuna veya loglara
asla konmaz. Token alma ve yenileme istekleri backend'den yapılır.

**İstisna – public client:** Backend'i olmayan ve secret'ı güvenli şekilde
saklayamayan mobil veya masaüstü uygulamalar başvuruda public client talep
edebilir. Public client'ın `CLIENT_SECRET`'ı yoktur; token alma ve yenileme
son kullanıcının cihazında PKCE ile yapılır ve aşağıdaki isteklerde
`client_secret` satırı gönderilmez. Daha önce oluşturulmuş istemciler public
olarak kalır.

**Endpoint'ler (Prod):**
- Authorization: `https://id.parasut.com/realms/parasut/protocol/openid-connect/auth`
- Token: `https://id.parasut.com/realms/parasut/protocol/openid-connect/token`
- Discovery: `https://id.parasut.com/realms/parasut/.well-known/openid-configuration`

**Staging için** `id.parasut.com` yerine `id-staging.parasut.com` kullanılır.
Staging client'ı sizin için oluşturulan Paraşüt staging şirketine bağlıdır;
bu şirket 6 ay sonra otomatik silinir.

### 1. Kullanıcıyı Yetkilendirme Ekranına Yönlendir

```
https://id.parasut.com/realms/parasut/protocol/openid-connect/auth
  ?client_id=CLIENT_ID
  &redirect_uri=REDIRECT_URI
  &response_type=code
  &scope=openid
  &state=RANDOM_STATE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256
  &kc_idp_hint=parasut   # opsiyonel — kimlik sağlayıcı seçim ekranını atlar
```

`state` parametresi CSRF koruması için zorunludur; rastgele üretilip oturum
ile eşleştirilmelidir.

**PKCE zorunludur.** Her yetkilendirme isteği için yeni bir `code_verifier`
üret (43-128 karakter, `A-Z a-z 0-9 - . _ ~` karakterlerinden rastgele) ve
oturumda sakla. `CODE_CHALLENGE = BASE64URL(SHA256(code_verifier))` (dolgu
`=` karakterleri olmadan). `code_challenge` içermeyen ya da `S256` dışında
bir yöntem kullanan istekler reddedilir. Aynı `code_verifier` 3. adımdaki
token isteğinde gönderilir. `kc_idp_hint=parasut` parametresi opsiyoneldir;
gönderildiğinde kullanıcı doğrudan Paraşüt giriş ekranına yönlendirilir.

**Yetkilendirmeyi başlatmanın 3 yolu:**
1. **3. taraf butonu:** Uygulamaya "Paraşüt Hesabımı Bağla" butonu ekleyip kullanıcıyı yukarıdaki URL'e yönlendir.
2. **Paraşüt Uygulamalar sayfası:** Uygulama Paraşüt Uygulamalar sayfasında yayınlanıyorsa kullanıcı "Kullanmaya Başla"ya tıkladığında Paraşüt, kullanıcıyı uygulamanın `/oauth/start` (veya benzeri) ucuna deep-link ile yönlendirir; akış oradan itibaren backend redirect ile sürdürülür. PKCE ve `state`'in güvenli yönetilebilmesi için OAuth 2.0/2.1 BCP IdP-initiated akışları önermez; authorization URL üretimi her zaman 3. taraf uygulamada başlamalıdır.
3. **Backend redirect (UI'sız):** Backend authorization URL'ini oluşturup kullanıcıyı doğrudan Paraşüt giriş ekranına yönlendirir.

### 2. Consent Adımı

Kullanıcı Paraşüt Login'e yönlendirilir, kimlik doğrulamasını tamamlar ve
uygulamanın hangi verilere erişeceğini gösteren consent ekranını onaylar.

### 3. Authorization Code'u Token ile Değiştir

Kullanıcı onayladıktan sonra: `REDIRECT_URI?code=AUTHORIZATION_CODE&state=RANDOM_STATE`

`state` değerini oturumdaki ile karşılaştır; eşleşmiyorsa isteği reddet.

```bash
curl -X POST https://id.parasut.com/realms/parasut/protocol/openid-connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=CLIENT_ID" \
  -d "client_secret=CLIENT_SECRET" \
  -d "code=AUTHORIZATION_CODE" \
  -d "redirect_uri=REDIRECT_URI" \
  -d "code_verifier=CODE_VERIFIER"
```

Confidential client'larda bu istek **server-side** yapılmalıdır. `CODE_VERIFIER`, 1. adımda
`CODE_CHALLENGE`'ı üretmek için kullanılan değerin kendisidir. `client_id` ve
`client_secret`, form alanı yerine HTTP Basic ile de gönderilebilir
(`Authorization: Basic BASE64(CLIENT_ID:CLIENT_SECRET)`); bu durumda
`client_secret` satırı gövdeden çıkarılır. Public client'lar `client_secret`
satırını göndermez.

Yanıt:
```json
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 7200,
  "refresh_token": "eyJ...",
  "refresh_expires_in": 2592000,
  "scope": "openid"
}
```

`expires_in` ve `refresh_expires_in` değerleri saniye cinsindendir.

### 4. Token Yenileme

`expires_in` dolmadan önce refresh_token ile yenile:

```bash
curl -X POST https://id.parasut.com/realms/parasut/protocol/openid-connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "client_id=CLIENT_ID" \
  -d "client_secret=CLIENT_SECRET" \
  -d "refresh_token=REFRESH_TOKEN"
```

Yanıtta genellikle yeni `access_token` ile birlikte yeni bir `refresh_token`
da gelir; sonraki yenilemede **dönen son** `refresh_token`'ı kullan (yanıtta
yeni bir `refresh_token` yoksa elindekini sakla). Yenileme isteği de
backend'den, `client_secret` ile (form alanı veya HTTP Basic) yapılır
(public client'lar hariç).

---

## Hata yönetimi

Hata gövdesi JSON:API'dir: `errors` dizisinin her elemanında `title`, `detail`, isteğe bağlı `code`, `source.pointer` (hatalı alan, ör. `/data/attributes/uri`), `source.parameter` (hatalı sorgu parametresi) ve `meta` (ek bilgi) bulunur.

```json
{
  "errors": [
    {
      "title": "must be https",
      "detail": "Uri must be https",
      "source": { "pointer": "/data/attributes/uri" }
    }
  ]
}
```

İki istisna (webhook uçlarında belgelenmiştir; hata ayrıştırıcın her yerde ikisini de kabul etsin):
- **402** yanıtında `errors` dizisi nesne değil **metin** içerir.
- Geçerli JSON olmayan gövde **400** ile reddedilir ve yanıt JSON:API değildir (`Content-Type: application/json`).

```json
{ "errors": ["Paraşüt kullanım süreniz sona ermiş."] }
```

```json
{ "error": "Invalid JSON" }
```

| Durum | Anlamı | Ne yap |
|-------|--------|--------|
| 200 / 201 | Başarılı / oluşturuldu | — |
| 202 | İş kuyruğa alındı (e-Fatura, e-Arşiv, `resend`, `respond`, `legalize`, gelen e-İrsaliye yanıtları) | `data.id` ile `GET /trackable_jobs/{id}` sorgula |
| 204 | Gövdesiz başarı (çoğu `DELETE`) | Gövdeyi ayrıştırma |
| 304 | `match`: eşleşecek kayıt yok (gövdesiz) | Hata değil |
| 400 | Eksik/hatalı gövde veya parametre; bilinmeyen filtre/sıralama/include | İsteği düzelt; tekrar deneme |
| 401 | Token geçersiz ya da süresi dolmuş; kullanıcının bu `company_id` için erişimi yok; `DELETE /payments/{id}` silinemeyen ödemede de 401 döner | Bir kez yenile, bir kez tekrarla; yine 401 ise dur. Sonsuz yenileme döngüsüne girme |
| 402 | Firmanın Paraşüt kullanım süresi sona ermiş | Kullanıcıya bildir; tekrar deneme |
| 403 | Kullanıcının entegrasyon izni ya da gereken rolü yok; API paketinde özellik (ör. webhook) yok; istemcinin firmaya erişimi yok | Tekrar deneme; `title`'ı logla, kullanıcıya yetki/paket sorununu göster |
| 404 | Kayıt yok (veya başka istemcinin webhook'u). `find_by_external_reference_key` anahtar yoksa gövdesiz 404 | — |
| 409 | Kategoriden öğe çıkarma: `Kategori daha önce değiştirilmiş` | Kaydı yeniden oku |
| 422 | Doğrulama hatası | `source.pointer`'ı form alanına eşle; `meta`'yı oku (ör. `sales_invoice_id`) |
| 429 | Limit doldu. Spec: "Paketiniz 10 saniyede kaç istek yapabileceğinizi, 1 ayda toplam ne kadar istek yapabileceğinizi ve hangi firmalar için işlem yapabileceğinizi belirler. Limitiniz dolduğunda HTTP 429 cevabı alırsınız; cevapta tekrar ne zaman istek yapabileceğinize dair süre bilgisi de bulunur." | Yanıttaki süre kadar bekle; süre okunamazsa üstel geri çekilme + rastgele gecikme |
| 500 / 503 | Paraşüt tarafında geçici sorun | Sınırlı sayıda, üstel geri çekilmeyle tekrar dene; yalnızca idempotent isteklerde ya da idempotans anahtarlı `POST`'ta |

Spec: "Hata mesajları İngilizcedir ve zamanla değişebilir; hatayı programatik olarak ayırt etmek için HTTP durum kodunu ve `source.pointer` değerini kullanın." (Satış faturası gibi bazı uçların mesajları Türkçedir; kural aynıdır.)

---

## Kanonik akışlar

Örneklerde `{company_id}` yol parametresidir; JSON blokları ilgili isteğin gövdesidir.

### Akış 1 — Firmayı seç

1. `GET https://api.parasut.com/v4/me?include=companies` ile kullanıcının erişebildiği firmaları al.
2. Kullanıcı birden fazla firmaya erişiyorsa seçtir; seçilen firmanın kimliğini `company_id` olarak sakla ve sonraki tüm yollarda kullan.

### Akış 2 — Cari ve ürün hazırla

1. Cariyi ara: `GET /{company_id}/contacts?filter[tax_number]=1234567890`. Varsa `id`'sini kullan.
2. Yoksa oluştur: `POST /{company_id}/contacts` (zorunlu: `name`, `account_type`; `account_type` yalnızca `customer` veya `supplier`; `contact_type`: `person` | `company`).

```json
{"data": {"type": "contacts", "attributes": {
  "name": "Örnek Yazılım A.Ş.", "account_type": "customer", "contact_type": "company",
  "email": "muhasebe@example.com", "tax_number": "1234567890", "tax_office": "Kadıköy"
}}}
```

3. Ürünü ara (`GET /{company_id}/products?filter[code]=SW-001`) ya da oluştur: `POST /{company_id}/products` (zorunlu: `name`).

```json
{"data": {"type": "products", "attributes": {
  "name": "Yazılım Lisansı", "code": "SW-001", "vat_rate": 20, "unit": "adet",
  "list_price": 1500, "currency": "TRL"
}}}
```

### Akış 3 — Satış faturasını idempotent oluştur

1. Bir UUID üret ve faturayı oluşturmadan **önce** kendi veritabanına kaydet (sipariş/işlem kaydının yanına).
2. `POST /{company_id}/sales_invoices`. Zorunlu: `item_type`, `issue_date`; her kalemde `quantity`, `unit_price`, `vat_rate`. `item_type`: `invoice | export | estimate | cancelled | recurring_invoice | recurring_estimate | recurring_export | refund`. Fatura numarası için `invoice_series` + `invoice_id` (integer, "Fatura sıra") gönder; `invoice_no` salt okunurdur.

```json
{
  "data": {
    "type": "sales_invoices",
    "attributes": {
      "item_type": "invoice", "issue_date": "2026-10-23", "due_date": "2026-11-22",
      "currency": "TRL", "invoice_series": "A", "invoice_id": 1,
      "description": "Ekim 2026 lisans bedeli"
    },
    "relationships": {
      "contact": { "data": { "type": "contacts", "id": "CONTACT_ID" } },
      "details": { "data": [ {
        "type": "sales_invoice_details",
        "attributes": { "quantity": 1, "unit_price": 1500, "vat_rate": 20 },
        "relationships": { "product": { "data": { "type": "products", "id": "PRODUCT_ID" } } }
      } ] },
      "external_reference_key": { "data": {
        "type": "external_reference_keys",
        "attributes": { "key": "3b241101-e2bb-4255-8caf-4136c566a962" }
      } }
    }
  }
}
```

3. `external_reference_key` standart JSON:API ilişkisi **değildir**: `data` içinde `id` yoktur, `attributes.key` vardır. `key` UUID olmalıdır; değilse 422 `Gönderilen key geçerli bir UUID formatında değil.`
4. Zaman aşımı, 5xx ya da bağlantı hatasında isteği **aynı** anahtarla tekrarla. Fatura zaten oluşmuşsa 422 `Bu key ile daha önce bir fatura oluşturulmuş.` döner; mevcut faturayı `errors[].meta.sales_invoice_id` alanından al (mesaj metnine değil, bu alana güven).
5. Alternatif: `GET /{company_id}/sales_invoices/find_by_external_reference_key?external_reference_key=<uuid>` → 200 fatura; anahtar yoksa gövdesiz 404.
6. `payment_account_id`, `payment_date`, `payment_description` yalnızca peşin faturada ödeme oluşturmak içindir; normal faturada gönderme, tahsilatı Akış 4 ile ekle.

### Akış 4 — Tahsilat ekle ve geri al

1. `POST /{company_id}/sales_invoices/{id}/payments`. Gövde (`PaymentFormAttributes`): `description`, `account_id` (kasa/banka hesabı, integer; tahsilatın döviz kurunu da belirler), `date`, `amount`, `exchange_rate`. İlişki yoktur.

```json
{"data": {"type": "payments", "attributes": {
  "description": "Havale", "account_id": 123, "date": "2026-10-23", "amount": 1800
}}}
```

2. `exchange_rate`: tahsilat faturadan farklı bir döviz kuruyla yapılıyorsa kurun TL karşılığını gönder; aynı kurla yapılıyorsa `1.0` gönder ya da hiç gönderme.
3. Geri almak için tahsilatın işlemini bul: `GET /{company_id}/sales_invoices/{id}/payments?include=transaction` (ya da `GET /sales_invoices/{id}?include=payments.transaction`).
4. İşlemi sil: `DELETE /{company_id}/transactions/{transaction_id}` → 204. `DELETE /payments/{id}` yalnızca `contact_debit`, `employee_debit` ve `check_out` işlemlerine bağlı ödemeleri siler; diğerlerinde 401 döner.
5. Cari bazında (faturasız) tahsilat: `POST /{company_id}/contacts/{id}/contact_debit_transactions`, ödeme: `.../contact_credit_transactions`. `data.type` `transactions`; alanlar `description, account_id, date, amount, exchange_rate, payable_ids` (`payable_ids`: öncelikli eşleşecek fatura ID'leri).
6. Kasa/banka hesabına doğrudan giriş/çıkış: `POST /{company_id}/accounts/{id}/debit_transactions` veya `credit_transactions`; `data.type` `transactions`, zorunlu `date` ve `amount`, isteğe bağlı `description` (para birimi alanı yoktur).

### Akış 5 — e-Fatura / e-Arşiv ile resmileştir

1. Alıcının e-Fatura gelen kutusunu sorgula: `GET /{company_id}/e_invoice_inboxes?filter[vkn]=1234567890`.
2. Sonuç varsa **e-Fatura**: `POST /{company_id}/e_invoices`. `scenario`: `basic` | `commercial`; `to`: gelen kutusu yanıtındaki `e_invoice_address`; ilişkinin adı **`invoice`**.

```json
{"data": {
  "type": "e_invoices",
  "attributes": { "scenario": "commercial", "to": "urn:mail:defaultpk@example.com" },
  "relationships": { "invoice": { "data": { "type": "sales_invoices", "id": "INVOICE_ID" } } }
}}
```

3. Sonuç yoksa **e-Arşiv**: `POST /{company_id}/e_archives`. İlişkinin adı **`sales_invoice`** (e-Fatura'daki `invoice` ile karıştırma). İnternet satışında `internet_sale` (`url`, `payment_type`, `payment_platform`, `payment_date`), gönderim varsa `shipment` doldurulmalıdır.

```json
{"data": {
  "type": "e_archives",
  "relationships": { "sales_invoice": { "data": { "type": "sales_invoices", "id": "INVOICE_ID" } } }
}}
```

4. İki istek de **202** döner ve `data` bir trackable job'dır. `data.id` ile `GET /{company_id}/trackable_jobs/{id}` sorgula; `status`: `running` → beklemeye devam, `done` → belge oluştu, `error` → `attributes.errors` (metin dizisi) kullanıcıya gösterilir. 202 dönen bir iş de `error` ile bitebilir. Backend iş durumunu son güncellemesinden sonra **15 dakika** saklar; bu süre geçince aynı sorgu 404 döner, sonucu bu süre içinde oku (backend davranışı; spec belirtmez).

```json
{"data": {"id": "JOB_ID", "type": "trackable_jobs", "attributes": {"status": "done", "errors": []}}}
```

5. Oluşan belgeyi bul: `GET /{company_id}/sales_invoices/{id}?include=active_e_document` → ilişkinin tipi `e_invoices`, `e_archives` ya da e-SMM için `e_smms`, `id`'si belge kimliğidir.
6. PDF: `GET /{company_id}/e_invoices/{id}/pdf` veya `/e_archives/{id}/pdf`. Yanıt PDF baytı değil, JSON'dur: `data.attributes.url` ve `data.attributes.expires_at`. URL'yi kalıcı saklama; süresi dolunca yeniden iste.
7. e-Fatura sonucu: kayıttaki `status` (`waiting`, `failed`, `successful`), `status_code`, `status_message`. Yalnızca `failed` olan e-Fatura `PATCH /{company_id}/e_invoices/{id}/resend` ile tekrar gönderilir (202 + job); `status_code` uygun değilse iş `E-faturanız yeniden gönderime uygun değil` hatasıyla biter.
8. Polling'e ek olarak `sales_invoice` `update` webhook'u da dinlenebilir (bkz. Webhook'lar; yalnızca e-belgesi olan faturalar için gelir). Webhook teslimatı garanti edilmez; işin sonucunu her zaman `GET /{company_id}/trackable_jobs/{id}` ile doğrula.

e-SMM farklıdır: `POST /{company_id}/e_smms` eşzamanlıdır ve **201** ile kaydı döner; PDF yolu `GET /{company_id}/e_smms/{id}/pdf`.

### Akış 6 — Fiş / fatura (alış) oluştur

Tek uç: `POST /{company_id}/purchase_bills`. Spec iki gövde biçimi tanımlar (dokümantasyonda `#basic` / `#detailed` diye ayrılır, URL'ye yazılmaz). Oluşturma isteğinde `relationships.details` varsa detaylı, yoksa basic fiş oluşur; tür sonradan değişmez.

**Basic (kalemsiz):** zorunlu `item_type` (`purchase_bill` | `refund`), `issue_date`, `due_date`, `currency`, `net_total`, `total_vat`. Diğer alanlar: `description`, `invoice_no` (tedarikçinin fatura numarası; burada yazılabilir), `exchange_rate`. İlişkiler: `supplier` (`contacts`), `paid_by_employee` (`employees`), `category`, `tags`.

```json
{"data": {
  "type": "purchase_bills",
  "attributes": {
    "item_type": "purchase_bill", "issue_date": "2026-10-23", "due_date": "2026-11-22",
    "invoice_no": "ABC2026000000123", "currency": "TRL", "net_total": 1200, "total_vat": 200
  },
  "relationships": { "supplier": { "data": { "type": "contacts", "id": "SUPPLIER_ID" } } }
}}
```

**Detaylı (kalemli):** zorunlu `item_type`, `issue_date`, `due_date`, `currency`; isteğe bağlı `withholding_rate`, `invoice_discount`, `invoice_discount_type`. Kalemler `purchase_bill_details` (zorunlu `quantity`, `unit_price`, `vat_rate`; ilişkiler `product`, `warehouse`). Toplamlar kalemlerden hesaplanır.

```json
{"data": {
  "type": "purchase_bills",
  "attributes": {
    "item_type": "purchase_bill", "issue_date": "2026-10-23", "due_date": "2026-11-22",
    "invoice_no": "ABC2026000000124", "currency": "TRL"
  },
  "relationships": {
    "supplier": { "data": { "type": "contacts", "id": "SUPPLIER_ID" } },
    "details": { "data": [ {
      "type": "purchase_bill_details",
      "attributes": { "quantity": 2, "unit_price": 500, "vat_rate": 20 },
      "relationships": { "product": { "data": { "type": "products", "id": "PRODUCT_ID" } } }
    } ] }
  }
}}
```

Ödeme: `POST /{company_id}/purchase_bills/{id}/payments` (gövde Akış 4 ile aynı). Gelen e-Faturadan fiş: `POST /{company_id}/e_invoices/{id}/create_purchase_bill` (gövde JSON:API değil, düz JSON; aynı e-Fatura için ikinci çağrı 422).

---

## Webhook'lar

### Erişim ve yönetim

Spec: "Webhook uçlarını yalnızca Geliştirici Portalı'ndan alınmış ve API paketi webhook içeren bir API istemcisinin access token'ı ile çağırabilirsiniz. Paketiniz webhook içermiyorsa istekler **403** döner ve hatanın `title` değeri `API client's package does not include webhooks` olur. Geliştirici Portalı'ndaki bir API istemcisine ait olmayan bir access token ile yapılan istekler de **403** (`Webhooks are only available to Developer Portal API clients`) döner."

Spec: "Webhook kayıtları API istemcisine (`client_id`) aittir: aynı firmada her istemci yalnızca kendi webhook'larını görür ve yönetir. Listelemek ve görüntülemek için **Ayarlar** okuma yetkisi, oluşturmak, güncellemek ve silmek için **Ayarlar** tam yetkisi gerekir. Bir istemci, bir firma için her `model` + `action` ikilisinden yalnızca bir webhook tanımlayabilir."

Diğer 403 `title` değerleri: `Forbidden` (Ayarlar yetkisi yok), `User has no 3rd party integration permission for company: 123`, `API client has no access to company: 123`.

| İşlem | Uç nokta | Başarı |
|-------|----------|--------|
| Listele | `GET /{company_id}/webhooks` | 200 (sayfalama yok, `client_key` dönmez) |
| Oluştur | `POST /{company_id}/webhooks` | **201** |
| Görüntüle | `GET /{company_id}/webhooks/{id}` | 200 (başka istemcinin webhook'u 404) |
| Güncelle | `PATCH /{company_id}/webhooks/{id}` | 200 |
| Sil | `DELETE /{company_id}/webhooks/{id}` | **204** (gövdesiz) |

Oluşturma gövdesi (`model`, `action`, `uri`, `client_key` zorunlu; `Content-Type: application/vnd.api+json`):

```json
{"data": {"type": "webhook", "attributes": {
  "model": "sales_invoice", "action": "update",
  "uri": "https://example.com/webhooks/parasut",
  "client_key": "3f9a1c27b84e4d6a9e0f5b2c7d18a4e6f0b3c9d2e5a7184b6c0d2f4e8a1b3c5d"
}}}
```

```bash
# client_key üret (tam 64 karakter, yalnızca A-Z a-z 0-9) ve gizli olarak sakla
openssl rand -hex 32

curl -X POST "https://api.parasut.com/v4/COMPANY_ID/webhooks" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  --data @webhook.json
```

Kurallar (spec'ten aynen):
- "İstek gövdesindeki `data.type` tekil `webhook` olmalıdır, `webhooks` değil."
- `client_key`: "`A-Z`, `a-z` ve `0-9` karakterlerinden oluşan, tam **64 karakterlik** gizli bir değer."
- "`client_key` API yanıtlarında hiçbir zaman dönmez; değeri kendi tarafınızda güvenli biçimde saklayın." "`client_key` silinemez, yalnızca değiştirilebilir." Değeri kaybedersen ya da sızdığından şüphelenirsen webhook'u yeni bir `client_key` ile güncelle.
- "`uri` HTTPS olmalıdır; `http` adresler kabul edilmez. `uri` internetten erişilebilen bir adres olmalıdır: özel ağ, yerel (`localhost`) ya da bağlantı-yerel (link-local) adreslere işaret eden bir `uri` oluşturma ve güncellemede **422** ile reddedilebilir ve bu adreslere webhook isteği gönderilmeyebilir."
- Güncelleme: "`model` ve `action` webhook oluşturulduktan sonra değiştirilemez. Başka bir event'i dinlemek için yeni bir webhook oluşturun, artık ihtiyacınız olmayanı silin. İstekte `model` ve `action` göndermeyin; webhook'un mevcut değerinden farklı bir değer 422 döner." Yalnızca gönderilen `uri` ve/veya `client_key` değişir.
- Oluşturmada tekrar: "500 veya 503 aldığınızda webhook yine de oluşmuş olabilir; isteği tekrarlamadan önce webhook'ları listeleyip kontrol edin (aynı `model` + `action` için ikinci istek 422 döner)."
- "Hata mesajları İngilizcedir ve zamanla değişebilir; hatayı programatik olarak ayırt etmek için HTTP durum kodunu ve `source.pointer` değerini kullanın."

Anahtar değiştirme (`PATCH /{company_id}/webhooks/{id}`):

```json
{"data": {"type": "webhook", "attributes": {"client_key": "7c1e9b04d2a86f35e0b4c7a19d3f62e8b5a0c4d71f9e2b36a8d5c0f47e1b9a2d"}}}
```

### Event'ler

| `model` | `create` | `update` | `delete` |
| --- | :---: | :---: | :---: |
| `shipment_document` (İrsaliye) | ✓ | ✓ | ✓ |
| `stock_movement` (Stok hareketi) | ✓ | ✓ | ✓ |
| `product` (Ürün) | ✓ | ✓ | ✓ |
| `contact` (Müşteri / Tedarikçi) | ✓ | ✓ | ✓ |
| `sales_invoice` (Satış faturası) | ✓ | ✓ | – |
| `stock_update` (Stok güncelleme) | ✓ | – | – |
| `company` (Firma) | – | ✓ | – |
| `purchase_bill` (Fiş / Fatura) | ✓ | ✓ | – |
| `inbound_e_invoice` (Gelen e-Fatura) | ✓ | – | – |

Silme event'i olmayan modellerde silinen kayıtları ancak API'den eşitleyerek fark edersin.

Spec: "Bazı event'ler her kayıt için gönderilmez:"
- "**`sales_invoice` `update`:** yalnızca e-Fatura ya da e-Arşiv belgesi olan (`relationships.active_e_document` tipi `e_invoices` ya da `e_archives`) faturalar için gönderilir; e-SMM'si olan faturalar için gönderilmez. Teklifler ve tekrarlayan fatura şablonları (`item_type`: `estimate`, `recurring_invoice`, `recurring_estimate`, `recurring_export`) için gönderilmez. `create` bu türler için de gönderilir; gerekiyorsa `item_type` ile ayıklayın."
- "**`purchase_bill` `create` ve `update`:** gelen bir e-Faturaya bağlı (`relationships.active_e_document` dolu) fiş/faturalar ve tekrarlayan fiş/fatura şablonları (`item_type`: `recurring_purchase_bill`) için gönderilmez. Gelen e-Faturaları `inbound_e_invoice` event'iyle izleyin."
- "**`stock_movement`:** stok güncellemesinden ve depo transferinden doğan stok hareketleri için `create`, `update` ve `delete` gönderilmez. Stok güncellemelerini `stock_update` event'iyle izleyin; depo transferleri için webhook yoktur."

PDF: "Satış faturasının PDF'i oluştuğunda gönderilen `sales_invoice` `update` event'inin verisine `pdf_url` alanı eklenir. Bu bağlantı **2 saat** geçerlidir." (`data.data.attributes.pdf_url`; hemen indir, URL'yi saklama.)

Spec: "API yanıtlarında ve webhook isteklerinde tanımadığınız `model`, `action` ya da başka bir enum değeri gelirse hata vermeyin, yok sayın; tanımadığınız alanları ve header'ları da yok sayın."

### Gelen istek

Paraşüt `uri` adresine `Content-Type: application/json` ve `X-Client-Key: <client_key>` header'ıyla `POST` gönderir:

```json
{
  "event_id": "9f2c4e7a1b3d5f60718293a4b5c6d7e8",
  "company_id": 123,
  "resource": "contact",
  "action": "update",
  "event_date": "2025-01-10T10:55:00Z",
  "data": {
    "id": 456,
    "company_id": 123,
    "created_at": "2025-01-09T08:00:00.000Z",
    "updated_at": "2025-01-10T10:54:59.123Z",
    "data": { "id": "456", "type": "contacts", "attributes": { "name": "..." } }
  }
}
```

- Zarf her modelde aynıdır (`resource` = webhook'un `model` değeri). `data` üst seviyede `id` (integer), `company_id`, `created_at`, `updated_at` taşır; kaydın JSON:API gösterimi `data.data` (`id` string) ve varsa `data.included` içindedir.
- `company` event'inde "`data` JSON:API biçiminde değildir; firmanın alanlarını düz olarak içerir." (`id` ve `updated_at` yine üst seviyededir.)
- "`delete` event'inde `data`, kaydın silinmeden hemen önceki hâlidir ve diğer event'lerle aynı yapıdadır; kayıtla birlikte silinen ilişkili kayıtlar `data.included` içinde yer almayabilir."
- `event_id`: "biçimine güvenmeyin, yalnızca eşitlik için karşılaştırın". `event_date`: "Kaydın değiştiği an değildir; sıralama için kullanmayın".
- "Bilinmeyen alanları ve değerleri yok sayın ve gövdeye katı bir şema doğrulaması uygulamayın; garanti edilen alanlar yukarıda listelenenlerdir."

### Alıcı kuralları (spec'ten aynen)

1. **Doğrulama:** "İsteğin Paraşüt'ten geldiğini doğrulamak için header'daki değeri sakladığınız `client_key` ile karşılaştırın. Zamanlama saldırılarına karşı sabit zamanlı bir karşılaştırma kullanın (ör. Ruby'de `Rack::Utils.secure_compare`, Node.js'te `crypto.timingSafeEqual`) ve eşleşmeyen istekleri reddedin."
2. **Hızlı yanıt:** "Bağlantı 10 saniyede kurulamazsa ya da yanıt 30 saniyede gelmezse istek başarısız sayılır." "Uzun süren işleri isteği aldıktan sonra arka planda yapın ve hemen `2xx` dönün."
3. **Tekrarlar:** "Aynı event size birden fazla kez ulaşabilir; tekrarları `event_id` ile ayıklayın."
4. **Sıralama:** "Webhook istekleri, işlemlerin Paraşüt'te gerçekleştiği sırayla gelmeyebilir; sıra garanti edilmez." "`event_date` sıralama için uygun değildir."
   - "Aynı kayda (`resource` + `data.id`) ait istekleri `data.updated_at` değerine göre karşılaştırın; `updated_at` değeri daha eski olan bir isteği daha yenisinin üzerine işlemeyin."
   - "Bir kayıt için `delete` isteği aldıktan sonra aynı kayda ait gelen `create` ve `update` isteklerini işlemeyin. Kaydın son güncellemesine ait istek, silme isteğinden sonra ulaşabilir; silinmiş bir kaydı bu istekle yeniden oluşturmayın."
   - "Kaydın güncel hâline ihtiyacınız olduğunda onu API'den okuyun."
5. **Teslimat garantisi yok:** "Webhook istekleri en iyi çaba ilkesiyle gönderilir; her değişiklik için bir istek gönderileceği garanti edilmez. Kısa sürede art arda değişen bir kayıt için yalnızca son hâli gönderilebilir: örneğin bir kayıt için `create` isteği hiç gelmeden `update` ya da `delete` isteği gelebilir. Kayıtların eksiksiz bir kopyasını tutmanız gerekiyorsa webhook isteklerini bir değişiklik bildirimi olarak kullanın ve verileri düzenli aralıklarla API'den eşitleyin."
6. **IP kısıtı (isteğe bağlı):** "İstekleri kaynak IP'ye göre de kısıtlamak isterseniz Paraşüt'ün çıkış IP adreslerini destek ekibinden talep edebilirsiniz."

### Yeniden deneme

| Endpoint'in yanıtı | Ne olur |
| --- | --- |
| `2xx` | Teslim edildi |
| `5xx`, `408`, `429`, zaman aşımı, bağlantı ya da SSL hatası | Artan aralıklarla 10 kereye kadar, yaklaşık 4–5 saat boyunca yeniden denenir; sonra bu event için istek gönderilmez |
| Diğer `4xx`, `3xx` | Yeniden denenmez; yönlendirmeler takip edilmez |

"Zaman aşımı süreleri, deneme sayısı ve deneme aralıkları zamanla değişebilir." "Sürekli başarısız olan webhook'lar devre dışı bırakılabilir." `uri`'yi doğrudan son adrese ver (yönlendirme izlenmez); uzun kesintiden sonra API'den eşitle.

### Alıcı akışı

Doğrula → tekrarı ayıkla → sıra kontrolü (silme kazanır) → kuyruğa al → hemen 2xx.

```ts
import { timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
const CLIENT_KEY = Buffer.from(process.env.PARASUT_WEBHOOK_CLIENT_KEY ?? "", "utf8");
if (CLIENT_KEY.length !== 64) throw new Error("PARASUT_WEBHOOK_CLIENT_KEY 64 karakter olmalı");
// db: kendi veri katmanın (eventKey benzersiz anahtar, kayıt durumu, iş kuyruğu)

// varsayılan 100kb sınırı çok kalemli faturalarda 413 döndürür; 413 yeniden denenmez
app.post("/webhooks/parasut", express.json({ limit: "5mb" }), async (req, res) => {
  // 1) Sabit zamanlı karşılaştırma (uzunluklar farklıysa timingSafeEqual hata fırlatır)
  const got = Buffer.from(req.get("X-Client-Key") ?? "", "utf8");
  if (got.length !== CLIENT_KEY.length || !timingSafeEqual(got, CLIENT_KEY)) {
    return res.sendStatus(401); // spec kod belirtmez; diğer 4xx yeniden denenmez
  }
  const event = req.body; // katı şema doğrulaması yapma

  // 2) Tekrarı ayıkla: event_id webhook başınadır; firma + model + action ile birlikte sakla (yalnızca eşitlikle karşılaştır)
  const eventKey = [event.company_id, event.resource, event.action, event.event_id].join(":");
  if (await db.processedEvents.has(eventKey)) return res.sendStatus(200);

  // 3) Sıra: aynı kayıt = resource + data.id; ölçüt data.updated_at; delete kazanır
  const key = `${event.resource}:${event.data.id}`;
  const isDelete = event.action === "delete";

  // 4) Tek transaction: eventKey'i işaretle, durumu koşullu yaz, kuyruğa at
  await db.transaction(async (tx) => {
    await tx.processedEvents.add(eventKey);
    // Atomik koşullu upsert; eşzamanlı isteklerde oku-sonra-yaz yarışını önler. Örn. SQL:
    // INSERT ... ON CONFLICT (key) DO UPDATE SET deleted = EXCLUDED.deleted, updated_at = EXCLUDED.updated_at
    //   WHERE NOT record_state.deleted AND (EXCLUDED.deleted OR EXCLUDED.updated_at >= record_state.updated_at)
    //   RETURNING key
    const applied = await tx.recordState.setIfNewer(key, { deleted: isDelete, updatedAt: event.data.updated_at });
    if (!applied) return; // eski ya da silinmiş kayda gelen istek
    await tx.jobs.enqueue("parasut-sync", { companyId: event.company_id, resource: event.resource, id: event.data.id, action: event.action });
  });

  // 5) Hemen 2xx; worker gerekirse güncel kaydı API'den okur
  return res.sendStatus(204);
});
```

Ruby: `Rack::Utils.secure_compare(request.get_header("HTTP_X_CLIENT_KEY").to_s, ENV.fetch("PARASUT_WEBHOOK_CLIENT_KEY"))`. Geliştirmede de internetten erişilebilen bir HTTPS adresi kullan.

---

## Diğer kaynaklar (tek satır + tuzak)

- **Satış faturası yaşam döngüsü:** `DELETE /sales_invoices/{id}/cancel` faturayı iptal eder ve **200** döner (silmez); `PATCH .../recover` geri alır; `PATCH .../convert_to_invoice` proformayı faturaya çevirir (stok hareketleri varsayılan depodan). `GET /sales_invoices/{id}/details` sayfalanmaz ve `include` almaz.
- **Teklif (`sales_offers`):** kalemler için `GET /sales_offers/{id}?include=details.product` (liste ucunda yalnızca `contact`, `sales_invoice`) ya da `GET /sales_offers/{id}/details?include=product`.
- **İrsaliye (`shipment_documents`):** kalemler `stock_movements`. Giden e-İrsaliye: `GET /e_despatch_inboxes?tax_number=` (zorunlu, `filter[...]` değil) → `POST /shipment_documents/{id}/legalize` (202 + job).
- **Fatura–irsaliye bağlama:** `POST /sales_invoices/{id}/link_shipment_documents` gövdesi JSON:API kaynağı değildir: `{"data": {"shipment_document_ids": [1, 2]}}`; işlem bütünseldir ve stok entegrasyonu açık olmalıdır.
- **`match`** (`sales_invoices/{id}/match`, `purchase_bills/{id}/match`): açık tutarı karşı bakiyeyle eşleştirir; gövdesiz **304** = eşleşecek kayıt yok.
- **Çek / Senet (`checks`):** `issued_by` (alınan) ya da `given_to` (verilen) ilişkilerinden **tam olarak biri** gönderilir; `POST /checks/{id}/payments` tutarı her zaman çekin tamamıdır, `amount` gönderilmez; ciro `PATCH /checks/{id}/transfer`.
- **Yorum (`comments`):** sorguda `commentable_type` CamelCase (`SalesInvoice`), gövdedeki `relationships.commentable` tipi çoğul snake_case (`sales_invoices`); kayıt başına en fazla 120 yorum; dosya eki API ile gönderilemez.
- **Fiyat listesi (`price_lists`):** `item_type` `all | category | custom`; `PATCH`'te `details` dizisinde olmayan kalemler **silinir**.
- **Depo transferi (`warehouse_transfers`):** çıkış ve giriş deposu farklı olmalı, en az bir kalem gerekir; `PATCH`'te dizide olmayan kalemler silinir; webhook'u yoktur.
- **Gelen e-İrsaliye (`inbound_e_despatches`):** varsayılan olarak yalnızca yanıtlanmamışlar döner (`filter[responded]=false`); `accept`, `respond`, `reject` 202 + job.
- **Gelen e-Fatura:** `POST /e_invoices/{id}/respond` yalnızca `successful` + `commercial` + süresi dolmamış e-Faturada iş yapar (aksi hâlde iş yine `done` biter); `archive` / `unarchive` vardır.
- **İmzalı UBL:** `GET /e_invoices/{id}/signed_ubl` ve `/e_archives/{id}/signed_ubl` 200 XML ya da 302 döner; hazır değilse düz metin `signed ubl is not ready` ile 404 (biraz sonra tekrar dene).
- **Açılış bakiyesi (`opening_balances`):** yalnızca `show`, `delete`, `payments`; listeleme ve oluşturma ucu yoktur.
- **Döviz kuru (`exchange_rates`):** `filter[date]`; USD, EUR, GBP için TCMB kuru; sayfalama ve `meta` yok.
- **`products/by_ids?ids=1,2,3`:** en fazla 50 ID, sayfalama yok; bulunamayan ID'ler yanıtta yer almaz.
- **Kasa/banka:** `POST /accounts/money_transfers` iki hesap arası transfer (aynı para biriminde tutarlar eşit olmalı); `POST /accounts/{id}/adjust_balance` bakiyeyi sabitler, geri almak için oluşan `balance_adjustment` işlemini `DELETE /transactions/{id}` ile sil.
- **Hesap ekstresi (`transaction_history_items`):** en yeniden en eskiye, satır başına yürüyen bakiye; sıralama sabittir; parametreler `filter[...]` değildir; `page[size]` 10 / 25.
- **Kategori (`item_categories`):** öğe çıkarmada kayıt artık o kategoride değilse 409.

---

## Doğrulanmamış

Aşağıdakiler spec'te açık değildir; varsayım yapma, staging'de dene ya da destek ekibine sor:
- 429 yanıtındaki bekleme süresinin header ya da gövde alanı adı.
- Staging API base URL'i (spec yalnızca staging OIDC adresini verir: `https://id-staging.parasut.com/realms/parasut/.well-known/openid-configuration`).
- `GET /me?include=companies` yanıtındaki firma `id`'sinin `company_id` ile aynı olduğu (Geliştirici Portalı rehberleri böyle kullanır, spec açıkça yazmaz).
- Şemada `number` olan alanların string (`"1500.00"`) olarak da kabul edilmesi.
- Basic fişte `net_total` alanının KDV dahil genel toplam mı olduğu (satış faturasında `net_total` "Genel Toplam" anlamındadır).
- e-Fatura / e-Arşiv PDF'i henüz hazır değilken `pdf` ucunun ne döndüğü ve trackable job için önerilen sorgulama aralığı.
- Devre dışı bırakılan bir webhook'un nasıl anlaşılacağı (`Webhook` kaydında durum alanı yoktur) ve `X-Client-Key` eşleşmediğinde hangi durum kodunun dönülmesi gerektiği.
- Bilinmeyen filtre/include için 400 kuralının tüm uçlarda geçerli olup olmadığı.

---

*Güncel ve kapsamlı dokümantasyon için: https://devhub.parasut.com/api-dokumantasyonu*
