Webhooks
Have every order event pushed to your endpoint, signed per Standard Webhooks, retried and replayable
Webhooks
Register an HTTPS endpoint and Linkit pushes every order event to it: an order created, its status changed, cancelled, refunded, invoiced at the till, updated or deleted — from every channel your organization sells on. The events are the same objects the change feed returns, so a webhook consumer and a poller are interchangeable.
Endpoints are managed in the dashboard under Developer → Webhooks, or through the API below. Deliveries follow the Standard Webhooks specification, so any of its libraries verifies them.
Register an endpoint
POST /api/v1/webhook-subscriptions{
"url": "https://erp.example.com/linkit/webhooks",
"description": "ERP order intake",
"event_types": ["order.created", "order.status_changed", "order.cancelled"]
}event_types empty (or omitted) means every order event, including types added later. The URL must be https, and it may not point at a private, loopback or link-local address — this is checked when you register it and again, against every address the name resolves to, before each delivery.
201 returns the subscription and its signing secret, this once:
{
"id": "k3m9x2p7q1w8e5r",
"organization_id": "3xrde8yfgeqeaas",
"url": "https://erp.example.com/linkit/webhooks",
"description": "ERP order intake",
"event_types": ["order.cancelled", "order.created", "order.status_changed"],
"status": "active",
"disabled_reason": "",
"secret_hint": "Qw==",
"rotating": false,
"created": "2026-09-29T05:00:00.000Z",
"updated": "2026-09-29T05:00:00.000Z",
"last_success_at": "",
"last_failure_at": "",
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}Store secret now. It is never returned again — the list and detail routes show only secret_hint, its last four characters. Lost it? Rotate.
A new endpoint receives changes from the moment it is created. At most 20 endpoints per organization.
What is sent
POST to your URL with Content-Type: application/json and these headers:
| Header | Value |
|---|---|
webhook-id | The event id, e.g. evt_87602_3. The same on every retry and replay — deduplicate on it. |
webhook-timestamp | Unix seconds when this attempt was signed. |
webhook-signature | v1,<base64 HMAC-SHA256> — two, space-separated, during a secret rotation. |
x-linkit-event-type | The event type, for routing before you parse the body. |
x-linkit-organization-id | Your organization id. |
The body is one order event:
{
"spec_version": "2026-09-29",
"id": "evt_87602_3",
"type": "order.created",
"occurred_at": "2026-09-29T05:12:44.120331Z",
"organization_id": "3xrde8yfgeqeaas",
"cursor": "djEuODc2MDIuMw",
"order_id": "r1234567890abcd",
"change": "created",
"changed_fields": [],
"status_from": "",
"status_to": "received",
"payment_status_from": "",
"payment_status_to": "paid",
"order": {
"id": "r1234567890abcd",
"channel": { "app_slug": "hungerstation-orders-v2", "family": "hungerstation", "kind": "delivery", "...": "..." },
"external": { "order_id": "HS-123456", "reference": "123456", "code": "A7", "placed_at": "2026-09-29T05:12:40Z" },
"status": "received",
"status_raw": "RECEIVED",
"payment": { "status": "paid", "status_raw": "paid", "method": "PAID" },
"amounts": { "currency": "SAR", "subtotal": 40, "discounts": 0, "vat": 6, "delivery_fee": 0, "service_fee": 0, "total": 46 },
"lines": [{ "product_id": "100234", "sku_id": "s1", "name": "Panadol 500mg", "quantity": 2, "unit_price": 20, "total": 40 }],
"...": "the full canonical order — see Order changes"
}
}The event types — including order.departed, sent when an order moves to another branch — and the canonical order are documented on Order changes. A test ping (POST …/{id}/test) sends "type": "webhook.test" with no order.
Verify the signature
Compute base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}")), where key is the base64 decoding of the secret after its whsec_ prefix, and compare it — in constant time — with each v1, value in webhook-signature. Reject a timestamp more than five minutes from your clock: that is what stops a captured delivery being replayed at you. Verify the raw bytes you received, before any JSON parsing.
import crypto from 'node:crypto';
export function verifyLinkitWebhook(secret, headers, rawBody, toleranceSec = 300) {
const id = headers['webhook-id'];
const ts = Number(headers['webhook-timestamp']);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.`).update(rawBody).digest('base64');
return headers['webhook-signature'].split(' ').some((candidate) => {
const [version, signature] = candidate.split(',');
return version === 'v1' && signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
});
}Every Linkit SDK ships this as a helper — Go linkit.VerifyWebhook, Rust linkit::webhooks::verify, Python linkit.verify_webhook, TypeScript verifyWebhook, .NET LinkitWebhook.Verify, Kotlin/Swift/Dart LinkitWebhook.verify, C++ linkit::verify_webhook, Zig linkit.webhooks.verify — and Standard Webhooks' own libraries work unchanged.
Retries, ordering and failure
- A
2xxwithin 10 seconds is a delivery. Anything else — another status, a timeout, a refused connection — is retried after 10 s, 1 min, 5 min, 30 min, 2 h, 8 h and 24 h (8 attempts, about 35 hours), then the delivery is markeddead. - Every delivery retries on its own clock: one failing event never holds back the next. So events can arrive out of order — order them by
occurred_at/cursor, and deduplicate onwebhook-id. - Redirects are not followed. Answer fast and do your work afterwards.
- An endpoint with no successful delivery for three days is disabled, with the reason on the subscription and in the dashboard. Fix it and set it
activeagain; nothing is lost while it is paused or disabled beyond what reacheddead, and dead deliveries can be replayed. - Deliveries and their log are kept 30 days.
Manage endpoints
| Method | Path | |
|---|---|---|
GET | /api/v1/webhook-subscriptions | List (never secrets) plus the event types you may filter on. |
POST | /api/v1/webhook-subscriptions | Create — returns the secret once. |
GET | /api/v1/webhook-subscriptions/{id} | One endpoint. |
PATCH | /api/v1/webhook-subscriptions/{id} | Any of url, description, event_types, status (active / paused). |
DELETE | /api/v1/webhook-subscriptions/{id} | Delete the endpoint and its log. |
POST | /api/v1/webhook-subscriptions/{id}/rotate-secret | New secret, returned once; for 24 hours every delivery is signed with both. |
POST | /api/v1/webhook-subscriptions/{id}/test | Queue a webhook.test event. |
GET | /api/v1/webhook-subscriptions/{id}/deliveries | The log: `?status=pending |
POST | /api/v1/webhook-subscriptions/{id}/deliveries/{deliveryId}/replay | Send a delivery again now (same webhook-id). |
These routes need the webhook permission (read, create, update, delete). Organization owners and members hold it; read-only members can list endpoints and their logs but not change them. An endpoint receives every branch's events (an order moving between branches arrives as order.departed for the branch it left, then an update for the one it joined), so an account confined to one branch is refused all of these routes (403).