# Zunaro API > Zunaro (zunaro.ai) is a ticketing platform for event organizers. This file is the > agent-readable rendition of the public REST API reference. Human version with > runnable examples: https://zunaro.ai/developers/api ## Base URL https://api.zunaro.ai/v1 ## Authentication Every request carries an API key as a bearer token: Authorization: Bearer zun_live_... - Keys are created in Dashboard -> Settings -> Developers. - The organizer (tenant) is resolved FROM the key — there is no org id in the URL, and cookies are never read (the surface is CSRF-immune by construction). - Keys carry scopes: `read` gates every GET, `write` gates every POST/PATCH/DELETE. - A missing/unknown/revoked/expired key always gets the same generic 401 (no oracle), with `www-authenticate: Bearer realm="zunaro-api"`. - A valid key without the required scope is a 403. - Keys are SECRETS — treat them like passwords. Never embed a `zun_live_` key in public client-side code or a mobile app; call from your server, or proxy browser calls through your own backend. ## Conventions - Field names are snake_case. - Money is always integer minor units (cents) in fields suffixed `_minor`, alongside an ISO 4217 `currency`. - Timestamps are ISO 8601 strings. - Every resource carries an `object` discriminator (`event`, `order`, `ticket`, `list`, ...). - Ids are UUIDs. A malformed id in a path is a 404, same as a nonexistent one. ## Errors Every error is an RFC 9457 `application/problem+json` body: {"type": "about:blank", "title": "Bad Request", "status": 400, "detail": "..."} - Unknown `/v1/*` paths answer the same shape as a 404 with `detail: "Unknown API endpoint."`. - Every response echoes an `x-request-id` header — quote it to support when reporting a 500. ## Rate limits All windows are one minute. Exceeding any limit answers 429 with `retry-after: 60`. | Meter | Limit | |---|---| | Pre-auth, per IP (every request, valid or not) | 600/min | | Per key, read operations | 300/min | | Per key, write operations | 60/min | ## Pagination Every list endpoint takes `?limit=` (1-100, default 25) and `?starting_after=` and answers the envelope: {"object": "list", "data": [...], "has_more": true|false} Cursors are resource ids (UUIDs); an unknown `starting_after` id is a 400. ## Idempotency Send an `Idempotency-Key` header (any string <= 255 chars, e.g. a UUID) on POST/PATCH to make retries safe: - The first request with a key executes and its response is memoized for EVERY status — 5xx included (the handler may have committed before a late failure, so a same-key retry must replay the outcome, never re-execute and duplicate committed work). - A retry with the same key AND the same payload replays the stored response verbatim, marked with the `idempotent-replayed: true` response header. To genuinely retry after a replayed 5xx, switch to a NEW key. - The same key with a different payload -> 409. - The same key while the first request is still in flight -> 409. - Keys are scoped to your organizer and remembered for 24 hours. - Replays require the SAME API key: the memoized request hash includes the calling key, so an identical retry sent with a different API key of your organizer answers 409 ("used with a different payload") instead of replaying — namespace key strings per integration. - GET/DELETE requests ignore the header. ## CORS The API answers `access-control-allow-origin: *` (bearer-only auth means there is nothing ambient for a cross-origin call to ride on) and exposes `x-request-id`, `retry-after`, and `idempotent-replayed` to browser callers. This is for your own trusted, internal tooling — never ship a key in public client-side code. ## Endpoints ### Events - `GET /v1/events` (read) — list the organizer's whole catalog (drafts, past, and archived included), newest first. Paginated. - `POST /v1/events` (write) — one-shot composite create: venue + event + performances + ticket types (each ticket type is priced on every performance), published and purchasable by default. Body (strict — unknown keys are a 400): - `title` (string 3-512, required) - `description` (HTML string <= 20000 | null — sanitized server-side) - `timezone` (IANA id | null; absent -> the organizer settings default) - `venue` (required) — one of `{"id": ""}` (reuse existing), `{"name": "..."}` (find by case-insensitive name or create), or the string `"online"` (the shared Online venue) - `category_name` (string | null — find-or-create; absent -> "General") - `performances` (1-50, required): `{starts_at, ends_at?, capacity (1-1000000), title?}` — absent/null `ends_at` -> starts_at + 3 hours; past dates allowed (history imports) - `ticket_types` (1-20, required): `{name (2-64), description?, price_minor (0-100000000; 0 = free), quantity? (per-performance sale cap; null = uncapped)}` - `publish` (boolean, default true; false leaves a storefront-invisible draft) Publishing emits one `event.published` webhook per performance. The 201 response is the canonical read shape (identical to `GET /events/{id}`). - `GET /v1/events/{id}` (read) — event detail including ticket types and their per-performance prices. - `PATCH /v1/events/{id}` (write) — partial update of `title`, `description` (default-locale translation), `contact_email` (10-50 chars incl. @; null clears), and `is_archived`. Absent fields untouched; null clears the nullable fields; at least one field required. Emits an `event.updated` webhook. - `GET /v1/events/{id}/performances` (read) — the event's schedule, soonest first. Unknown/foreign event id is a 404 (never an empty list). Paginated. Event shape: `{id, object: "event", title, description, admission_type ("general_admission" | "reserved_seating"), image_url, contact_email, timezone, venue_id, category_id, is_archived, translations: [{locale, title, description}], created_at, updated_at}`. The detail shape adds `ticket_types: [{id, object: "ticket_type", name, description, sort_order, translations, prices: [{performance_id, seat_category_key, price_minor, limited_quantity, is_active}]}]`. `title`/`description` are a convenience resolution — the `en` translation first, else the first available. ### Performances - `GET /v1/performances/{id}` (read) — shape: `{id, object: "performance", event_id, status, title, starts_at, ends_at, capacity, online_sales_enabled, box_office_sales_enabled, translations, created_at, updated_at}`. - `GET /v1/performances/{id}/availability` (read) — remaining tickets per ticket type, the same math the storefront buyer sees: capacity - sold - active cart holds - hold allocations. Shape: `{object: "availability", performance_id, performance_remaining, ticket_types: [{ticket_type_id, remaining}]}`. A ticket type missing from `ticket_types` has no active price on this performance (not on sale). Advisory snapshot, not a reservation — the database recount at order placement remains the oversell guard. ### Orders - `GET /v1/orders` (read) — newest first. Filters: `status` (unpaid | placed | fulfilled | cancelled), `email` (exact, case-insensitive), `event_id`, `performance_id` (match any transaction line). Paginated. - `GET /v1/orders/{id}` (read) — transactions (per-event totals) always included; add `?expand[]=items` to embed each transaction's tickets. - `POST /v1/orders` (write) — one-shot OFFLINE sale on the box-office channel. Money never moves through this API: - `tender: "cash"` records a payment received in full out-of-band (order lands `placed`) - `tender: "pay_later"` creates an unpaid order (order lands `unpaid`) Body (strict): `items` (1-20 of `{performance_id, ticket_type_id, quantity (1-50)}`), `buyer` (`{first_name (required), last_name?, email (required — receives the receipt and e-tickets), phone?}`), `tender`. Box-office channel rules apply: the sale window is bypassed, but the performance must be published and box-office-sellable, and availability is enforced atomically (FOR UPDATE) — asking for more tickets than remain is a 400 whose `detail` names the ticket type and what remains (the 409s on this endpoint are the idempotency conflicts). Side effects on success: an `order.created` webhook, then best-effort receipt + e-ticket emails (e-tickets emit `ticket.issued`) and a CRM contact upsert. WITHOUT an Idempotency-Key, every call deliberately mints a new order. The 201 response is the canonical read shape with items included. Order shape: `{id, object: "order", order_no, status, channel (e.g. "online", "box_office"), buyer: {first_name, last_name, email, phone}, currency, total_minor, etickets_sent_at, disputed_at, created_at, updated_at}`. The detail shape adds `transactions: [{id, object: "order_transaction", transaction_no, event_id, performance_id, delivery_method, items_total_minor, surcharges_minor, taxes_minor, service_fees_minor, total_minor, items?: [ticket...]}]`. ### Tickets - `GET /v1/tickets` (read) — issued tickets (order items), newest first. Filters: `order_id`, `performance_id`, `status` (purchased | checked_in | refunded | cancelled). Paginated. - `GET /v1/tickets/{id}` (read) — shape: `{id, object: "ticket", ticket_no, serial_no, status, performance_id, ticket_type_id, holder: {first_name, last_name, email}, is_free, unit_price_minor, total_minor, checked_in_at, transferred_at, order_id, order_no, created_at}`. - `POST /v1/tickets/{id}/check_in` (write, no body) — admit one ticket by id. Authoritative: unlike the gate scanner (which proves possession of a signed QR), an API caller is the organizer's own tooling, so the ticket's current holder is admitted even after a transfer. Same atomic guards as the gate. A fresh admit emits `ticket.checked_in` exactly once. - 200 — admitted; returns the ticket with `status: "checked_in"` - 404 — no such ticket for this organizer - 409 — not admittable: already checked in, order unpaid, ticket refunded/cancelled, or a concurrent transfer raced this call (retry) ### Webhook endpoints (management) - `GET /v1/webhook_endpoints` (read) — newest first. Paginated. - `POST /v1/webhook_endpoints` (write) — register an HTTPS consumer. Body: `{url (https, <= 2048), description?, event_types (>= 1 of the 7 types below)}`. The 201 response carries the raw `whsec_` signing `secret` ONCE — every later read shows only `secret_preview` (`whsec_…`). Store it immediately. The URL is vetted against SSRF at create AND at every send: HTTPS only, no embedded credentials, every address the hostname resolves to must be public (private, link-local, loopback, and cloud-metadata ranges are rejected, 400). - `GET /v1/webhook_endpoints/{id}` (read) - `PATCH /v1/webhook_endpoints/{id}` (write) — partial: `url` (re-vetted), `description` (null clears), `event_types`, `status` (`enabled`/`disabled` manual toggle only — `auto_disabled` is the platform's verdict and never client-assignable; re-enabling clears `disabled_reason`). - `DELETE /v1/webhook_endpoints/{id}` (write) — hard delete, delivery log cascades. Returns `{id, object: "webhook_endpoint", deleted: true}`. - `POST /v1/webhook_endpoints/{id}/rotate_secret` (write, no body) — immediate cutover: the old secret stops verifying NOW; the new raw `whsec_` secret is in this response once. - `GET /v1/webhook_endpoints/{id}/deliveries` (read) — delivery log, newest first. Delivery shape: `{id, object: "webhook_delivery", endpoint_id, event_id (= the webhook-id header value), event_type, status (pending | success | failed | exhausted), attempt, response_status, response_ms, error, next_retry_at, created_at, updated_at}`. - `POST /v1/webhook_deliveries/{id}/redeliver` (write, no body) — appends a FRESH delivery row (attempt 0, fresh retry budget) for the same event -> endpoint pair; the original row is never mutated. The consumer-visible `webhook-id` stays the EVENT id, so consumer-side dedup still holds. 201. Endpoint shape: `{id, object: "webhook_endpoint", url, description, event_types, status (enabled | disabled | auto_disabled), disabled_reason, secret_preview, created_at, updated_at}`. ## Outbound webhooks Zunaro POSTs a JSON envelope to your endpoint per the Standard Webhooks convention (https://www.standardwebhooks.com/): {"type": "", "timestamp": "", "data": {...}} ### Delivery contract - Headers on every POST: - `webhook-id` — the EVENT id, stable across retries and manual redelivers; use it as your deduplication key. - `webhook-timestamp` — unix seconds, fresh per attempt. Reject stale values (e.g. > 5 minutes old) to bound replay. - `webhook-signature` — `v1,` (see below). - Any 2xx within 15 seconds acknowledges. Redirects are not followed (3xx = failure). - Retries on failure: 6 retries after the initial attempt, delayed 30s -> 2m -> 10m -> 1h -> 6h -> 24h (7 tries total). - Respond 410 Gone to permanently unsubscribe — the endpoint is auto-disabled and the delivery is not retried. Sustained failure (the last 20 deliveries all failed/exhausted, spanning at least 3 days) also auto-disables. - Payloads are compact summaries (< 20 KB) — fetch full resources via the REST API. - Delivery logs and event payloads are retained for 30 days. ### Verifying the signature 1. Take your endpoint secret `whsec_`; base64-decode the part AFTER the `whsec_` prefix to get the key bytes. 2. Compute `HMAC-SHA256(keyBytes, "{webhook-id}.{webhook-timestamp}.{raw body}")` over the exact raw request body. 3. Base64-encode the MAC and compare (constant-time) against the value after `v1,` in the `webhook-signature` header. 4. Reject stale `webhook-timestamp` values. Or use a Standard Webhooks library and pass it the whole `whsec_...` secret. ### Event types (subscribable) - `order.created` — fires once when a new order is minted, on every channel (online storefront, box office, kiosk, API). `data`: `{order_id, order_no, status, total_minor, currency, channel}`. NOTE: `channel` here is the cart layer's vocabulary (e.g. `online`, `boxOffice`) — distinct from the REST serializer's `box_office`. - `order.paid` — fires ONLY when a previously-unpaid order settles: the unpaid -> placed payment flip (settling a pay-later order from the dashboard or box office). Orders that settle in full at creation are born `placed` and announce themselves via `order.created` alone — no `order.paid` follows. `data`: `{order_id, order_no, status, amount_minor (the payment that completed the order; 0 when the flip needed no new money), total_minor, currency, tender (internal vocabulary, e.g. cash, card, payLater)}`. - `order.refunded` — TWO emission points share this type: 1. Refund initiated — offline modes emit terminal `status: "refunded"`; card (gateway) mode emits `status: "pending"`. `data`: `{order_id, refund_id, status, refunded_items, total_refunded_minor, currency, mode}`. 2. Gateway settlement — when the gateway confirms (or fails) a pending card refund, a second smaller payload fires: `{order_id, refund_id, status ("refunded" | "failed")}`. - `ticket.issued` — fires after the e-ticket email is in the buyer's hands. `data`: `{order_id, order_no, ticket_ids (capped at 100), ticket_ids_truncated, ticket_count (the real total)}`. When truncated, list them all via `GET /v1/tickets?order_id=...`. - `ticket.checked_in` — fires exactly once per fresh admit, from every entrance (gate scanner and API alike). `data`: `{ticket_id, ticket_no, order_no, event_id, performance_id}`. - `event.published` — fires per performance on its go-live transition (dashboard publish switch, and `POST /v1/events` with `publish: true` — one emission per created performance). `data`: `{event_id, performance_id}`. - `event.updated` — fires when an existing event's details are saved (dashboard editor and `PATCH /v1/events/{id}`). `data`: `{event_id}`. Plus `test.ping` — a synthetic event sent by the dashboard's "send test event" button. Targets one explicit endpoint (never part of subscription fan-out; not a subscribable `event_types` value) and is delivered even to disabled endpoints. `data`: `{message, endpoint_id}`. ## More - Human-readable API reference: https://zunaro.ai/developers/api - Developer landing: https://zunaro.ai/developers - Product guides (dashboard side): https://zunaro.ai/academy