A server-to-server REST API for your queue. Read queues, entries, services and appointments, and create or cancel queue tokens, from your own website, kiosk, billing or display software.
Server-side only. Your key is a secret. The API sends no CORS headers, so a browser cannot call it, on purpose. Call it from your own server and keep the key in a secret manager.
Everything is scoped to the one business that owns the key. An id from any other business looks exactly like an id that does not exist.
Quickstart
From nothing to your first queue token in about five minutes.
Get access
The Developer API is switched on per business. Request access; we switch it on and tell you.
Create a key
The business owner opens Dashboard → Settings → Developer API and creates a key. Give it a name, only the scopes it needs, an expiry, and, if you can, your server's IP address. The key is shown once and cannot be shown again, so copy it straight into your secret manager.
Keep it out of your code
Store it as an environment variable for the examples below:
The response has the customer's token (for example A12) and their position. The token appears on the business's dashboard straight away. Read it back at any time with Get an entry.
Authentication
Send your key in the Authorization header on every request:
HTTP
Authorization: Bearer zwk_live_<id>_<secret>
Never put a key in a URL. A key in a query string is refused with 400 even if it is valid, because URLs end up in logs and browser history.
Never use a key in a browser or mobile app. Anyone could read it. Keep it on your server.
Every kind of key failure returns the same401, so nothing can be learned about which keys exist.
Scopes
A key can do only what its scopes allow. Give each key the fewest it needs.
Scope
Allows
queue:read
Read queues, entries and services.
queue:write
Create queue entries and cancel waiting ones.
appointments:read
Read appointments.
Expiry, IP allowlist and rotation
Keys expire after 1 to 365 days (90 by default). A business can have up to 10 active keys.
An optional IP allowlist takes IPv4 addresses, IPv4 ranges such as 203.0.113.0/24, and exact IPv6 addresses. From any other address the key is refused with 403 ip_not_allowed.
To rotate a key: create the new key, deploy it, then revoke the old one. The dashboard shows when each key was last used and from where. Revoking takes effect on the next request.
Only the business owner can create or revoke keys, with their normal login. A key can never create, list or revoke keys.
Requests and responses
Topic
How it works
Format
JSON in and out. Send Content-Type: application/json on requests with a body. A body can be at most 32 KB.
Success
The result is wrapped as { "data": ... }. Lists add nextCursor (see Pagination).
Timestamps
ISO 8601 in UTC, for example 2026-09-21T05:41:02.000Z.
Dates
A date parameter is a calendar day, YYYY-MM-DD, in the business's timezone (shown by GET /me). Omit it for today.
Phone numbers
You may send +91 98765 00001 or 9876500001. They are stored and returned as digits with the country code: 919876500001.
IDs
Opaque strings. Store them as strings and do not parse them.
Strict input
Unknown fields in a body and unknown query parameters are rejected with 400, never silently ignored. That catches typos early.
Unknown response fields
Ignore fields you do not recognise. We add new fields without a new version.
Caching
Responses are Cache-Control: no-store.
Headers
Header
Direction
Meaning
Authorization
request
Bearer <key>. Required.
Idempotency-Key
request
On POST. Makes a retry safe. See Idempotency.
X-Request-Id
response
A unique id for the request. Quote it to support.
API-Version
response
The major version that answered, currently v1.
RateLimit-Limit / -Remaining / -Reset
response
Your allowance in the current window, what is left, and seconds until it resets.
Retry-After
response
On 429 and on a busy idempotent request: seconds to wait.
Location
response
On 201: the URL of the new entry.
Idempotent-Replay
response
true when the response is the stored result of an earlier identical request.
Pagination
Lists use cursors. Each page returns up to limit items (1 to 100, default 50) and a nextCursor. Pass it back as cursor to get the next page. When nextCursor is null you have everything. Do not build a cursor yourself.
Node.js
async function allEntries(queueId, status) {
const entries = [];
let cursor = null;
do {
const url = new URL(`https://api.zirowait.com/v1/queues/${queueId}/entries`);
url.searchParams.set("limit", "100");
if (status) url.searchParams.set("status", status);
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.ZIROWAIT_KEY}` } });
const page = await res.json();
if (!res.ok) throw new Error(page.error.code);
entries.push(...page.data);
cursor = page.nextCursor;
} while (cursor);
return entries;
}
Python
def all_entries(queue_id, status=None):
entries, cursor = [], None
while True:
params = {"limit": 100}
if status: params["status"] = status
if cursor: params["cursor"] = cursor
res = requests.get(f"https://api.zirowait.com/v1/queues/{queue_id}/entries", params=params,
headers={"Authorization": f"Bearer {os.environ['ZIROWAIT_KEY']}"}, timeout=10)
page = res.json()
if not res.ok: raise RuntimeError(page["error"]["code"])
entries += page["data"]
cursor = page["nextCursor"]
if not cursor: return entries
Idempotency
Networks fail. If a request to create a token times out, you do not know whether it worked, and sending it again could create a second token. An Idempotency-Key removes the doubt.
Send a unique key (8 to 64 characters: letters, digits, _ or -) with each POST. A UUID is ideal.
Retry with the same key and the same body: you get the original result back, with Idempotent-Replay: true. No duplicate is created.
The same key with a different body is a bug in your code: 422 idempotency_key_reuse.
If the first request is still running, a retry gets 409 request_in_progress. Wait a second and try again.
Only successful results are stored, for 24 hours. A failed attempt releases the key so you can retry it.
Node.js
async function createToken(queueId, name, phone) {
const key = crypto.randomUUID(); // one key per logical action
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await fetch(`https://api.zirowait.com/v1/queues/${queueId}/entries`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.ZIROWAIT_KEY}`, "Idempotency-Key": key, "Content-Type": "application/json" },
body: JSON.stringify({ name, phone }),
});
if (res.status < 500 && res.status !== 429) return await res.json(); // done, or a mistake retrying cannot fix
} catch { /* network error: retry with the same key */ }
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
}
throw new Error("Gave up after 3 attempts");
}
Rate limits
Limit
Value
Applies to
Requests
120 per minute
Each key, all endpoints
Writes
30 per minute
Each key, POST requests (they also count toward the 120)
Failed sign-ins
30 per 10 minutes
Each IP address. Only 401 responses count; a typo elsewhere never locks you out.
Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. Past the limit you get 429 rate_limited with a Retry-After header. GET /v1/me tells you your key's exact limits.
info
To stay well inside the limits, poll no faster than every 5 to 10 seconds. A lobby display that reads one queue summary and one entries page every 10 seconds makes 12 requests a minute.
Node.js
async function call(url, init, tries = 4) {
for (let i = 0; i < tries; i++) {
const res = await fetch(url, init);
if (res.status !== 429) return res;
const wait = Number(res.headers.get("retry-after") || 2 ** i);
await new Promise((r) => setTimeout(r, wait * 1000));
}
throw new Error("still rate limited");
}
Errors
Every error has the same shape and never contains internal detail. Branch on code, not on message, and quote requestId when you contact support.
A 400 invalid_request includes details: which fields were wrong and why. It never repeats the values you sent.
JSON · 400
{
"error": {
"code": "invalid_request",
"message": "Invalid body: name (1-120 chars), phone (8-20 digits) and an optional serviceId are accepted; no other fields.",
"requestId": "0c8e3f5a-6b1d-4c1e-9a55-2f5d8e1b7c91",
"docUrl": "https://www.zirowait.com/developers/docs#errors",
"details": [
{
"field": "phone",
"message": "Invalid format."
},
{
"field": "nickname",
"message": "Unknown field. Remove it."
}
]
}
}
Error codes
Status
Code
Meaning
What to do
400
invalid_request
A field, query parameter or header is invalid or unknown. error.details says which and why.
Fix the fields listed in details. Unknown fields must be removed.
400
invalid_json
The body is not valid JSON.
Send valid JSON with Content-Type: application/json.
401
unauthorized
The key is missing or invalid. Every cause (malformed, unknown, wrong secret, revoked, expired) looks the same on purpose.
Check the Authorization header is exactly Bearer <key>. If it worked before, the key may have expired or been revoked: create a new one.
403
insufficient_scope
The key lacks a scope this endpoint needs. error.required lists them.
Create a key with the needed scope.
403
api_access_disabled
Developer API access is switched off for this business.
Ask ZiroWait to switch it on.
403
ip_not_allowed
The key has an IP allowlist and your server's address is not on it.
Call from an allowed address, or add your server's IP to the key.
403
plan_limit_reached
The business's plan limit was reached, for example the daily token limit.
The business owner can upgrade the plan.
404
not_found
No such resource in your business. An id from another business looks the same.
Check the id, and that it came from this business's key.
409
queue_paused
The business has paused new tokens for this queue.
Try again later. Show your customer a friendly message.
409
invalid_state
The action is not possible in the entry's current state, for example canceling one that is already in service.
Read the entry to see its status first.
409
request_in_progress
A request with the same Idempotency-Key is still running.
Wait a second and retry with the same key.
413
payload_too_large
The body is over 32 KB.
Send less.
415
unsupported_media_type
The body was not sent as application/json.
Set Content-Type: application/json.
422
idempotency_key_reuse
That Idempotency-Key was already used with a different request.
Use a fresh key for each new request. Reuse a key only to retry the same one.
429
rate_limited
Too many requests. See the Retry-After header.
Wait Retry-After seconds, then retry with backoff.
429
too_many_failed_attempts
Too many failed sign-ins from your IP address.
Fix the key and wait. It clears after a while.
500
internal_error
Something went wrong on our side.
Retry with the same Idempotency-Key. If it keeps happening, contact support with the requestId.
Troubleshooting
Symptom
Likely cause and fix
401 and the key looks right
The header must be exactly Authorization: Bearer <key>, with no quotes or extra spaces. Or the key expired or was revoked: create a new one.
403 api_access_disabled
The Developer API is not switched on for this business yet.
403 ip_not_allowed
Your server's public IP is not on the key's allowlist. Check what address your server calls from (a cloud NAT address is often different from the machine's own).
A CORS error in the browser
Expected. The API is not callable from browsers. Move the call to your server.
400 with details
Read details: each item names the field. Unknown fields must be removed.
409 queue_paused
The business paused new tokens. Try again later.
429
Slow down and honour Retry-After. Do not retry in a tight loop.
Versioning and changes
The URL carries the major version: /v1. Within a version we only make changes that do not break a correct client:
New endpoints, new optional parameters, new response fields, and new error codes.
So your client must ignore response fields it does not know and treat an unknown error code as a generic failure for that status.
A change that could break a correct client would ship as a new major version, announced in the changelog before it takes effect.
The API-Version response header tells you which major version answered.
Machine-readable description: https://api.zirowait.com/v1/openapi.json (OpenAPI 3.1, no key needed). Import it into Postman (Import → Link) or Insomnia, open it in Swagger UI, or generate a client library from it.
API reference · Account
Everything below needs the key in the Authorization header. The scope each endpoint needs is shown on its card.
Check your key
GET/v1/meany valid key
Confirms your key works and shows the business it belongs to, the key's scopes and expiry, and the rate limits that apply. Works with any valid key, so it is the first call to make.
The queue plus how many entries are waiting, in service, completed, canceled or no-show for the day, and the average estimated wait of the people waiting now. It is one cheap call, ideal for a lobby display.
Parameters
Name
In
Type
Description
queueIdrequired
path
string
The queue. An opaque id. Treat it as a string; do not parse it.
date
query
YYYY-MM-DD
The day to count, in the business's timezone. Defaults to today.
The day's entries in token order (today by default). Filter by status to get, for example, only the people still waiting. Results are paginated with a cursor.
Parameters
Name
In
Type
Description
queueIdrequired
path
string
The queue. An opaque id. Treat it as a string; do not parse it.
status
query
enum
WAITING, IN_SERVICE, COMPLETED, CANCELED or NO_SHOW. Omit for all.
date
query
YYYY-MM-DD
The day to list, in the business's timezone. Defaults to today.
Cancels an entry that is still waiting and returns it with the status CANCELED. An entry that is already in service, completed, canceled or a no-show cannot be canceled.
Parameters
Name
In
Type
Description
entryIdrequired
path
string
The entry. An opaque id. Treat it as a string; do not parse it.
Example request
curl -X POST "https://api.zirowait.com/v1/entries/$ENTRY_ID/cancel" \
-H "Authorization: Bearer $ZIROWAIT_KEY"
Let a customer type their name and phone on a screen and get a token. Create the entry with an idempotency key, show the token and position, then poll the entry so the screen stays current.
Node.js
// 1. When the customer taps "Join"
const key = crypto.randomUUID();
const create = await fetch(`https://api.zirowait.com/v1/queues/${QUEUE_ID}/entries`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Idempotency-Key": key, "Content-Type": "application/json" },
body: JSON.stringify({ name, phone }),
});
const { data: entry } = await create.json();
show(`Your token is ${entry.token}. ${entry.position - 1} people ahead.`);
// 2. Keep the screen up to date every 10 seconds
const timer = setInterval(async () => {
const res = await fetch(`https://api.zirowait.com/v1/entries/${entry.id}`, { headers: { Authorization: `Bearer ${KEY}` } });
const { data } = await res.json();
show(data.status === "WAITING" ? `Position ${data.position}` : data.status);
if (data.status !== "WAITING") clearInterval(timer);
}, 10000);
Lobby display board
Show "Now serving" and the next few tokens on a screen. One queue summary call gives the counts and average wait; one entries call gives the tokens.
Read yesterday's completed entries page by page and write them to a file. Use the date parameter so the answer never depends on when the job runs.
Node.js
const day = "2026-09-20"; // the business's calendar day
let cursor = null, rows = ["token,name,phone,completedAt"];
do {
const url = new URL(`https://api.zirowait.com/v1/queues/${QUEUE_ID}/entries`);
url.search = new URLSearchParams({ status: "COMPLETED", date: day, limit: "100", ...(cursor ? { cursor } : {}) });
const page = await (await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } })).json();
for (const e of page.data) rows.push([e.token, e.customer.name, e.customer.phone, e.completedAt].join(","));
cursor = page.nextCursor;
} while (cursor);
await fs.promises.writeFile(`entries-${day}.csv`, rows.join("\n"));
Security
What we do
Keys are never stored. We keep only a keyed HMAC-SHA256 of the secret, so a database leak yields no usable keys.
256-bit random secrets, verified in constant time. Unknown keys cost the same to reject as wrong ones.
Least privilege: scopes, expiry, an optional IP allowlist, instant revocation, at most 10 active keys per business.
Tenant isolation: the business always comes from the key, never from the URL or body, and every query is scoped to it.
Audit log: every authenticated request is recorded (method, path, status, IP, request id), never bodies or keys, and kept for 90 days.
Abuse controls: per-key rate limits, failed-sign-in blocking by IP, a 32 KB body cap, strict validation and no CORS.
Safe by default: off for every business until switched on. Error messages never contain internals or the values you sent.
What you should do
Keep keys on servers only, in a secret manager, not in code, apps, chat or tickets.
Create one key per integration with the fewest scopes, and set the IP allowlist.
Rotate keys on a schedule, and revoke one immediately if it may have leaked.
Send an Idempotency-Key on every POST.
report
To report a vulnerability, email support@zirowait.com. Please do not test against another business's data.
What this API does not have yet: a third-party security audit, request signing, OAuth-style consent screens and mTLS. We do not claim parity with the largest cloud providers.
Changelog
v1.1.02026-09-21
Clearer errors, a machine-readable spec and two new endpoints
New: GET /v1/openapi.json, an OpenAPI 3.1 description of the whole API. Import it into Postman, Insomnia or Swagger UI, or generate a client from it.
New: GET /v1/queues/{queueId} returns a queue with today's counts and the average wait, for lobby displays.
New: GET /v1/appointments/{appointmentId}.
Validation errors now include error.details: which fields were wrong and why (never the values you sent).
Every error now includes error.docUrl, a link to this page.
GET /v1/me now includes your key's rate limits.
Responses now carry an API-Version header. Creating an entry returns a Location header, also on an idempotent replay.
GET /v1/openapi.json and GET /v1 are public and need no key. Everything else is unchanged.
v1.0.02026-09-20
First release
Scoped, expiring API keys with IP allowlists.
Queues, entries, services and appointments (read), create and cancel tokens.
Idempotency keys, per-key rate limits, request ids and an audit log.
Coming next
Signed webhooks, so events are pushed to you instead of polled.
Stuck, or something here is unclear? Email support@zirowait.com and include the requestId from the failing response, the endpoint, and what you expected. The requestId lets us find your exact request.