Receive Zyda orders by webhook
For: Owners, Operations
Where: Setup > Integrations > Webhooks, API keys
Available: Web dashboard · All countries
This guide is for the developer who receives a restaurant's Zyda orders in their own system, such as an ERP, a POS or a
spreadsheet tool. To send orders to Zyda, see Connect your own system: orders API and webhooks.
Add a webhook
1. In the Zyda dashboard, open Setup > Integrations > Webhooks.
2. Select Add webhook and enter your URL.
3. Choose the events you want.
4. Choose which parts of the order to send (see "Parts of the order").
5. Select Save, then copy the webhook secret. Zyda shows it once. Keep it on your server only.
The URL must start with https://, reach a public address, and hold no user name or password. Zyda refuses a private or
internal address when you save it. Zyda does not follow redirects.
From the same page the store can send a test event, read the delivery log of the last 30 days, send a past delivery
again, make a new secret (the old one stops working at once), and turn a webhook off and on.
Adding a webhook: the URL and the events to send
Webhook events
| Event | When Zyda sends it | |---|---| | order.created | The order is submitted. This is the first time it reaches the
store. | | order.status_changed | Every status change after that: accepted, ready, dispatched, delivered, fulfilled,
canceled, returned, deleted. Also when staff correct an order back to an earlier status. | | order.paid | The money is
in: at submit for an order paid up front, or later when an online payment is recorded, when staff mark cash or card on
delivery as collected, or when staff mark an on-account order as paid. | | order.canceled | The order is canceled. Zyda
sends it together with its order.status_changed. |
Every order of the store sends events, from any source, including live orders you sent with the orders API. These send
nothing:
- an order that is never submitted, for example an expired unpaid online order;
- a recorded sale you sent with the orders API;
- a dine-in table round. Its money is paid on the table.
Zyda does not promise the order in which deliveries arrive. A retry can arrive after a later event. Use occurred_at and
order.status to decide which state is newest.
The webhook request
Zyda sends an HTTP POST with a JSON body.
| Header | Value | |---|---| | Content-Type | application/json | | User-Agent | Zyda-Webhooks/1 | | X-Zyda-Event | The
event name, for example order.created. | | X-Zyda-Delivery | The delivery id. It is also delivery_id in the body. It
stays the same on every retry of one delivery. | | X-Zyda-Signature | t=<unix seconds>,v1=<signature>. See "Check the
signature". |
The webhook body
Every event has the same shape. This is version 1.
- order.status is the status this event is about.
- order.previous_status is the status before it. On order.created it is pending.
- Times are ISO 8601, in UTC.
- Amounts are numbers, in the store's currency.
- test is true only for a test event.
Example of order.status_changed:
{
"version": 1,
"event": "order.status_changed",
"delivery_id": "6f1c2a9e-0b7d-4c55-9a51-2f0e8d3b7c14",
"occurred_at": "2026-10-14T10:32:00Z",
"test": false,
"store": { "id": 4321, "name": "Sample Store" },
"order": {
"id": 880123,
"number": "1042",
"status": "accepted",
"previous_status": "submitted",
"created_at": "2026-10-14T10:20:05Z",
"submitted_at": "2026-10-14T10:20:06Z",
"due_at": "2026-10-14T10:50:06Z",
"is_scheduled": false,
"time_slot": null,
"delivery_type": "pickup",
"origin": "manual_order",
"is_manual_order": true,
"branch": { "id": 101, "name": "Main Branch" },
"channel": "whatsapp",
"captured_by": "api",
"taken_by": "staff",
"customer": { "name": "Sample Customer", "phone": "+96550000000" },
"address": {
"area": null, "city": null, "block": null, "street": null, "avenue": null, "building": null,
"floor": null, "unit_no": null, "unit_type": null, "notes": null, "lat": null, "lng": null
},
"items": [
{
"id": 5550001,
"menu_item_id": "a1b2c3d4-0000-4000-8000-000000000001",
"variant_id": "123456",
"name": "Burger patties, 6 pieces",
"name_ar": null,
"variant_name": "Regular",
"quantity": 2,
"unit_price": 4.75,
"total_price": 9.5,
"notes": "Extra fat",
"options": []
}
],
"payment": {
"method": "CASH",
"status": "paid",
"cash_collected_at": null,
"currency": "KWD",
"subtotal": 9.5,
"discount": 0.0,
"voucher_code": null,
"delivery_fee": 0.0,
"service_fee": null,
"tax": 0.0,
"total": 9.5
}
}
}
Only event, order.status and order.previous_status change between events:
| Event | order.status | order.previous_status | |---|---|---| | order.created | submitted | pending | |
order.status_changed | the new status | the status before it | | order.paid | the order's status at that time | the
status before it, if the same change moved the status. Otherwise null. | | order.canceled | canceled | the status before
it |
Notes on the fields:
- channel is the order source key, for example website, phone or talabat. See "Order sources".
- captured_by is the system that recorded the order. It is api for an order you sent.
- taken_by is who took the order: customer, staff, ring_agent or ai_agent.
- Each entry in items[].options holds name, name_ar, quantity and price.
- For cash and card on delivery, payment.status can read paid before collection. cash_collected_at is set, and
order.paid sent, when staff mark it collected.
- discount includes the voucher.
- channel, captured_by, taken_by and cash_collected_at can be null on older orders.
Parts of the order
Each webhook can leave out any of these parts: customer (name and phone), address, items and payment. A part that is
left out keeps its keys. Each value is null, and items is []. So the shape of the body never changes.
Check the signature
Check every request before you trust it.
1. Read t and v1 from the X-Zyda-Signature header.
2. Build this text: the value of t, a dot, then the raw request body exactly as you received it. For example
1760437920.{"version":1,...}.
3. Compute HMAC-SHA256 of that text. Use the webhook secret as the key. Write the result as lowercase hex.
4. Compare your result with v1. Use a constant-time compare. If they differ, refuse the request.
5. Refuse the request if t is more than a few minutes away from your clock. This stops someone from sending an old
request again.
Use the raw body. If you parse the JSON and write it again, the bytes change and the signature no longer matches.
Node.js example (the steps above work the same in any language):
const crypto = require('crypto');
function verifyZydaSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
String(signatureHeader || '').split(',').map((part) => {
const at = part.indexOf('=');
return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
})
);
const t = parts.t;
if (!t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(parts.v1, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Answer quickly, and retries
- Answer with any 2xx status within 10 seconds. Zyda waits 5 seconds to connect. Do slow work after you answer, for
example in a queue.
- The store sees the first 2 KB of your answer in the delivery log.
- A timeout, a network error, 5xx or 429 is tried again after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6
hours and 14 hours: 8 tries in all, over about a day.
- Any other answer, including other 4xx and a redirect, fails the delivery at once. One exception: a Google Apps
Script web app (script.google.com) that answers 302 or 303 counts as delivered, because the script ran. Zyda still
does not follow the redirect.
- The same delivery can reach you more than once. Drop repeats by X-Zyda-Delivery.
- After 50 failed deliveries in a row, Zyda turns the webhook off. A delivery counts as failed only after all its
tries. The store turns it on again with Send events.
A test event sent from the dashboard has "test": true, a delivery_id that starts with test_, and a made-up order with
id 0 and number TEST-0001.
Related articles
- Connect your own system: orders API and webhooks
- Set up webhooks and API keys
- Choose the order sources your team records
- Send your orders to a Google Sheet