Webhooks
Webhooks
Event notifications Borkol POSTs to your server, with the event catalog, delivery semantics, HMAC signature verification, and retry behavior.
View as MarkdownWebhooks 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. |
{
"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. |
{
"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) |
{
"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). |
{
"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". |
{
"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.
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']) ...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.