# Webhooks

Webhooks pushen platform-events van Borkol naar je server op het moment dat ze gebeuren, zodat je niet hoeft te pollen. Wanneer een bestelling wordt aangemaakt, een bestelling van status verandert, een voorraadniveau wordt aangepast, een offerte wordt aangeboden, geaccepteerd, afgewezen of verloopt, een factuur of creditnota wordt verstuurd, een factuur wordt betaald of vervalt, een contentrecord wordt gepubliceerd, een formulier wordt gepubliceerd of een bezoeker een formulier verstuurt, stuurt Borkol een HTTP POST naar elk actief endpoint van de winkel dat op dat event is geabonneerd. Elke delivery wordt ondertekend met een secret per endpoint, zodat je server kan verifiëren dat hij echt van Borkol komt.

## Endpoints configureren

Webhook-endpoints worden per winkel geconfigureerd in het client-dashboard (app.borkol.com) onder Settings > Webhooks. Endpoint-configuratie is geen onderdeel van de publieke storefront-API. Elk endpoint heeft:

| Veld | Type | Beschrijving |
| --- | --- | --- |
| `id` | string (UUID) | Endpoint-identifier. |
| `url` | string | Bestemmings-URL, maximaal 2048 tekens. Moet `https` zijn, behalve voor lokale ontwikkelhosts (`localhost`, `127.0.0.1`, `*.test`, `*.localhost`). |
| `events` | array van strings | De events waarop dit endpoint is geabonneerd; elke deelverzameling van de eventcatalogus hieronder. |
| `is_active` | boolean | Inactieve endpoints ontvangen geen deliveries. |
| `secret` | string | Signing secret in het formaat `whsec_` gevolgd door 40 willekeurige alfanumerieke tekens. Wordt precies één keer getoond bij het aanmaken en daarna verborgen; sla hem direct op. |

Een event waaiert uit naar elk actief endpoint van de winkel dat erop geabonneerd is. Het dashboard biedt ook een "Send test"-actie die een `ping`-event aflevert bij één endpoint, ongeacht zijn abonnementen.

## Delivery-formaat

Elke delivery is een HTTP POST naar de endpoint-URL met deze headers:

| Header | Waarde |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Borkol-Event` | De eventnaam (bijvoorbeeld `order.created`). |
| `X-Borkol-Signature` | Lowercase hex HMAC-SHA256 van de exacte ruwe request body, berekend met het `whsec_`-secret van het endpoint. |

De body is altijd deze envelope (JSON gecodeerd met unescaped slashes):

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `event` | string | altijd | Eén van `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) | altijd | Timestamp van het aanmaken van het delivery-record (wanneer het event werd geregistreerd, niet wanneer deze specifieke poging is verstuurd). |
| `data` | object | altijd | Event-specifieke payload; zie de catalogus hieronder. |

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

Je server moet binnen 10 seconden met een willekeurige 2xx-status antwoorden. De response body wordt genegeerd. Elke niet-2xx-status, verbindingsfout of timeout telt als een mislukte poging en triggert een retry.

## Eventcatalogus

Dit zijn de enige abonneerbare events. Retries leveren dezelfde envelope opnieuw af; dedupliceer aan jouw kant als je verwerking niet idempotent is (een `event` plus `created_at` plus payload-identiteit is een praktische sleutel).

### order.created

Wordt verstuurd wanneer een bestelling wordt aangemaakt. `data`-velden:

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `id` | string (UUID) | altijd | Bestelling-ID. |
| `number` | integer | altijd | Sequentieel bestelnummer per winkel. |
| `status` | string | altijd | Bestelstatus bij aanmaken; één van `pending_payment`, `paid`, `shipped`, `completed`, `cancelled`, `refunded`. |
| `total_amount` | integer | altijd | Besteltotaal in minor units (centen). |
| `currency` | string | altijd | ISO 4217-valutacode. |
| `email` | string | altijd | E-mailadres van de koper. |

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

Wordt verstuurd wanneer een bestelling naar een nieuwe status overgaat. `data` heeft dezelfde vorm als bij `order.created`; `status` is de nieuwe status.

Bestelstatussen volgen deze state machine:

