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
2xxwithin 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,
429and5xx. ARetry-Afterheader (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, every3xx(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.
- Take the raw request body, before any JSON parsing.
- Read
tand everyv1from the header. - Reject the request if
tis more than 300 seconds away from your clock. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as hex with each secret you hold (the wholewhsec_…string is the key). Accept if anyv1matches, 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 False10.3 Event types
| Type | data | |
|---|---|---|
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:pickupAtis the time the restaurant promised,deliveryAtits drop-off time (deliveries only),prepMinutesthe minutes from acceptance topickupAt. They are present when the restaurant set a time.- Reason codes:
RESTAURANT_REJECTED,RESTAURANT_CANCELLED(with the restaurant's text or a default message), andPARTNER_REQUESTED(your own cancel). connection.revokedis sent once, just before the connection is revoked, and is not retried. Afterwards your key gets403 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.versionper order. Ignore an event whose version is not higher than the last one you applied. store.status_changedandconnection.revokedcarrydata.seq, which only rises per connection. Forstore.status_changed, the event with the highestseqis the current state.menu.updatedneeds no ordering: the URL always serves the newest file. Download it on anymenu.updated, however late, and skip the download when you already hold that hash.