# Webhooks

Webhooks push platform events from Borkol to your server as they happen, so you do not have to poll. When an order is created, an order changes status, or a stock level is adjusted, Borkol sends an HTTP POST to every active endpoint of the store that is subscribed to that event. Each delivery is signed with a per-endpoint secret so your server can verify it genuinely came from Borkol.

## Configuring endpoints

Webhook endpoints are configured per store in the client dashboard (app.borkol.com) under Settings > Webhooks. Endpoint configuration is not part of the public storefront API. Each endpoint has:

| Field | Type | Description |
| --- | --- | --- |
| `id` | string (UUID) | Endpoint identifier. |
| `url` | string | Destination URL, maximum 2048 characters. Must be `https` except for local development hosts (`localhost`, `127.0.0.1`, `*.test`, `*.localhost`). |
| `events` | array of strings | The events this endpoint subscribes to; any subset of the event catalog below. |
| `is_active` | boolean | Inactive endpoints receive no deliveries. |
| `secret` | string | Signing secret in the format `whsec_` followed by 40 random alphanumeric characters. Shown exactly once at creation and hidden afterwards; store it immediately. |

An event fans out to every active endpoint of the store subscribed to it. The dashboard also offers a "Send test" action that delivers a `ping` event to a single endpoint regardless of its subscriptions.

## Delivery format

Every delivery is an HTTP POST to the endpoint URL with these headers:

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Borkol-Event` | The event name (for example `order.created`). |
| `X-Borkol-Signature` | Lowercase hex HMAC-SHA256 of the exact raw request body, computed with the endpoint's `whsec_` secret. |

The body is always this envelope (JSON encoded with unescaped slashes):

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `event` | string | always | One of `order.created`, `order.status_changed`, `stock.adjusted`, `ping`. |
| `created_at` | string (ISO 8601) | always | Timestamp of the delivery record's creation (when the event was recorded, not when this particular attempt was sent). |
| `data` | object | always | Event-specific payload; see the catalog below. |

```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"
  }
}
```

Your server must respond with any 2xx status within 10 seconds. The response body is ignored. Any non-2xx status, connection error, or timeout counts as a failed attempt and triggers a retry.

## Event catalog

These are the only subscribable events. Retries deliver the same envelope again; deduplicate on your side if processing is not idempotent (an `event` plus `created_at` plus payload identity is a practical key).

### order.created

Sent when an order is created. `data` fields:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | always | Order ID. |
| `number` | integer | always | Per-store sequential order number. |
| `status` | string | always | Order status at creation; one of `pending_payment`, `paid`, `shipped`, `completed`, `cancelled`, `refunded`. |
| `total_amount` | integer | always | Order total in minor units (cents). |
| `currency` | string | always | ISO 4217 currency code. |
| `email` | string | always | Buyer email address. |

```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

Sent when an order transitions to a new status. `data` has the same shape as `order.created`; `status` is the new status.

Order statuses follow this state machine:

| From | Allowed transitions |
| --- | --- |
| `pending_payment` | `paid`, `cancelled` |
| `paid` | `shipped`, `cancelled`, `refunded` |
| `shipped` | `completed`, `refunded` |
| `completed` | `refunded` |
| `cancelled` | none (terminal) |
| `refunded` | none (terminal) |

```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

Sent when a stock level changes. `data` fields:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `product_id` | string (UUID) | always | Product whose stock changed. |
| `variant_id` | string (UUID) or null | always | Variant whose stock changed; null when the product has no variants. |
| `quantity` | integer | always | The new absolute on-hand quantity (not the delta). |

```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
  }
}
```

### ping (test only)

Not subscribable. Sent to a single endpoint when a merchant clicks "Send test" in the dashboard, regardless of the endpoint's subscriptions. `data` fields:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `message` | string | always | Always `"Borkol webhook test"`. |

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

## Verifying signatures

Compute HMAC-SHA256 over the exact raw request body (before any JSON parsing or re-serialization) using the endpoint's `whsec_` secret, hex-encode the result, and compare it to the `X-Borkol-Signature` header with a constant-time comparison. Reject the request if they differ.

```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']) ...
```

Two rules matter in practice: verify against the raw bytes you received, never a re-encoded copy of the parsed JSON; and use a constant-time comparison (`crypto.timingSafeEqual`, `hash_equals`) rather than `==`.

## Retries and delivery records

Deliveries are queued and sent asynchronously. Each delivery is attempted up to 3 times in total:

| Attempt | When |
| --- | --- |
| 1 | Immediately after the event. |
| 2 | 60 seconds after attempt 1 fails. |
| 3 | 300 seconds after attempt 2 fails. |

An attempt fails on any non-2xx response, connection error, or timeout (10 seconds). After the third failed attempt the delivery is marked `failed` and is not retried again. A delivery's status moves from `pending` to either `success` or `failed`.

Each delivery is recorded with `id`, `event`, `status` (`pending`, `success`, or `failed`), `attempts`, `response_status` (the HTTP status your server returned, or null on a connection error), `last_attempt_at`, and `created_at`. The last 25 deliveries per endpoint are viewable in the client dashboard, which is the first place to look when debugging a receiver.

Because retries exist, your receiver must be idempotent: the same event can arrive more than once (for example when your server processed the event but the 2xx response was lost). Respond 2xx quickly, then process; a handler that does slow work before responding will time out and cause duplicate deliveries.