| Van | Toegestane overgangen |
| --- | --- |
| `pending_payment` | `paid`, `cancelled` |
| `paid` | `shipped`, `cancelled`, `refunded` |
| `shipped` | `completed`, `refunded` |
| `completed` | `refunded` |
| `cancelled` | geen (terminaal) |
| `refunded` | geen (terminaal) |

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

Wordt verstuurd wanneer een voorraadniveau verandert. `data`-velden:

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `product_id` | string (UUID) | altijd | Product waarvan de voorraad is veranderd. |
| `variant_id` | string (UUID) of null | altijd | Variant waarvan de voorraad is veranderd; null wanneer het product geen varianten heeft. |
| `quantity` | number | altijd | De nieuwe absolute voorraadhoeveelheid (niet de delta), in de `unit` van het product. Kan decimalen hebben (bijvoorbeeld `47.84`) voor producten die per gewicht, lengte, oppervlakte of volume verkocht worden. |

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

Verstuurd wanneer een merchant een aanbod op een [offerte](/nl/api/orders-and-quotes/quote-tokens) verstuurt (status wordt `offered`). Hetzelfde aanbod opnieuw versturen stuurt geen event. De payload bevat nooit het acceptatietoken: de link van de koper wordt alleen gemaild. `data`-velden (gelijk voor alle vier de `quote.*`-events):

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `id` | string (UUID) | altijd | Offerte-ID. |
| `number` | string | altijd | Offertenummer uit de nummerreeks van de winkel, bijvoorbeeld `Q-17`. |
| `status` | string | altijd | Offertestatus op het moment van het event; één van `draft`, `submitted`, `offered`, `accepted`, `declined`, `expired`, `cancelled`. |
| `origin` | string | altijd | `storefront` voor een offerte die als winkelwagenaanvraag begon, `merchant` voor een offerte die de merchant zelf schreef. |
| `total_amount` | integer | altijd | Offertetotaal in de kleinste munteenheid (centen). |
| `currency` | string | altijd | ISO 4217-valutacode. |
| `email` | string | altijd | E-mailadres van de koper. |
| `valid_until` | string (ISO 8601) of null | altijd | Einde van de geldigheid van het aanbod, of `null` wanneer het aanbod niet verloopt. |
| `signed_at` | string (ISO 8601) of null | altijd | Wanneer de koper accepteerde (in beide acceptatiemodi); `null` tot dan. |

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

