Webhooks
Webhooks are how OneTablet pushes a state change to you as it happens. Every event also has a poll-able read behind it, so an integration with no reachable endpoint can still use the whole API — a webhook is a latency optimisation, never the only path to a fact.
What is live, and what this page is
Order webhooks are Phase 0 and live today. They are delivered to an endpoint registered with your OneTablet contact, not configured through an operation on this API.
The webhook catalogue below is the published contract for the event stream, and every row is marked with the phase it belongs to, so you can tell a live event from a design commitment without a second document.
You register ONE endpoint per domain — one url for order events, one for store events — and it receives every event for that domain. The API Reference now carries a generated page per event, filed in the same section as the operations it belongs beside: the order events under Orders, store.status.changed under Stores, report.completed under Reports, menu.publication.completed under Menus. A page per event is documentation, not an endpoint per event: each page exists so you can read one event's exact payload without unpicking it from a union, and if you run codegen you get one type per event. Every row in the catalogue below now has a generated page.
Envelope
Every delivery is an HTTPS POST to your registered endpoint with a JSON body:
{
"id": "8c1d…",
"event": "order.created",
"timestamp": "2026-08-13T18:04:11.123Z",
"organization_id": "3f9c…",
"data": {}
}
idis the unique event id and your deduplication key — see Deduplication below.eventis the discriminator — route on it, not on anything in the transport.timestampis the delivery time, not the event time. Order by the transported state (data.updatedAt,data.status/data.deliveryStatus), never by arrival.organization_idis your organization id — the same value you send asx-mealco-org-idon API requests, and the value carried on the delivery'sX-Organization-IDheader. It is deliberately snake_case on this one field: it is a long-lived wire value.dataembeds the same object the corresponding read returns. An order event carries the order objectfetchOrdersreturns; a store-status event carries the status objectfetchStoreStatusesreturns.
Delivery semantics
| Property | Contract |
|---|---|
| Delivery | At-least-once. Duplicates happen; make your handling idempotent. |
| Ordering | Not guaranteed. Order by the transported state, never by arrival. |
| Timeout | Respond within 30 seconds. Acknowledge first, process asynchronously. |
| Acknowledgement | Any 2xx. Anything else counts as a failed attempt. |
| Retries | A failed attempt is retried with exponential backoff, which is what makes delivery at-least-once. |
Deduplication
Deduplicate on the envelope's id, and on nothing else.
Every copy of one event carries the same id — a retried delivery, and the delivery to each url you have registered. Two legitimate transitions of one order are two events with two ids, so keying on id alone never collapses a real transition into a duplicate. Do not build a composite key out of the order id and the event type: those repeat across genuine transitions, and you would lose one. Note that timestamp is the moment an attempt was sent, so it differs between copies of one event and is not part of the key.
Recovery
If your endpoint is unreachable past the retry window, reconcile from the reads: fetchOrders over the affected time window, and fetchStoreStatuses for the affected stores. Do not build a pipeline that can only learn state from webhooks — an endpoint that is down for an hour would then be a permanent hole in your data rather than an hour of extra latency.
Event catalogue
| Event | Payload | Phase |
|---|---|---|
order.created | A new order entered OneTablet from a connected platform — the full order. | 0 — live |
order.status.updated | Lifecycle transition (ready, finished). | 0 — live |
order.rejected | Rejection, including automatic rejections at intake. | 0 — live |
order.cancelled | Cancellation — who cancelled (by), the reason, and when (at). | 0 — live |
order.delivery.updated | Courier lifecycle: assigned, en route, completed, cancelled. | 0 — live |
store.status.changed | A store's intended or observed state changed; carries the status object. | 1 |
report.completed | A requested report reached ready or failed. | 2 |
menu.publication.completed | Publication finished, with per-store, per-platform outcomes. | 4 |
order.cancelled is spelled with two ls — the same spelling as the order status value. ONE-2991 renamed it from order.canceled (one l) to match the published docs site, which has always said two. No partner was consuming this surface at the time. The LEGACY webhook feed is a separate transport and still emits order.canceled; that one is unchanged.
There are no discrepancy events
Discrepancy monitoring is state-based, not event-based. There is no store.discrepancy.detected and no store.discrepancy.resolved, and there is not going to be: a discrepancy is a standing condition, and an event stream describing the edges of a standing condition is a thing you can miss. Poll fetchStoreStatuses with hasDiscrepancy: true and route on the classification embedded in each status. The classification is a closed set of four values (pauseNotPropagated, unexpectedPlatformPause, deactivatedByPlatform, unclassified); new values are added additively, so route an unrecognised one like unclassified. Detection follows the reconciliation cadence — see Data freshness.
Endpoint registration
Endpoints are registered with your OneTablet contact during onboarding, and changes are applied the same day. Registration is per domain: one url receives every order event, one receives every store event — a small fixed set you fill in, not a per-event registry. The number of pages in the API Reference has no bearing on this; it never has. Self-serve endpoint management is on the roadmap; it is not committed to a phase.
Per-event-type filtering is deliberately not offered. Your handler already switches on event, and a server-side filter would be a second place for that list to be wrong — one that fails silently, by delivering nothing.
Securing your endpoint
Store-event deliveries are signed, and signing cannot be turned off. Each carries an HMAC-SHA256 over the raw body under a secret issued with your endpoint, plus a timestamp that is itself covered by the signature:
X-OneTablet-Timestamp: 1756213045
X-OneTablet-Signature: v1=<64 hex characters>
Hash the bytes as received — never a re-serialization of the parsed body — reject a timestamp more than five minutes from now, and compare in constant time. Verifying webhook signatures has the exact string being signed, a working Node snippet, and how secret rotation behaves; implement against that page rather than from this paragraph.
Phase 0 order events are not signed yet. Turning signing on for a live endpoint is a coordinated change — you have to be verifying before we can start sending signatures you would otherwise reject — so it is scheduled with you rather than switched on. Until then an order endpoint has no cryptographic authentication and should be treated that way: require HTTPS, keep the URL unguessable, and treat the organization_id field as a sanity check rather than proof of origin. Do not let a webhook body alone authorise anything you would not let an anonymous caller do.