Webhooks

Webhooks

Eventnotificaties die Borkol naar je server POST, met de eventcatalogus, delivery-semantiek, HMAC-signatuurverificatie en retry-gedrag.

Bekijk als Markdown

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:

VeldTypeBeschrijving
idstring (UUID)Endpoint-identifier.
urlstringBestemmings-URL, maximaal 2048 tekens. Moet https zijn, behalve voor lokale ontwikkelhosts (localhost, 127.0.0.1, *.test, *.localhost).
eventsarray van stringsDe events waarop dit endpoint is geabonneerd; elke deelverzameling van de eventcatalogus hieronder.
is_activebooleanInactieve endpoints ontvangen geen deliveries.
secretstringSigning 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:

HeaderWaarde
Content-Typeapplication/json
X-Borkol-EventDe eventnaam (bijvoorbeeld order.created).
X-Borkol-SignatureLowercase 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):

NaamTypeVerplichtBeschrijving
eventstringaltijdEé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_atstring (ISO 8601)altijdTimestamp van het aanmaken van het delivery-record (wanneer het event werd geregistreerd, niet wanneer deze specifieke poging is verstuurd).
dataobjectaltijdEvent-specifieke payload; zie de catalogus hieronder.
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:

NaamTypeVerplichtBeschrijving
idstring (UUID)altijdBestelling-ID.
numberintegeraltijdSequentieel bestelnummer per winkel.
statusstringaltijdBestelstatus bij aanmaken; één van pending_payment, paid, shipped, completed, cancelled, refunded.
total_amountintegeraltijdBesteltotaal in minor units (centen).
currencystringaltijdISO 4217-valutacode.
emailstringaltijdE-mailadres van de koper.
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:

VanToegestane overgangen
pending_paymentpaid, cancelled
paidshipped, cancelled, refunded
shippedcompleted, refunded
completedrefunded
cancelledgeen (terminaal)
refundedgeen (terminaal)
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:

NaamTypeVerplichtBeschrijving
product_idstring (UUID)altijdProduct waarvan de voorraad is veranderd.
variant_idstring (UUID) of nullaltijdVariant waarvan de voorraad is veranderd; null wanneer het product geen varianten heeft.
quantitynumberaltijdDe 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.
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 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):

NaamTypeVerplichtBeschrijving
idstring (UUID)altijdOfferte-ID.
numberstringaltijdOffertenummer uit de nummerreeks van de winkel, bijvoorbeeld Q-17.
statusstringaltijdOffertestatus op het moment van het event; één van draft, submitted, offered, accepted, declined, expired, cancelled.
originstringaltijdstorefront voor een offerte die als winkelwagenaanvraag begon, merchant voor een offerte die de merchant zelf schreef.
total_amountintegeraltijdOffertetotaal in de kleinste munteenheid (centen).
currencystringaltijdISO 4217-valutacode.
emailstringaltijdE-mailadres van de koper.
valid_untilstring (ISO 8601) of nullaltijdEinde van de geldigheid van het aanbod, of null wanneer het aanbod niet verloopt.
signed_atstring (ISO 8601) of nullaltijdWanneer de koper accepteerde (in beide acceptatiemodi); null tot dan.
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 (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):

NaamTypeVerplichtBeschrijving
idstring (UUID)altijdFactuur-ID.
numberstringaltijdDocumentnummer uit de nummerreeks van de winkel.
kindstringaltijdinvoice of credit_note.
statusstringaltijdEén van draft, sent, paid, overdue, cancelled.
total_amountintegeraltijdDocumenttotaal in de kleinste munteenheid (centen).
paid_amountintegeraltijdSom van de geregistreerde betalingen in de kleinste munteenheid.
open_amountintegeraltijdWat nog openstaat in de kleinste munteenheid (total_amount min paid_amount, nooit negatief).
currencystringaltijdISO 4217-valutacode.
emailstringaltijdE-mailadres van de ontvanger.
issue_datestring (YYYY-MM-DD) of nullaltijdFactuurdatum.
due_datestring (YYYY-MM-DD) of nullaltijdVervaldatum; null op een creditnota.
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 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):

NaamTypeVerplichtBeschrijving
idstring (UUID)altijdRecord-ID.
type_keystringaltijdDe objecttype-key, bijvoorbeeld service.
slugsobjectaltijdTaaltag 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_atstring (ISO 8601) of nullaltijdWanneer 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.

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

NaamTypeVerplichtBeschrijving
keystringaltijdDe key van het formulier, zoals gebruikt in GET /v1/api/forms/{key}.
versionintegeraltijdHet versienummer dat zojuist is gepubliceerd.
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. 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:

NaamTypeVerplichtBeschrijving
idstring (UUID)altijdID van de inzending.
numberstringaltijdNummer van de inzending, bijvoorbeeld INQ-0042.
form_keystringaltijdDe key van het ingevulde formulier.
statusstringaltijdInboxstatus bij het aanmaken, altijd new. Latere statussen (open, done, spam) worden in het dashboard gezet en versturen geen event.
localestringaltijdDe taal van de inzending: de locale die de site meestuurde als die is ingeschakeld voor de winkel, anders de standaardtaal van de winkel.
namestring of nullaltijdHet antwoord op het veld met key name, of null.
emailstring of nullaltijdHet antwoord op het eerste beantwoorde email-veld, of null.
created_atstring (ISO 8601)altijdWanneer de inzending is opgeslagen.
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:

NaamTypeVerplichtBeschrijving
messagestringaltijdAltijd "Borkol webhook test".
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.

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

PogingWanneer
1Onmiddellijk na het event.
260 seconden nadat poging 1 is mislukt.
3300 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.