# Webhook'lar

Webhook'lar, platform olaylarını gerçekleştikleri anda Borkol'dan sunucunuza iletir; böylece sürekli sorgulamanız (poll) gerekmez. Bir sipariş oluşturulduğunda, bir siparişin durumu değiştiğinde, bir stok seviyesi ayarlandığında, bir teklif önerildiğinde, kabul edildiğinde, reddedildiğinde veya süresi dolduğunda, bir fatura veya alacak dekontu gönderildiğinde, bir fatura ödendiğinde veya vadesi geçtiğinde, bir içerik kaydı yayınlandığında, bir form yayınlandığında veya bir ziyaretçi bir form gönderdiğinde Borkol, mağazanın o olaya abone olan her aktif endpoint'ine bir HTTP POST gönderir. Her teslimat, endpoint'e özel bir secret ile imzalanır; böylece sunucunuz isteğin gerçekten Borkol'dan geldiğini doğrulayabilir.

## Endpoint'leri yapılandırma

Webhook endpoint'leri mağaza başına client dashboard'da (app.borkol.com) Settings > Webhooks altında yapılandırılır. Endpoint yapılandırması herkese açık storefront API'sinin parçası değildir. Her endpoint şunlara sahiptir:

| Alan | Tür | Açıklama |
| --- | --- | --- |
| `id` | string (UUID) | Endpoint tanımlayıcısı. |
| `url` | string | Hedef URL, en fazla 2048 karakter. Yerel geliştirme host'ları (`localhost`, `127.0.0.1`, `*.test`, `*.localhost`) dışında `https` olmak zorundadır. |
| `events` | array of strings | Bu endpoint'in abone olduğu olaylar; aşağıdaki olay kataloğunun herhangi bir alt kümesi. |
| `is_active` | boolean | Pasif endpoint'lere teslimat yapılmaz. |
| `secret` | string | `whsec_` ile başlayıp 40 rastgele alfasayısal karakterle devam eden formatta imzalama secret'ı. Yalnızca oluşturma anında bir kez gösterilir, sonrasında gizlenir; hemen saklayın. |

Bir olay, mağazanın o olaya abone olan her aktif endpoint'ine dağıtılır. Dashboard ayrıca, aboneliklerinden bağımsız olarak tek bir endpoint'e `ping` olayı ileten bir "Send test" işlemi de sunar.

## Teslimat formatı

Her teslimat, endpoint URL'ine şu header'larla yapılan bir HTTP POST'tur:

