Webhooks
Webhooks
Eventnotificaties die Borkol naar je server POST, met de eventcatalogus, delivery-semantiek, HMAC-signatuurverificatie en retry-gedrag.
Bekijk als MarkdownWebhooks 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. |
{
"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. |
{
"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) |
{
"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. |
{
"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):
| 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. |
{
"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):
| 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. |
{
"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):
| 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.
{
"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:
| 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. |
{
"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:
| 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. |
{
"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". |
{
"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.
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) ...
});$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.