Verstuurd wanneer een offerte `accepted` wordt: de koper accepteert via [`POST .../accept`](/nl/api/orders-and-quotes/quote-tokens#post-v1apisalesquotesby-tokentokenaccept) (klik- of handtekeningmodus), of de merchant zet hem om in het dashboard. `signed_at` is gezet. Afhankelijk van de instelling van de winkel is de offerte een bestelling geworden (dan wordt ook `order.created` verstuurd) of een factuur. `data` als bij `quote.offered`.

### quote.declined

Verstuurd wanneer de koper een aangeboden offerte afwijst via `POST .../decline`. Een afgewezen offerte is niet definitief: een later nieuw aanbod stuurt opnieuw `quote.offered`. `data` als bij `quote.offered`.

### quote.expired

Verstuurd wanneer een `offered` offerte haar `valid_until` passeert: de verloopsweep vindt haar, of het eerste by-token-request na dat moment zet haar om. Eén keer per verloop verstuurd. `data` als bij `quote.offered`, met `status` `expired`.

### invoice.sent

Verstuurd wanneer een merchant een factuur verstuurt (`kind` `invoice`): op dat moment wordt het nummer toegekend en de PDF vastgezet. Opnieuw versturen stuurt geen event. `data`-velden (gelijk voor `invoice.sent`, `invoice.paid`, `invoice.overdue` en `credit_note.sent`):

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `id` | string (UUID) | altijd | Factuur-ID. |
| `number` | string | altijd | Documentnummer uit de nummerreeks van de winkel. |
| `kind` | string | altijd | `invoice` of `credit_note`. |
| `status` | string | altijd | Eén van `draft`, `sent`, `paid`, `overdue`, `cancelled`. |
| `total_amount` | integer | altijd | Documenttotaal in de kleinste munteenheid (centen). |
| `paid_amount` | integer | altijd | Som van de geregistreerde betalingen in de kleinste munteenheid. |
| `open_amount` | integer | altijd | Wat nog openstaat in de kleinste munteenheid (`total_amount` min `paid_amount`, nooit negatief). |
| `currency` | string | altijd | ISO 4217-valutacode. |
| `email` | string | altijd | E-mailadres van de ontvanger. |
| `issue_date` | string (`YYYY-MM-DD`) of null | altijd | Factuurdatum. |
| `due_date` | string (`YYYY-MM-DD`) of null | altijd | Vervaldatum; `null` op een creditnota. |

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

Eén keer verstuurd, wanneer een factuur naar `paid` gaat: een geregistreerde betaling dekt het openstaande bedrag, of de merchant markeert haar als betaald. Een extra betaling of een tweede markering op een al betaalde factuur stuurt niets. `data` als bij `invoice.sent`, met `status` `paid` en `open_amount` `0`.

### invoice.overdue

Verstuurd wanneer de vervalsweep een `sent` factuur waarvan de `due_date` is verstreken naar `overdue` zet. Eén keer per factuur verstuurd. `data` als bij `invoice.sent`, met `status` `overdue`.

### credit_note.sent

Verstuurd wanneer een merchant een creditnota verstuurt (`kind` `credit_note`); op dat moment wordt het eigen nummer van de creditnota toegekend. `data` als bij `invoice.sent`, met `kind` `credit_note` en `due_date` `null`.

### object_record.published

Verstuurd wanneer een conceptrecord van een publiceerbaar [objecttype](/nl/api/objects/index) wordt gepubliceerd. Het gaat af op het moment dat een merchant publiceert, ook wanneer het record voor een toekomstige `published_at` is ingepland; het latere moment waarop de planning verloopt, verstuurt niets. Records van types zonder publicatiestap hebben geen conceptstap, dus publiceren verstuurt het voor hen nooit, en een record aanmaken op zo'n type in het dashboard of via de API verstuurt helemaal geen event. Het commando `objects:import` is de uitzondering: elk record dat het als gepubliceerd aanmaakt, verstuurt `object_record.published`, ook op een type zonder publicatiestap.

`data`-velden (hetzelfde voor alle vier de `object_record.*`-events):

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `id` | string (UUID) | altijd | Record-ID. |
| `type_key` | string | altijd | De objecttype-key, bijvoorbeeld `service`. |
| `slugs` | object | altijd | Taaltag naar het adres van het record in die taal, bijvoorbeeld `{"en": "kitchen-fitting"}`. Een leeg object `{}` wanneer het type geen adressen heeft of het record er nog geen heeft. Alleen talen die als contenttaal zijn ingeschakeld, net als de `slugs` van het record in de publieke API. |
| `published_at` | string (ISO 8601) of null | altijd | Wanneer het record live gaat of ging, in hetzelfde formaat als het record in de publieke API (`2026-10-12T09:30:00.000000Z`). In de toekomst voor een ingepland record. |

De payload bevat nooit recordwaarden. Haal het record op met `GET /v1/api/objects/records/{id}` of via zijn adres; die antwoorden pas zodra het record zichtbaar is.

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

Verstuurd wanneer een gepubliceerd record verandert: zijn waarden, zijn adressen of zijn `published_at` (opnieuw inplannen of de datum van een gepubliceerd record wijzigen). Opslaan zonder iets te wijzigen verstuurt niets, en een concept bewerken ook niet. Omdat een gepubliceerd record nog voor de toekomst ingepland kan zijn, kan dit event gaan voor een record dat nog niet zichtbaar is. `data` zoals bij `object_record.published`.

### object_record.unpublished

Verstuurd wanneer een gepubliceerd record weer concept wordt. Alleen bij een echte overgang: een record depubliceren dat al een concept is, verstuurt niets. Haal de pagina uit je cache of site. `data` zoals bij `object_record.published`.

### object_record.deleted

Verstuurd wanneer een record dat gepubliceerd is, wordt verwijderd, ook via bulk verwijderen. Een concept verwijderen verstuurt niets, omdat de publieke API het nooit toonde. `slugs` bevat de adressen die het record had, zodat je die pagina's kunt verwijderen. `data` zoals bij `object_record.published`.

### form.published

Wordt verstuurd elke keer dat een merchant een nieuwe versie van een [formulier](/nl/api/forms/index) publiceert. Gebruik het om een gecachte formulierdefinitie te verversen. Het wordt verstuurd ongeacht de status van het formulier, dus het kan binnenkomen voor een formulier dat nog een concept is en nog niet kan worden geladen. Een formulier activeren of archiveren en de instellingen opslaan versturen niets. Velden van `data`:

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `key` | string | altijd | De key van het formulier, zoals gebruikt in `GET /v1/api/forms/{key}`. |
| `version` | integer | altijd | Het versienummer dat zojuist is gepubliceerd. |

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

### form_submission.created

Wordt verstuurd nadat de inzending van een bezoeker is opgeslagen via [`POST /v1/api/forms/{key}/submissions`](/nl/api/forms/submissions). Een honeypot-hit slaat niets op en verstuurt niets. De payload bevat nooit de antwoorden, bestanden of iets over het adres van de bezoeker; de merchant leest die in de inbox van het dashboard, en de publieke API heeft geen endpoint om een inzending op te halen. Velden van `data`:

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `id` | string (UUID) | altijd | ID van de inzending. |
| `number` | string | altijd | Nummer van de inzending, bijvoorbeeld `INQ-0042`. |
| `form_key` | string | altijd | De key van het ingevulde formulier. |
| `status` | string | altijd | Inboxstatus bij het aanmaken, altijd `new`. Latere statussen (`open`, `done`, `spam`) worden in het dashboard gezet en versturen geen event. |
| `locale` | string | altijd | De taal van de inzending: de `locale` die de site meestuurde als die is ingeschakeld voor de winkel, anders de standaardtaal van de winkel. |
| `name` | string of null | altijd | Het antwoord op het veld met key `name`, of `null`. |
| `email` | string of null | altijd | Het antwoord op het eerste beantwoorde `email`-veld, of `null`. |
| `created_at` | string (ISO 8601) | altijd | Wanneer de inzending is opgeslagen. |

```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 (alleen voor tests)

Niet abonneerbaar. Wordt naar één endpoint gestuurd wanneer een merchant in het dashboard op "Send test" klikt, ongeacht de abonnementen van het endpoint. `data`-velden:

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `message` | string | altijd | Altijd `"Borkol webhook test"`. |

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

## Signaturen verifiëren

Bereken HMAC-SHA256 over de exacte ruwe request body (vóór enige JSON-parsing of herserialisatie) met het `whsec_`-secret van het endpoint, hex-encodeer het resultaat en vergelijk het met de `X-Borkol-Signature`-header via een constant-time vergelijking. Wijs de request af als ze verschillen.

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

Twee regels zijn in de praktijk belangrijk: verifieer tegen de ruwe bytes die je hebt ontvangen, nooit tegen een opnieuw gecodeerde kopie van de geparseerde JSON; en gebruik een constant-time vergelijking (`crypto.timingSafeEqual`, `hash_equals`) in plaats van `==`.

## Retries en delivery-records

Deliveries worden in een queue geplaatst en asynchroon verstuurd. Elke delivery wordt in totaal maximaal 3 keer geprobeerd:

| Poging | Wanneer |
| --- | --- |
| 1 | Onmiddellijk na het event. |
| 2 | 60 seconden nadat poging 1 is mislukt. |
| 3 | 300 seconden nadat poging 2 is mislukt. |

Een poging mislukt bij elke niet-2xx-response, verbindingsfout of timeout (10 seconden). Na de derde mislukte poging wordt de delivery gemarkeerd als `failed` en niet opnieuw geprobeerd. De status van een delivery gaat van `pending` naar ofwel `success` ofwel `failed`.

Elke delivery wordt vastgelegd met `id`, `event`, `status` (`pending`, `success` of `failed`), `attempts`, `response_status` (de HTTP-status die je server teruggaf, of null bij een verbindingsfout), `last_attempt_at` en `created_at`. De laatste 25 deliveries per endpoint zijn zichtbaar in het client-dashboard; dat is de eerste plek om te kijken bij het debuggen van een receiver.

Omdat retries bestaan, moet je receiver idempotent zijn: hetzelfde event kan meer dan één keer binnenkomen (bijvoorbeeld wanneer je server het event verwerkte maar de 2xx-response verloren ging). Antwoord snel met 2xx en verwerk daarna; een handler die traag werk doet vóór het antwoorden loopt tegen de timeout aan en veroorzaakt dubbele deliveries.