| Header | Değer |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Borkol-Event` | Olay adı (örneğin `order.created`). |
| `X-Borkol-Signature` | Ham request body'nin tamamı üzerinden, endpoint'in `whsec_` secret'ıyla hesaplanan küçük harfli hex HMAC-SHA256. |

Body her zaman şu zarftır (slash'ları escape edilmeden JSON kodlanır):

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `event` | string | her zaman | Şunlardan biri: `order.created`, `order.status_changed`, `stock.adjusted`, `quote.offered`, `quote.accepted`, `quote.declined`, `quote.expired`, `invoice.sent`, `invoice.paid`, `invoice.overdue`, `credit_note.sent`, `object_record.published`, `object_record.updated`, `object_record.unpublished`, `object_record.deleted`, `form.published`, `form_submission.created`, `ping`. |
| `created_at` | string (ISO 8601) | her zaman | Teslimat kaydının oluşturulma zaman damgası (olayın kaydedildiği an, bu deneme özelinde gönderim anı değil). |
| `data` | object | her zaman | Olaya özgü payload; aşağıdaki kataloğa bakın. |

```json title="Request"
{
  "event": "order.created",
  "created_at": "2026-08-14T09:12:44+00:00",
  "data": {
    "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "number": 1042,
    "status": "pending_payment",
    "total_amount": 15900,
    "currency": "EUR",
    "email": "buyer@example.com"
  }
}
```

Sunucunuz 10 saniye içinde herhangi bir 2xx durumuyla yanıt vermelidir. Response body dikkate alınmaz. 2xx dışı herhangi bir durum, bağlantı hatası veya zaman aşımı başarısız bir deneme sayılır ve yeniden denemeyi tetikler.

## Olay kataloğu

Abone olunabilecek olaylar yalnızca bunlardır. Yeniden denemeler aynı zarfı tekrar iletir; işlemeniz idempotent değilse kendi tarafınızda tekilleştirin (`event` artı `created_at` artı payload kimliği pratik bir anahtardır).

### order.created

Bir sipariş oluşturulduğunda gönderilir. `data` alanları:

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `id` | string (UUID) | her zaman | Sipariş ID'si. |
| `number` | integer | her zaman | Mağaza başına ardışık sipariş numarası. |
| `status` | string | her zaman | Oluşturma anındaki sipariş durumu; şunlardan biri: `pending_payment`, `paid`, `shipped`, `completed`, `cancelled`, `refunded`. |
| `total_amount` | integer | her zaman | Küçük birim (kuruş/cent) cinsinden sipariş toplamı. |
| `currency` | string | her zaman | ISO 4217 para birimi kodu. |
| `email` | string | her zaman | Alıcının e-posta adresi. |

```json title="Payload"
{
  "event": "order.created",
  "created_at": "2026-08-14T09:12:44+00:00",
  "data": {
    "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "number": 1042,
    "status": "pending_payment",
    "total_amount": 15900,
    "currency": "EUR",
    "email": "buyer@example.com"
  }
}
```

### order.status_changed

Bir sipariş yeni bir duruma geçtiğinde gönderilir. `data`, `order.created` ile aynı şekle sahiptir; `status` yeni durumdur.

Sipariş durumları şu durum makinesini izler:

| Kaynak durum | İzin verilen geçişler |
| --- | --- |
| `pending_payment` | `paid`, `cancelled` |
| `paid` | `shipped`, `cancelled`, `refunded` |
| `shipped` | `completed`, `refunded` |
| `completed` | `refunded` |
| `cancelled` | yok (uç durum) |
| `refunded` | yok (uç durum) |

```json title="Payload"
{
  "event": "order.status_changed",
  "created_at": "2026-08-14T10:03:01+00:00",
  "data": {
    "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "number": 1042,
    "status": "paid",
    "total_amount": 15900,
    "currency": "EUR",
    "email": "buyer@example.com"
  }
}
```

### stock.adjusted

Bir stok seviyesi değiştiğinde gönderilir. `data` alanları:

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `product_id` | string (UUID) | her zaman | Stoku değişen ürün. |
| `variant_id` | string (UUID) or null | her zaman | Stoku değişen varyant; ürünün varyantı yoksa null. |
| `quantity` | number | her zaman | Yeni mutlak eldeki miktar (fark değil), ürünün `unit` biriminde. Ağırlık, uzunluk, alan veya hacimle satılan ürünlerde ondalıklı olabilir (örneğin `47.84`). |

```json title="Payload"
{
  "event": "stock.adjusted",
  "created_at": "2026-08-14T11:27:19+00:00",
  "data": {
    "product_id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
    "variant_id": null,
    "quantity": 42
  }
}
```

### quote.offered

Bir merchant bir [teklif](/tr/api/orders-and-quotes/quote-tokens) üzerinde bir öneri gönderdiğinde gönderilir (durum `offered` olur). Aynı öneriyi yeniden göndermek olay göndermez. Payload hiçbir zaman kabul token'ını içermez: alıcının bağlantısı yalnızca e-posta ile gönderilir. `data` alanları (dört `quote.*` olayı için aynıdır):

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `id` | string (UUID) | her zaman | Teklif kimliği. |
| `number` | string | her zaman | Mağazanın numara serisinden teklif numarası, örneğin `Q-17`. |
| `status` | string | her zaman | Olay anındaki teklif durumu; `draft`, `submitted`, `offered`, `accepted`, `declined`, `expired`, `cancelled` değerlerinden biri. |
| `origin` | string | her zaman | Sepet tabanlı bir istek olarak başlayan teklif için `storefront`, merchant'ın doğrudan yazdığı teklif için `merchant`. |
| `total_amount` | integer | her zaman | En küçük para biriminde (kuruş) teklif toplamı. |
| `currency` | string | her zaman | ISO 4217 para birimi kodu. |
| `email` | string | her zaman | Alıcının e-posta adresi. |
| `valid_until` | string (ISO 8601) veya null | her zaman | Önerinin geçerlilik sonu; önerinin süresi dolmuyorsa `null`. |
| `signed_at` | string (ISO 8601) veya null | her zaman | Alıcının kabul ettiği an (her iki kabul modunda); o ana kadar `null`. |

```json title="Payload"
{
  "event": "quote.offered",
  "created_at": "2026-10-24T09:12:44+00:00",
  "data": {
    "id": "6e7f8a9b-0c1d-4e2f-a3b4-c5d6e7f8a9b0",
    "number": "Q-17",
    "status": "offered",
    "origin": "storefront",
    "total_amount": 248900,
    "currency": "EUR",
    "email": "buyer@corp.example",
    "valid_until": "2026-11-07T23:59:59+00:00",
    "signed_at": null
  }
}
```

### quote.accepted

Bir teklif `accepted` olduğunda gönderilir: alıcı [`POST .../accept`](/tr/api/orders-and-quotes/quote-tokens#post-v1apisalesquotesby-tokentokenaccept) ile kabul eder (tıklama veya imza modu) ya da merchant dashboard'da dönüştürür. `signed_at` ayarlanır. Mağazanın ayarına göre teklif bir siparişe (bu durumda `order.created` de gönderilir) veya bir faturaya dönüşmüştür. `data`, `quote.offered` ile aynıdır.

### quote.declined

Alıcı, önerilen bir teklifi `POST .../decline` ile reddettiğinde gönderilir. Reddedilen bir teklif nihai değildir: sonraki yeni bir öneri yeniden `quote.offered` gönderir. `data`, `quote.offered` ile aynıdır.

### quote.expired

`offered` durumundaki bir teklif `valid_until` tarihini geçtiğinde gönderilir: süre dolumu taraması onu bulur ya da o andan sonraki ilk token tabanlı istek durumu değiştirir. Her süre dolumu için bir kez gönderilir. `data`, `quote.offered` ile aynıdır; `status` `expired` olur.

### invoice.sent

Bir merchant bir fatura gönderdiğinde (`kind` `invoice`) gönderilir: numara o anda tahsis edilir ve PDF kilitlenir. Yeniden göndermek olay göndermez. `data` alanları (`invoice.sent`, `invoice.paid`, `invoice.overdue` ve `credit_note.sent` için aynıdır):

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `id` | string (UUID) | her zaman | Fatura kimliği. |
| `number` | string | her zaman | Mağazanın numara serisinden belge numarası. |
| `kind` | string | her zaman | `invoice` veya `credit_note`. |
| `status` | string | her zaman | `draft`, `sent`, `paid`, `overdue`, `cancelled` değerlerinden biri. |
| `total_amount` | integer | her zaman | En küçük para biriminde (kuruş) belge toplamı. |
| `paid_amount` | integer | her zaman | Kaydedilen ödemelerin en küçük para birimindeki toplamı. |
| `open_amount` | integer | her zaman | En küçük para biriminde hâlâ açık olan tutar (`total_amount` eksi `paid_amount`, asla negatif değil). |
| `currency` | string | her zaman | ISO 4217 para birimi kodu. |
| `email` | string | her zaman | Alıcının e-posta adresi. |
| `issue_date` | string (`YYYY-MM-DD`) veya null | her zaman | Düzenleme tarihi. |
| `due_date` | string (`YYYY-MM-DD`) veya null | her zaman | Vade tarihi; alacak dekontunda `null`. |

```json title="Payload"
{
  "event": "invoice.sent",
  "created_at": "2026-10-24T10:03:01+00:00",
  "data": {
    "id": "0f1e2d3c-4b5a-4968-8776-655443322110",
    "number": "F2026-0042",
    "kind": "invoice",
    "status": "sent",
    "total_amount": 248900,
    "paid_amount": 0,
    "open_amount": 248900,
    "currency": "EUR",
    "email": "buyer@corp.example",
    "issue_date": "2026-10-24",
    "due_date": "2026-11-07"
  }
}
```

### invoice.paid

Bir fatura `paid` durumuna geçtiğinde bir kez gönderilir: kaydedilen bir ödeme açık tutarı karşılar ya da merchant faturayı ödendi olarak işaretler. Zaten ödenmiş bir faturaya yapılan ek bir ödeme veya ikinci bir işaretleme hiçbir şey göndermez. `data`, `invoice.sent` ile aynıdır; `status` `paid`, `open_amount` `0` olur.

### invoice.overdue

Vade taraması, `due_date` tarihi geçmiş `sent` durumundaki bir faturayı `overdue` durumuna taşıdığında gönderilir. Her fatura için bir kez gönderilir. `data`, `invoice.sent` ile aynıdır; `status` `overdue` olur.

### credit_note.sent

Bir merchant bir alacak dekontu gönderdiğinde (`kind` `credit_note`) gönderilir; alacak dekontunun kendi numarası o anda tahsis edilir. `data`, `invoice.sent` ile aynıdır; `kind` `credit_note`, `due_date` `null` olur.

### object_record.published

Yayınlanabilir bir [nesne türünün](/tr/api/objects/index) taslak kaydı yayınlandığında gönderilir. Satıcı yayınladığı anda gönderilir, kayıt gelecekteki bir `published_at` için zamanlanmış olsa bile; zamanlamanın dolduğu sonraki an hiçbir şey göndermez. Yayınlanabilir olmayan türlerin kayıtlarının taslak adımı yoktur, bu yüzden yayınlama onlar için bu olayı asla göndermez; böyle bir türde panelde veya API üzerinden kayıt oluşturmak da hiçbir olay göndermez. İstisna `objects:import` komutudur: yayınlanmış olarak oluşturduğu her kayıt, yayınlanabilir olmayan bir türde de `object_record.published` gönderir.

`data` alanları (dört `object_record.*` olayı için de aynıdır):

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `id` | string (UUID) | her zaman | Kayıt ID'si. |
| `type_key` | string | her zaman | Nesne türü anahtarı, örneğin `service`. |
| `slugs` | object | her zaman | Dil etiketinden kaydın o dildeki adresine, örneğin `{"en": "kitchen-fitting"}`. Türün adresi yoksa veya kaydın henüz adresi yoksa boş nesne `{}`. Public API'deki kaydın `slugs` alanında olduğu gibi yalnızca içerik dili olarak etkin olan dilleri içerir. |
| `published_at` | string (ISO 8601) or null | her zaman | Kaydın yayına gireceği veya girdiği an, public API'deki kayıtla aynı biçimde (`2026-10-12T09:30:00.000000Z`). Zamanlanmış bir kayıt için gelecekte. |

Payload hiçbir zaman kayıt değerlerini içermez. Kaydı `GET /v1/api/objects/records/{id}` ile veya adresiyle getirin; bunlar yalnızca kayıt görünür olduğunda yanıt verir.

```json title="Payload"
{
  "event": "object_record.published",
  "created_at": "2026-10-12T09:30:00+00:00",
  "data": {
    "id": "7a8b9c0d-1e2f-4a3b-84c5-d6e7f8a9b0c1",
    "type_key": "service",
    "slugs": { "en": "kitchen-fitting", "nl": "keukenmontage" },
    "published_at": "2026-10-12T09:30:00.000000Z"
  }
}
```

### object_record.updated

Yayınlanmış bir kayıt değiştiğinde gönderilir: değerleri, adresleri veya `published_at` değeri (yayınlanmış bir kaydı yeniden zamanlama ya da tarihini değiştirme). Hiçbir şeyi değiştirmeden kaydetmek bir şey göndermez, bir taslağı düzenlemek de göndermez. Yayınlanmış bir kayıt yine de gelecek için zamanlanmış olabileceğinden, henüz görünür olmayan bir kayıt için gönderilebilir. `data`, `object_record.published` ile aynıdır.

### object_record.unpublished

Yayınlanmış bir kayıt taslağa geri alındığında gönderilir. Yalnızca gerçek bir geçişte gönderilir: zaten taslak olan bir kaydı yayından kaldırmak bir şey göndermez. Sayfayı önbelleğinizden veya sitenizden kaldırın. `data`, `object_record.published` ile aynıdır.

### object_record.deleted

Yayınlanmış bir kayıt silindiğinde gönderilir; toplu silme dahil. Bir taslağı silmek bir şey göndermez, çünkü public API onu hiç sunmadı. `slugs`, kaydın sahip olduğu adresleri taşır; böylece o sayfaları kaldırabilirsiniz. `data`, `object_record.published` ile aynıdır.

### form.published

Bir satıcı bir [formun](/tr/api/forms/index) yeni bir sürümünü her yayınladığında gönderilir. Önbellekteki bir form tanımını yenilemek için kullanın. Formun durumundan bağımsız olarak gönderilir; bu nedenle henüz taslak olan ve yüklenemeyen bir form için de gelebilir. Bir formu etkinleştirmek veya arşivlemek ve ayarlarını kaydetmek hiçbir şey göndermez. `data` alanları:

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `key` | string | her zaman | `GET /v1/api/forms/{key}` içinde kullanılan form anahtarı. |
| `version` | integer | her zaman | Az önce yayınlanan sürüm numarası. |

```json title="Payload"
{
  "event": "form.published",
  "created_at": "2026-10-13T09:30:00+00:00",
  "data": {
    "key": "quote-request",
    "version": 3
  }
}
```

### form_submission.created

Bir ziyaretçinin başvurusu [`POST /v1/api/forms/{key}/submissions`](/tr/api/forms/submissions) üzerinden saklandıktan sonra gönderilir. Honeypot durumunda hiçbir şey saklanmaz ve hiçbir şey gönderilmez. Payload asla yanıtları, dosyaları veya ziyaretçinin adresine dair herhangi bir şeyi içermez; satıcı bunları dashboard'daki gelen kutusunda okur ve public API'de bir başvuruyu getirmek için bir endpoint yoktur. `data` alanları:

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `id` | string (UUID) | her zaman | Başvuru ID'si. |
| `number` | string | her zaman | Başvuru numarası, örneğin `INQ-0042`. |
| `form_key` | string | her zaman | Gönderilen formun anahtarı. |
| `status` | string | her zaman | Oluşturulduğu andaki gelen kutusu durumu, her zaman `new`. Sonraki durumlar (`open`, `done`, `spam`) dashboard'da belirlenir ve olay göndermez. |
| `locale` | string | her zaman | Başvurunun dili: sitenin gönderdiği `locale` mağaza için etkinse o, değilse mağazanın varsayılan dili. |
| `name` | string veya null | her zaman | `name` anahtarlı alanın yanıtı veya `null`. |
| `email` | string veya null | her zaman | Yanıtlanmış ilk `email` alanının yanıtı veya `null`. |
| `created_at` | string (ISO 8601) | her zaman | Başvurunun saklandığı zaman. |

```json title="Payload"
{
  "event": "form_submission.created",
  "created_at": "2026-10-13T09:41:12+00:00",
  "data": {
    "id": "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a",
    "number": "INQ-0042",
    "form_key": "quote-request",
    "status": "new",
    "locale": "en",
    "name": "Ada Lovelace",
    "email": "ada@example.com",
    "created_at": "2026-10-13T09:41:12+00:00"
  }
}
```

### ping (yalnızca test)

Abone olunamaz. Bir merchant dashboard'da "Send test" düğmesine tıkladığında, endpoint'in aboneliklerinden bağımsız olarak tek bir endpoint'e gönderilir. `data` alanları:

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `message` | string | her zaman | Her zaman `"Borkol webhook test"`. |

```json title="Payload"
{
  "event": "ping",
  "created_at": "2026-08-14T12:00:00+00:00",
  "data": {
    "message": "Borkol webhook test"
  }
}
```

## İmzaları doğrulama

Endpoint'in `whsec_` secret'ını kullanarak, tam ham request body üzerinden (herhangi bir JSON ayrıştırma veya yeniden serileştirmeden önce) HMAC-SHA256 hesaplayın, sonucu hex olarak kodlayın ve `X-Borkol-Signature` header'ıyla sabit zamanlı bir karşılaştırma kullanarak karşılaştırın. Farklıysalar isteği reddedin.

```javascript title="Node.js (Express) verification"
import crypto from "node:crypto";
import express from "express";

