Overview
The Zestia API is a JSON REST API for your restaurant’s own systems. It is included in the Business and Enterprise plans. All requests go to:
https://zestiapos.com/api/v1/connectEvery response uses the same envelope — success, data, meta and message. Money is a number in the restaurant’s currency; timestamps are UTC ISO-8601.
{
"success": true,
"data": [ { "id": 5512, "object": "order", "order_number": "ORD-1042", "status": "pending", "total": 2450.0, "...": "..." } ],
"meta": { "pagination": "page", "current_page": 1, "last_page": 4, "per_page": 50, "total": 188, "has_more": true },
"message": null
}Authentication
An owner or manager creates keys in Settings → Developers. A key looks like zk_live_…, is shown once, and is stored only as a hash — keep it on your server, never in a browser or mobile app. Send it on every request:
curl "https://zestiapos.com/api/v1/connect/orders?status=pending,confirmed&per_page=50" \
-H "Authorization: Bearer zk_live_your_key"X-Api-Key: zk_live_… works too. A key can be limited to one branch: it then only sees that branch’s orders, menu prices and reports, and orders it creates go to that branch. Revoking a key stops it immediately.
Scopes
| Scope | Allows |
|---|---|
read:menu | Categories, items, variants, modifier groups, availability and branch prices |
read:orders | Orders with items, totals, customer and delivery details |
write:orders | Create orders and move them through statuses |
read:customers | Customer profiles, loyalty points and visit totals |
read:reports | Daily sales: totals, tax, payments by method, orders by type |
read:branches | Branch details, addresses and opening hours |
Endpoints
| Endpoint | Scope | Description |
|---|---|---|
GET/menu | read:menu | Menu; add branch_id for that branch’s prices and availability |
GET/orders | read:orders | List orders (status, order_type, date_from, date_to, updated_since, branch_id) |
GET/orders/{id} | read:orders | One order with items and modifiers |
POST/orders | write:orders | Create an order (Idempotency-Key header required) |
PATCH/orders/{id}/status | write:orders | Change status: confirmed, preparing, ready, served, out_for_delivery, delivered, completed, cancelled |
GET/customers | read:customers | List customers (search, phone, updated_since) |
GET/customers/{id} | read:customers | One customer |
GET/branches | read:branches | Branches the key can see |
GET/reports/daily-sales | read:reports | Sales for date_from…date_to (restaurant-local days, max 92) |
Full request and response schemas are in the OpenAPI 3.1 document — import it into Postman, Insomnia or a client generator.
Creating orders
Orders created through the API follow exactly the same path as your online storefront: prices come from your menu (branch prices, variants, modifiers, promo codes and delivery zones), the order lands on the dashboard and kitchen display as pending, and stock is deducted when it is confirmed. Orders start unpaid (cash on delivery or pay at pickup). order_type is delivery, pickup or takeaway.
curl -X POST "https://zestiapos.com/api/v1/connect/orders" \
-H "Authorization: Bearer zk_live_your_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7b0f6f3e-order-1042" \
-d '{
"branch_id": 12,
"order_type": "delivery",
"customer": { "name": "Ayesha Khan", "phone": "+923001234567" },
"delivery_address": { "address_line": "House 4, Street 9, DHA Phase 5", "city": "Lahore" },
"items": [
{ "menu_item_id": 301, "variant_id": 88, "quantity": 2, "modifiers": [41, 42] },
{ "menu_item_id": 305, "quantity": 1, "notes": "No onions" }
],
"notes": "Ring the bell twice"
}'Idempotency-Key is required. Use a unique value per order and reuse it when you retry after a timeout: the original response comes back with Idempotent-Replayed: true instead of a second order. Reusing a key with a different body returns 422 IDEMPOTENCY_KEY_REUSED. Keys are kept for 7 days.
Move orders along with PATCH /orders/{id}/status and a body like {"status": "confirmed"}. Transitions are validated (for example pending → confirmed → preparing → ready → completed); cancelling needs a reason.
Filters & pagination
date_from/date_toare the restaurant’s local calendar days (2026-10-19), so “today” matches what staff see.updated_since(ISO-8601) returns records changed since your last sync, oldest first — ideal for polling.- Page pagination by default (
page,per_pageup to 100). Addpagination=cursorand followmeta.next_cursorfor large exports.
Webhooks
Add endpoints in Settings → Developers → Webhooks and pick the events to receive. Zestia sends a POST with a JSON body; answer with any 2xx within 10 seconds.
| Event | Sent when |
|---|---|
order.created | A new order was placed on any channel (POS, storefront, WhatsApp, API…) |
order.paid | Payments recorded on an order made it fully paid |
order.status_changed | An order moved to a new status; data.previous_status holds the old one |
menu.updated | Items, prices, variants, modifiers or branch overrides changed (batched per minute) |
customer.created | A new customer profile was created |
{
"id": "evt_01jak3m9x8q2c7v5n4t6r0p1yz",
"type": "order.status_changed",
"api_version": "2026-10-19",
"created_at": "2026-10-19T14:03:11Z",
"data": {
"object": { "id": 5512, "object": "order", "status": "ready", "...": "..." },
"previous_status": "preparing"
}
}- Failed deliveries (non-2xx, timeout, redirect) are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. After 20 failures in a row the endpoint is paused and the owner is notified.
- Retries and manual redeliveries keep the same
id— use it to ignore duplicates. Events can arrive out of order; compareupdated_ator fetch the latest state from the API. - Use Send test event to receive a signed
ping, and the delivery log to see status codes, timings and response snippets. - Endpoint URLs must be public
https://addresses; redirects are not followed.
Verifying signatures
Every request carries X-Zestia-Signature: t=<unix time>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw request body> using the endpoint’s signing secret (whsec_…, shown once when you create or rotate it). Compute it over the raw bytes, compare in constant time, and reject timestamps older than 5 minutes.
import crypto from 'node:crypto';
import express from 'express';
function verifyZestiaSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
String(header).split(',').map((p) => p.trim().split('=')),
);
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// Verify the raw bytes — not a re-serialised JSON object
app.post('/webhooks/zestia', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyZestiaSignature(req.body.toString('utf8'), req.get('X-Zestia-Signature'), process.env.ZESTIA_WEBHOOK_SECRET);
if (!ok) return res.status(400).send('bad signature');
const event = JSON.parse(req.body.toString('utf8'));
// De-duplicate on event.id — retries and redeliveries reuse it
res.sendStatus(204);
});Rate limits
Limits are per key per minute: Business 120, Enterprise 600. Every response includes X-RateLimit-Limit and X-RateLimit-Remaining; a 429 includes Retry-After in seconds. Prefer updated_since and webhooks over tight polling loops. If your plan changes to one without API access, existing keys and webhooks keep working for 30 days.
Errors
Errors use the same envelope with success: false, a readable message, a stable error_code and, for validation errors, an errors object keyed by field.
{
"success": false,
"message": "This API key is missing the \"write:orders\" scope.",
"error_code": "INSUFFICIENT_SCOPE"
}| HTTP | error_code | Meaning |
|---|---|---|
| 400 | IDEMPOTENCY_KEY_REQUIRED | POST /orders without an Idempotency-Key header |
| 401 | API_KEY_MISSING · INVALID_API_KEY | No key, an unknown key or a revoked key |
| 403 | INSUFFICIENT_SCOPE | The key lacks the scope (required_scope says which) |
| 403 | API_ACCESS_NOT_IN_PLAN · TENANT_INACTIVE | Plan without API access (after the 30-day grace) or inactive subscription |
| 403 | BRANCH_FORBIDDEN | A branch-restricted key asked for another branch |
| 404 | NOT_FOUND · BRANCH_NOT_FOUND | Unknown id — or one that belongs to another restaurant |
| 409 | IDEMPOTENCY_IN_PROGRESS | The same Idempotency-Key is still being processed; retry shortly |
| 422 | VALIDATION_FAILED · ORDER_REJECTED · IDEMPOTENCY_KEY_REUSED | Invalid input (see errors), unavailable item / invalid transition, or a reused key with a different body |
| 429 | RATE_LIMITED | Per-key limit reached; wait Retry-After seconds |
Questions or a missing endpoint? Email [email protected].