Linkit

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:

HeaderValue
webhook-idThe event id, e.g. evt_87602_3. The same on every retry and replay — deduplicate on it.
webhook-timestampUnix seconds when this attempt was signed.
webhook-signaturev1,<base64 HMAC-SHA256> — two, space-separated, during a secret rotation.
x-linkit-event-typeThe event type, for routing before you parse the body.
x-linkit-organization-idYour 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 2xx within 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 marked dead.
  • 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 on webhook-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 active again; nothing is lost while it is paused or disabled beyond what reached dead, and dead deliveries can be replayed.
  • Deliveries and their log are kept 30 days.

Manage endpoints

MethodPath
GET/api/v1/webhook-subscriptionsList (never secrets) plus the event types you may filter on.
POST/api/v1/webhook-subscriptionsCreate — 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-secretNew secret, returned once; for 24 hours every delivery is signed with both.
POST/api/v1/webhook-subscriptions/{id}/testQueue a webhook.test event.
GET/api/v1/webhook-subscriptions/{id}/deliveriesThe log: `?status=pending
POST/api/v1/webhook-subscriptions/{id}/deliveries/{deliveryId}/replaySend 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.