const app = express();

// Capture the raw body; parsing and re-stringifying breaks the signature.
app.post("/webhooks/borkol", express.raw({ type: "application/json" }), (req, res) => {
  const secret = process.env.BORKOL_WEBHOOK_SECRET; // "whsec_..."
  const signature = req.get("X-Borkol-Signature") ?? "";

  const expected = crypto.createHmac("sha256", secret).update(req.body).digest("hex");

  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!valid) {
    return res.status(401).send("invalid signature");
  }

  const { event, created_at, data } = JSON.parse(req.body.toString("utf8"));
  // Acknowledge fast; do real work asynchronously (the 10 second timeout is strict).
  res.status(200).end();

  // handle(event, data) ...
});
```

```php title="PHP verification"
$secret = getenv('BORKOL_WEBHOOK_SECRET'); // "whsec_..."
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_BORKOL_SIGNATURE'] ?? '';

$expected = hash_hmac('sha256', $rawBody, $secret);

if (! hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$payload = json_decode($rawBody, true);
http_response_code(200);
// handle($payload['event'], $payload['data']) ...
```

Pratikte iki kural önemlidir: imzayı ayrıştırılmış JSON'ın yeniden kodlanmış bir kopyasına karşı değil, aldığınız ham byte'lara karşı doğrulayın; ve `==` yerine sabit zamanlı bir karşılaştırma (`crypto.timingSafeEqual`, `hash_equals`) kullanın.

## Yeniden denemeler ve teslimat kayıtları

Teslimatlar kuyruğa alınır ve asenkron gönderilir. Her teslimat toplamda en fazla 3 kez denenir:

| Deneme | Ne zaman |
| --- | --- |
| 1 | Olaydan hemen sonra. |
| 2 | 1. deneme başarısız olduktan 60 saniye sonra. |
| 3 | 2. deneme başarısız olduktan 300 saniye sonra. |

Bir deneme, 2xx dışı herhangi bir yanıtta, bağlantı hatasında veya zaman aşımında (10 saniye) başarısız olur. Üçüncü başarısız denemeden sonra teslimat `failed` olarak işaretlenir ve bir daha denenmez. Bir teslimatın durumu `pending`'den `success` veya `failed`'e geçer.

Her teslimat `id`, `event`, `status` (`pending`, `success` veya `failed`), `attempts`, `response_status` (sunucunuzun döndürdüğü HTTP durumu, bağlantı hatasında null), `last_attempt_at` ve `created_at` ile kaydedilir. Endpoint başına son 25 teslimat client dashboard'da görüntülenebilir; bir alıcıda hata ayıklarken ilk bakılacak yer burasıdır.

Yeniden denemeler var olduğu için alıcınız idempotent olmalıdır: aynı olay birden fazla kez gelebilir (örneğin sunucunuz olayı işlediği halde 2xx yanıtı kaybolduğunda). Hızlıca 2xx yanıtlayın, sonra işleyin; yanıt vermeden önce yavaş iş yapan bir handler zaman aşımına uğrar ve mükerrer teslimatlara neden olur.
