↑↓select↵openescclose

Events

Webhooks

Signed delivery, retries, signature verification, event types and ordering.

10.1 Delivery

We POST each event to your webhook URL:

Text
Content-Type: application/json
User-Agent: horeca24-partner-webhooks/1
X-Partner-Event-Id: evt_6f1c…
X-Partner-Event-Type: order.accepted
X-Partner-Signature: t=1792065600,v1=5d41…
JSON
{ "id": "evt_6f1c…", "type": "order.accepted", "createdAt": "2026-10-15T10:05:00.000Z",
  "connectionId": "pc_3f1c0a9e5b7d2c4f6a81", "data": { … } }
  • Answer 2xx within 3 seconds, as soon as you have stored the event. Do the work afterwards. We read at most 4 KiB of your answer.
  • Retried: timeouts, network errors, 429 and 5xx. A Retry-After header (seconds or HTTP date) is honoured; otherwise we retry after about 1, 2 and 10 minutes, then every 30 minutes (±10 % jitter), for 24 hours after the event was created.
  • Not retried: any other 4xx, every 3xx (redirects are not followed), and a URL that fails the address check. Such events, and events still undelivered after 24 hours, are marked as failed. Our support is alerted and can send them again.
  • Delivery is at least once and unordered. Deduplicate by id.
  • Order events are each delivered; none is skipped or merged.
  • Connection events (menu.updated, store.status_changed, connection.revoked) are coalesced by type: while one is waiting, a newer one of the same type replaces it. You receive the latest state, not every step in between.

10.2 Verifying the signature

Text
X-Partner-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>[,v1=…]

There is one v1= per active secret, so two during a secret rotation. The signature is computed anew at every attempt, so a retry carries a fresh t.

  1. Take the raw request body, before any JSON parsing.
  2. Read t and every v1 from the header.
  3. Reject the request if t is more than 300 seconds away from your clock.
  4. Compute HMAC-SHA256(secret, t + "." + rawBody) as hex with each secret you hold (the whole whsec_… string is the key). Accept if any v1 matches, using a constant-time comparison.
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(header, secrets, rawBody, toleranceSeconds = 300) {
  let t = NaN;
  const signatures = [];
  for (const part of (header ?? "").split(",")) {
    const [key, value] = part.trim().split("=", 2);
    if (key === "t") t = Number(value);
    else if (key === "v1" && /^[0-9a-f]{64}$/.test(value ?? "")) signatures.push(Buffer.from(value, "hex"));
  }
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  return secrets.some(secret => {
    const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
    return signatures.some(signature => timingSafeEqual(signature, expected));
  });
}
import hashlib, hmac, re, time

def verify(header: str, secrets: list[str], raw_body: bytes, tolerance: int = 300) -> bool:
    t, signatures = None, []
    for part in (header or "").split(","):
        key, _, value = part.strip().partition("=")
        if key == "t" and value.isdigit():
            t = int(value)
        elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
            signatures.append(value)
    if t is None or abs(time.time() - t) > tolerance:
        return False
    for secret in secrets:
        expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, s) for s in signatures):
            return True
    return False

10.3 Event types

Typedata
order.accepted{orderId, externalId, version, status: "accepted", occurredAt, pickupAt?, deliveryAt?, prepMinutes?}
order.ready{orderId, externalId, version, status: "ready", occurredAt}
order.completed{orderId, externalId, version, status: "completed", occurredAt}
order.rejected{orderId, externalId, version, status: "rejected", occurredAt, reason: {code, message}}
order.cancelled{orderId, externalId, version, status: "cancelled", occurredAt, reason: {code, message}}
menu.updated{url, hash, publishedAt, updatedAt}, see §7.2
store.status_changed`{status: "online" \"offline", reason?, seq}`
connection.revoked{connectionId, seq}
  • order.accepted: pickupAt is the time the restaurant promised, deliveryAt its drop-off time (deliveries only), prepMinutes the minutes from acceptance to pickupAt. They are present when the restaurant set a time.
  • Reason codes: RESTAURANT_REJECTED, RESTAURANT_CANCELLED (with the restaurant's text or a default message), and PARTNER_REQUESTED (your own cancel).
  • connection.revoked is sent once, just before the connection is revoked, and is not retried. Afterwards your key gets 403 CONNECTION_REVOKED.

Example order.accepted:

JSON
{ "id": "evt_6f1c…", "type": "order.accepted", "createdAt": "2026-10-15T10:05:00.000Z",
  "connectionId": "pc_3f1c0a9e5b7d2c4f6a81",
  "data": { "orderId": "oo_5c1e…", "externalId": "A-1001", "version": 2, "status": "accepted",
            "occurredAt": "2026-10-15T10:05:00.000Z", "pickupAt": "2026-10-15T10:35:00.000Z", "prepMinutes": 30 } }

10.4 Ordering

  • Order events: compare data.version per order. Ignore an event whose version is not higher than the last one you applied.
  • store.status_changed and connection.revoked carry data.seq, which only rises per connection. For store.status_changed, the event with the highest seq is the current state.
  • menu.updated needs no ordering: the URL always serves the newest file. Download it on any menu.updated, however late, and skip the download when you already hold that hash.