Auth
Per-tenant API keys. Bearer token in the Authorization header.
Operators generate a per-tenant key in /app/api-keys and present it as Authorization: Bearer zk_live_.... Each key carries a set of scopes (menu:read, customers:read, orders:read, orders:write, webhooks:manage) and a per-minute rate limit; an endpoint returns 403 forbidden_scope if the key lacks the scope it needs. A key can be bound to a single brand or left org-wide. All requests are TLS-only; HTTP is redirected. (A partner OAuth client_credentials flow is on the roadmap but not available yet — use API keys today.)
REST
Live v1 endpoints. JSON in, JSON out. Errors are flat: {"error": "code", "message"?}.
GET/api/v1/menumenu:read+
Fetch the active menu (categories + items) for the brand bound to the key. Org-scoped keys must bind to a brand if the org runs more than one brand.
Required scope: menu:read
Capped at 2000 items. meta.truncated is true if the cap was hit.
{ "data": { "categories": [...], "items": [...] }, "meta": { "brand_scope": "brand", "count": { "categories": 6, "items": 48 }, "truncated": false } }GET/api/v1/customerscustomers:read+
List the organization's customer book. Offset paginated. Brand-bound keys still return the org-wide book (customers are org-level).
Required scope: customers:read
Query params: ?page (>=1) and ?page_size (1-200). Default page 1.
{ "data": [...], "meta": { "brand_scope": "organization", "page": 1, "page_size": 50, "total": 1280, "has_more": true } }GET/api/v1/orders/{order_number}orders:read+
Fetch a single order by its order number, with status, money breakdown, and timestamps. Brand-bound keys can only read their brand's orders.
Required scope: orders:read
{ "data": { "order_number": "Z-1024", "status": "ready", "total_cents": 4180, ... }, "meta": { "brand_scoped": true } }GET/api/v1/webhookswebhooks:manage+
List this org's active outbound webhook subscriptions. The signing secret is never returned here.
Required scope: webhooks:manage
{ "data": [{ "id": "...", "url": "...", "events": [...] }], "meta": { "count": 2 } }POST/api/v1/webhookswebhooks:manage+
Create an outbound webhook subscription. The signing secret is returned exactly once in this response — store it now.
Required scope: webhooks:manage
201 on success. url must be HTTPS and not a zayrev.com / zayos.com host. 1-50 events, all from the catalog below.
{ "url": "https://example.com/hooks/zayos", "events": ["order.created", "order.ready"] }{ "data": { "id": "...", "url": "...", "events": [...], "created_at": "...", "signing_secret": "whsec_..." } }DELETE/api/v1/webhooks/{id}webhooks:manage+
Deactivate an outbound webhook subscription. Soft delete — the delivery history is preserved.
Required scope: webhooks:manage
{ "data": { "id": "...", "deactivated": true } }These are part of the planned v1 surface but are not live yet. POST /api/v1/orders returns 501 today (its request shape is stable, so you can integrate ahead of the writes going live); the others are not built. Do not depend on these returning data.
POST/api/v1/orderssoonorders:write+
Create an order via the headless API. Re-prices server-side, runs the same inventory / capacity / promo checks as the hosted checkout.
Required scope: orders:write
Returns 501 today. customer.name, customer.email, customer.phone, location_id, and at least one item are required. Writes go live in a later v1 release; the request shape is stable so you can integrate now.
{ "customer": { "name": "Maya R.", "phone": "+13055550101", "email": "maya@example.com" }, "fulfillment": "pickup", "location_id": "...", "items": [{ "menu_item_id": "...", "quantity": 2 }] }{ "error": "not_implemented_v1", "message": "...", "received": { "items_count": 1, "fulfillment": "pickup", "location_id": "..." } }POST/api/v1/refundssoon+
Issue a refund against an order. Not yet available.
Not built yet. Issue refunds from the operator dashboard for now.
POST/api/v1/menu/itemssoon+
Create a menu item under a brand. Not yet available.
Not built yet. Menu writes happen in the operator dashboard for now; the API is read-only on menu.
PATCH/api/v1/menu/items/{id}/availabilitysoon+
86 / un-86 an item, optionally per location. Not yet available.
Not built yet. Toggle availability in the operator dashboard for now.
Webhooks
HMAC-SHA256-signed event push to a URL you configure. At-least-once delivery with retries.
Configure a delivery URL in /app/integrations. We POST every relevant event with header `x-zayos-signature`, whose value is the hex-encoded HMAC-SHA256 of the exact raw request body using your shared secret. Verify by recomputing HMAC-SHA256(rawBody, secret) and constant-time-comparing the hex. We retry up to 5 times with backoff on non-2xx responses. (No timestamp header is sent, so there is no timestamp-based replay window — rely on the event id for idempotency.)
| Event | When it fires |
|---|---|
| order.created | A new paid order entered the kitchen feed. |
| order.accepted | Operator accepted the order. |
| order.ready | Order is ready for handoff. |
| order.delivered | Customer received the order. |
| order.cancelled | Order was cancelled (reason in payload). |
| order.refunded | Full or partial refund issued (refund_type in payload). |
| menu.updated | The brand's menu changed (item added, edited, or 86'd). |
Rate limits
Per API key. Each key has its own per-minute budget, set in /app/api-keys (default 60/min).
The public API requires a key on every request, so limits are keyed per API key (not per IP). Read endpoints (GET menu / customers / orders / webhooks) get the key's full per-minute budget; write endpoints (POST orders, POST/DELETE webhooks) get half that budget from a separate bucket. Exceeding it returns 429 {"error": "rate_limited", "retry_after_seconds": N} with a Retry-After header. Every response also carries X-RateLimit-Limit and X-RateLimit-Remaining.
Errors
Flat shape, stable error codes. Switch on the code; the message is human-readable and may change.
Every error returns a flat JSON body: {"error": "snake_case_code", "message": "Human-readable explanation"}. The message field is optional and present on most (not all) errors. There is no nested error object and no request_id field today. Auth: 401 "unauthorized" (missing/invalid key), 403 "forbidden_scope" (key lacks the required scope). Validation (400): "invalid_json", "invalid_pagination", "customer_required", "location_id_required", "items_required", "too_many_items", "invalid_url", "blocked_url", "invalid_events", "unknown_events", "invalid_id", "brand_required_for_multi_brand_org". Not found (404): "not_found", "order_not_found". Conflict (409): "webhook_cap_reached". Method (405): "method_not_allowed". Rate limit (429): {"error": "rate_limited", "retry_after_seconds": N} plus a Retry-After header. Not built yet (501): "not_implemented_v1". Transient/backend (503): "service_unavailable" and the *_query_failed / *_create_failed / *_delete_failed family.
Hello world (curl)
Replace <your-app-domain> with your Zayos app origin (e.g. your dashboard host). Fetch the live menu — a read-only call that works today:
curl https://<your-app-domain>/api/v1/menu \
-H "Authorization: Bearer ${ZAYOS_API_KEY}"Order creation is documented but not live yet — this request returns 501 today. The shape is stable, so you can build against it now:
curl https://<your-app-domain>/api/v1/orders \
-H "Authorization: Bearer ${ZAYOS_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"customer": { "name": "Maya R.", "phone": "+13055550101", "email": "maya@example.com" },
"fulfillment": "pickup",
"location_id": "...",
"items": [{ "menu_item_id": "...", "quantity": 2 }]
}'