borkoldocs

Webhooks

Webhooks

Event notifications Borkol POSTs to your server, with the event catalog, delivery semantics, HMAC signature verification, and retry behavior.

View as Markdown

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:

FieldTypeDescription
idstring (UUID)Endpoint identifier.
urlstringDestination URL, maximum 2048 characters. Must be https except for local development hosts (localhost, 127.0.0.1, *.test, *.localhost).
eventsarray of stringsThe events this endpoint subscribes to; any subset of the event catalog below.
is_activebooleanInactive endpoints receive no deliveries.
secretstringSigning 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:

HeaderValue
Content-Typeapplication/json
X-Borkol-EventThe event name (for example order.created).
X-Borkol-SignatureLowercase 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):

NameTypeRequiredDescription
eventstringalwaysOne of order.created, order.status_changed, stock.adjusted, ping.
created_atstring (ISO 8601)alwaysTimestamp of the delivery record's creation (when the event was recorded, not when this particular attempt was sent).
dataobjectalwaysEvent-specific payload; see the catalog below.
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:

NameTypeRequiredDescription
idstring (UUID)alwaysOrder ID.
numberintegeralwaysPer-store sequential order number.
statusstringalwaysOrder status at creation; one of pending_payment, paid, shipped, completed, cancelled, refunded.
total_amountintegeralwaysOrder total in minor units (cents).
currencystringalwaysISO 4217 currency code.
emailstringalwaysBuyer email address.
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:

FromAllowed transitions
pending_paymentpaid, cancelled
paidshipped, cancelled, refunded
shippedcompleted, refunded
completedrefunded
cancellednone (terminal)
refundednone (terminal)
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:

NameTypeRequiredDescription
product_idstring (UUID)alwaysProduct whose stock changed.
variant_idstring (UUID) or nullalwaysVariant whose stock changed; null when the product has no variants.
quantityintegeralwaysThe new absolute on-hand quantity (not the delta).
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:

NameTypeRequiredDescription
messagestringalwaysAlways "Borkol webhook test".
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.

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

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:

AttemptWhen
1Immediately after the event.
260 seconds after attempt 1 fails.
3300 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.