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 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));
});
}import base64, hashlib, hmac, time
def verify_linkit_webhook(secret: str, headers: dict, raw_body: bytes, tolerance: int = 300) -> bool:
msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > tolerance:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
mac = hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256)
expected = "v1," + base64.b64encode(mac.digest()).decode()
return any(hmac.compare_digest(expected, sig) for sig in headers["webhook-signature"].split())func VerifyLinkitWebhook(secret string, h http.Header, body []byte, now time.Time) bool {
ts, err := strconv.ParseInt(h.Get("webhook-timestamp"), 10, 64)
if err != nil || math.Abs(float64(now.Unix()-ts)) > 300 {
return false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
fmt.Fprintf(mac, "%s.%d.", h.Get("webhook-id"), ts)
mac.Write(body)
expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, sig := range strings.Fields(h.Get("webhook-signature")) {
if hmac.Equal([]byte(sig), []byte(expected)) {
return true
}
}
return false
}public static bool VerifyLinkitWebhook(string secret, string id, string timestamp, byte[] body, string signatureHeader)
{
if (!long.TryParse(timestamp, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return false;
var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret[6..] : secret);
using var hmac = new System.Security.Cryptography.HMACSHA256(key);
var prefix = System.Text.Encoding.UTF8.GetBytes($"{id}.{ts}.");
var expected = "v1," + Convert.ToBase64String(hmac.ComputeHash(prefix.Concat(body).ToArray()));
return signatureHeader.Split(' ').Any(sig => System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.ASCII.GetBytes(sig), System.Text.Encoding.ASCII.GetBytes(expected)));
}function verify_linkit_webhook(string $secret, array $h, string $rawBody, int $tolerance = 300): bool {
$ts = (int) $h['webhook-timestamp'];
if (abs(time() - $ts) > $tolerance) return false;
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = 'v1,' . base64_encode(hash_hmac('sha256', "{$h['webhook-id']}.{$ts}.{$rawBody}", $key, true));
foreach (explode(' ', $h['webhook-signature']) as $sig) {
if (hash_equals($expected, $sig)) return true;
}
return false;
}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.