{"openapi":"3.1.0","info":{"title":"Zillo Developer API","version":"1.0.0","description":"Programmatic access to a Zillo merchant's products, orders, customers, gift cards, tickets, bookings, vouchers, and memberships. Authenticate with a scoped API key (`zk_live_…` / `zk_test_…`) or an OAuth access token minted via the MCP install flow.","contact":{"name":"Zillo Support","url":"https://docs.zillo.app/developers"}},"servers":[{"url":"https://api.zillo.app/v1","description":"Production"}],"tags":[{"name":"Products","description":"Read and manage the items a merchant sells: gift cards, tickets, experience slots, memberships, and vouchers. Products live in one of three states (`draft`, `published`, `archived`) — only `published` products appear on the storefront. Type-specific fields (event capacity, slot inventory, recurring plan price, etc.) hang off the base record via subtype tables and are returned inline when relevant."},{"name":"Orders","description":"Customer transactions across every product type. Orders progress through `pending` → `paid` → `fulfilled` (or `refunded` / `partially_refunded`) and carry the breakdown the customer was charged: subtotal, fees, tax, total. Each order spawns the issued items it bought (gift card, tickets, booking, membership, voucher) as separate resources — orders aren't a substitute for those."},{"name":"Customers","description":"The people who've bought from you. A customer record is created on first purchase (deduped by email) and persists across subsequent orders, gift card redemptions, and membership renewals. `accepts_marketing` reflects the checkbox the customer ticked at checkout."},{"name":"Gift cards","description":"Gift card balances issued from a `gift_card` product. Each card has an immutable `initial_cents` and a mutable `balance_cents` that ticks down via `POST /gift_cards/{id}/redeem`. Cards can be voided (e.g. fraud) or expired (per the merchant's gift-card settings); both states refuse redemptions."},{"name":"Tickets","description":"Tickets issued from a `ticket` product (events with finite capacity). Each ticket carries a `qr_token` (the `T-XXXX-…` string that goes on the wallet pass) used by staff to scan at the door. Single-entry tickets (`max_uses=1`) flip `redeemed_at` on first scan; multi-entry tickets (`max_uses>1` — e.g. 3-day festival pass, in/out re-entry) accept up to `max_uses` scans before returning 409 `exhausted`. Every scan appends a row to the redemption history and fires the `ticket.redeemed` webhook with `was_first` set."},{"name":"Bookings","description":"Reservations against an `experience` product. Two flavours: single-use slot bookings (capacity enforced at checkout under `SELECT … FOR UPDATE`, `experience_slot_id` set) and multi-pass walk-in passes (`max_uses>1`, `experience_slot_id` null — the buyer doesn't pick a date, they walk in and get scanned each visit). Same `qr_token` + scan-at-the-door pattern as Tickets, prefixed `B-`. Multi-pass scans accept up to `max_uses` before returning 409 `exhausted`."},{"name":"Vouchers","description":"Non-monetary redeemables. Single-use (`max_uses=1`, e.g. \"Free haircut\") or multi-use packs (`max_uses>1`, e.g. \"10 coffees for $40\"). Ticket-shaped row, no parent event; the `qr_token` is `V-` prefixed so scan-side code can route by prefix without an extra lookup."},{"name":"Memberships","description":"Recurring subscriptions billed through Stripe on the connected account. Statuses mirror Stripe's: `active`, `trialing`, `past_due`, `canceled`. Members carry an `M-` prefixed QR token used for door check-ins via the mobile app's POS / redeem flow."},{"name":"Webhooks","description":"Outbound HTTPS endpoints the API will POST signed JSON event payloads to. Every delivery is signed with HMAC-SHA256 over the body using the endpoint's secret; verify via the `Zillo-Signature` header. Failed deliveries are retried with exponential backoff. See `/developers/webhooks/events` for the catalogue."},{"name":"Redemptions","description":"Cross-resource lookups for at-the-door staff: paste a token, find out which gift card / ticket / booking / voucher / membership it belongs to. Powers the mobile redeem screen and the dashboard's Redeem console."}],"components":{"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","bearerFormat":"zk_<mode>_<secret>","description":"Pass `Authorization: Bearer zk_live_…` or `zk_test_…`."},"oauth2":{"type":"oauth2","flows":{"authorizationCode":{"authorizationUrl":"https://dashboard.zillo.app/oauth/authorize","tokenUrl":"https://api.zillo.app/oauth/token","scopes":{"products:read":"products:read","products:write":"products:write","orders:read":"orders:read","orders:write":"orders:write","customers:read":"customers:read","customers:write":"customers:write","gift_cards:read":"gift_cards:read","gift_cards:write":"gift_cards:write","gift_cards:redeem":"gift_cards:redeem","tickets:read":"tickets:read","tickets:write":"tickets:write","tickets:redeem":"tickets:redeem","bookings:read":"bookings:read","bookings:write":"bookings:write","bookings:redeem":"bookings:redeem","vouchers:read":"vouchers:read","vouchers:write":"vouchers:write","vouchers:redeem":"vouchers:redeem","memberships:read":"memberships:read","memberships:write":"memberships:write","memberships:checkin":"memberships:checkin","webhooks:read":"webhooks:read","webhooks:write":"webhooks:write"}}}}},"responses":{"Unauthorized":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"ProRequired":{"description":"API access requires Zillo Pro on the store (error `pro_required`). Stores already using the API before Pro launched are unaffected.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"Resource not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RateLimited":{"description":"Rate limit exceeded. Inspect `X-RateLimit-Reset`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string"},"message":{"type":"string"}},"required":["error","message"]},"product":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["product"]},"created":{"type":"integer","description":"Unix seconds."},"updated":{"type":"integer"},"merchant_id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["gift_card","experience","ticket","membership","voucher","physical_product","digital_product"]},"status":{"type":"string","enum":["draft","published","archived"]},"title":{"type":"string"},"slug":{"type":"string"},"description":{"type":["string","null"]},"image_url":{"type":["string","null"],"format":"uri"},"price_cents":{"type":"integer","minimum":0,"description":"Base / 'from' price. For physical & digital products the authoritative price is per-variant."},"currency":{"type":"string"},"variants":{"type":"array","description":"Purchasable variants (physical_product / digital_product). Returned on GET /products/{id}, omitted on list endpoints.","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["product_variant"]},"options":{"type":"object","description":"Chosen option values, e.g. {\"Size\":\"M\"}. Empty for a no-options default variant.","additionalProperties":{"type":"string"}},"sku":{"type":["string","null"]},"price_cents":{"type":"integer","minimum":0},"stock":{"type":["integer","null"],"description":"null = untracked / unlimited."},"sold_count":{"type":"integer","minimum":0}},"required":["id","object","options","price_cents","sold_count"]}}},"required":["id","object","created","merchant_id","type","status","title","slug","price_cents","currency"]},"order":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["order"]},"created":{"type":"integer"},"merchant_id":{"type":"string","format":"uuid"},"status":{"type":"string"},"currency":{"type":"string"},"customer_email":{"type":"string","format":"email"},"customer_name":{"type":["string","null"]},"subtotal_cents":{"type":"integer"},"total_cents":{"type":"integer"},"fee_cents":{"type":"integer"},"tax_cents":{"type":"integer"},"shipping_cents":{"type":"integer","description":"Order-level shipping charge (one shipment). 0 when no physical line."},"shipping_address":{"type":["object","null"],"description":"Buyer shipping address — present only when the order contains a physical_product line.","properties":{"name":{"type":"string"},"line1":{"type":"string"},"line2":{"type":["string","null"]},"city":{"type":"string"},"region":{"type":["string","null"]},"postal_code":{"type":"string"},"country":{"type":"string"},"phone":{"type":["string","null"]}}}},"required":["id","object","created","merchant_id","status","currency","subtotal_cents","total_cents"]},"customer":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["customer"]},"created":{"type":"integer"},"email":{"type":"string","format":"email"},"name":{"type":["string","null"]},"accepts_marketing":{"type":"boolean"}},"required":["id","object","created","email","accepts_marketing"]},"gift_card":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["gift_card"]},"created":{"type":"integer"},"code":{"type":"string"},"initial_cents":{"type":"integer"},"balance_cents":{"type":"integer"},"redeemed_at":{"type":["integer","null"]},"voided_at":{"type":["integer","null"]}},"required":["id","object","created","code","initial_cents","balance_cents"]},"ticket":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["ticket"]},"qr_token":{"type":"string"},"seat_index":{"type":"integer","minimum":1},"max_uses":{"type":"integer","minimum":1,"description":"Scans allowed per ticket. `1` for single-entry tickets; `>1` for multi-day or multi-entry passes (e.g. 3-day festival pass, in/out re-entry). Stamped at issuance — later product edits never change already-issued tickets."},"uses_after":{"type":"integer","minimum":0,"description":"Number of scan events recorded so far. Walk `/tickets/{id}/redemptions` for the per-scan event log."},"remaining":{"type":"integer","minimum":0,"description":"Convenience field: `max_uses - uses_after`. Reaches 0 when the ticket is fully used."},"redeemed_at":{"type":["integer","null"],"description":"Unix seconds of the *first* scan. Stays set on multi-entry tickets after subsequent scans — use `uses_after` to tell whether the ticket is fully used."},"voided_at":{"type":["integer","null"]}},"required":["id","object","qr_token","max_uses","uses_after","remaining"]},"ticket_redemption":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["ticket_redemption"]},"created":{"type":"integer"},"merchant_id":{"type":"string","format":"uuid"},"ticket_id":{"type":"string","format":"uuid"},"redeemed_at":{"type":"integer"},"source":{"type":["string","null"]},"note":{"type":["string","null"]}},"required":["id","object","created","ticket_id","redeemed_at"]},"booking":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["booking"]},"qr_token":{"type":"string"},"party_size":{"type":"integer"},"experience_slot_id":{"type":["string","null"],"format":"uuid","description":"Reserved slot id, or `null` for multi-pass walk-in bookings (`max_uses > 1`) where no slot was picked at purchase."},"max_uses":{"type":"integer","minimum":1,"description":"Scans allowed on this booking. `1` for traditional slot-based bookings; `>1` for multi-pass walk-in passes (e.g. 10-class yoga pack). Stamped at issuance."},"uses_after":{"type":"integer","minimum":0,"description":"Number of scan events recorded so far. Walk `/bookings/{id}/redemptions` for the per-scan event log."},"remaining":{"type":"integer","minimum":0,"description":"Convenience field: `max_uses - uses_after`."},"redeemed_at":{"type":["integer","null"],"description":"Unix seconds of the *first* scan. Use `uses_after` to tell whether a multi-pass is fully spent."},"voided_at":{"type":["integer","null"]}},"required":["id","object","qr_token","party_size","max_uses","uses_after","remaining"]},"booking_redemption":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["booking_redemption"]},"created":{"type":"integer"},"merchant_id":{"type":"string","format":"uuid"},"booking_id":{"type":"string","format":"uuid"},"redeemed_at":{"type":"integer"},"source":{"type":["string","null"]},"note":{"type":["string","null"]}},"required":["id","object","created","booking_id","redeemed_at"]},"voucher":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["voucher"]},"qr_token":{"type":"string"},"max_uses":{"type":"integer","minimum":1,"description":"Total redemptions allowed on this pass. `1` for single-use vouchers; `>1` for prepaid packs (e.g. a 5-class yoga pass). Stamped at issuance — later product edits never change already-sold passes."},"uses_after":{"type":"integer","minimum":0,"description":"Number of redemptions recorded so far. Walk `/vouchers/{id}/redemptions` for the per-scan event log."},"remaining":{"type":"integer","minimum":0,"description":"Convenience field: `max_uses - uses_after`. Reaches 0 when the pass is fully spent."},"redeemed_at":{"type":["integer","null"],"description":"Unix seconds of the *first* redemption. Stays set on multi-use packs after subsequent scans — use `uses_after` to tell whether the pass is fully spent."},"voided_at":{"type":["integer","null"]},"expires_at":{"type":["integer","null"]},"pass_index":{"type":"integer","minimum":1}},"required":["id","object","qr_token","max_uses","uses_after","remaining"]},"voucher_redemption":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["voucher_redemption"]},"created":{"type":"integer"},"merchant_id":{"type":"string","format":"uuid"},"voucher_id":{"type":"string","format":"uuid"},"redeemed_at":{"type":"integer"},"source":{"type":["string","null"]},"note":{"type":["string","null"]}},"required":["id","object","created","voucher_id","redeemed_at"]},"membership_usage":{"type":"object","description":"Per-period check-in allowance snapshot. Present when the plan sets `max_checkins_per_period` (e.g. '5 classes a month'); omitted on unlimited plans. Counts reset automatically each Stripe billing period.","properties":{"max_per_period":{"type":["integer","null"],"minimum":1,"description":"Total check-ins allowed per period. `null` on unlimited plans."},"used_this_period":{"type":"integer","minimum":0},"remaining_this_period":{"type":["integer","null"],"minimum":0},"period_start":{"type":["integer","null"],"description":"Unix seconds — start of the current Stripe billing period."},"period_end":{"type":["integer","null"],"description":"Unix seconds — when the allowance refills."}},"required":["max_per_period","used_this_period","remaining_this_period","period_start","period_end"]},"membership":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["membership"]},"qr_token":{"type":"string"},"status":{"type":"string"},"current_period_start":{"type":["integer","null"]},"current_period_end":{"type":["integer","null"]},"cancel_at_period_end":{"type":"boolean"},"usage":{"$ref":"#/components/schemas/membership_usage","description":"Live per-period allowance. Present on the detail endpoint and on check-in responses + webhook payloads when the plan has a cap; omitted otherwise (and on the list endpoint, to avoid an N+1 count query — call the detail endpoint when you need it)."}},"required":["id","object","qr_token","status"]},"membership_checkin":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["membership_checkin"]},"created":{"type":"integer"},"merchant_id":{"type":"string","format":"uuid"},"membership_id":{"type":"string","format":"uuid"},"checked_in_at":{"type":"integer"},"note":{"type":["string","null"]},"source":{"type":["string","null"]},"usage":{"$ref":"#/components/schemas/membership_usage","description":"Allowance state AFTER this check-in landed. Present when the underlying plan has a `max_checkins_per_period` cap; omitted on unlimited plans."}},"required":["id","object","created","membership_id","checked_in_at"]},"webhook_endpoint":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["webhook_endpoint"]},"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"signing_secret_prefix":{"type":"string"},"description":{"type":["string","null"]}},"required":["id","object","url","events","enabled"]},"webhook_delivery":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["webhook_delivery"]},"created":{"type":"integer"},"endpoint_id":{"type":"string","format":"uuid"},"event_id":{"type":"string"},"event_type":{"type":"string"},"status":{"type":"string","enum":["pending","in_flight","succeeded","failed","abandoned"]},"attempt_count":{"type":"integer"},"last_attempt_at":{"type":["integer","null"]},"next_attempt_at":{"type":"integer"},"response_status":{"type":["integer","null"]}},"required":["id","object","created","endpoint_id","event_id","event_type","status","attempt_count","next_attempt_at"]}}},"paths":{"/products":{"get":{"tags":["Products"],"summary":"List products","description":"Returns a paginated list of products owned by the authenticated merchant, ordered by creation time (newest first). Walk the cursor with `starting_after` / `ending_before` and a `limit` of up to 100 per page. Requires the `products:read` scope.","security":[{"apiKey":["products:read"]}],"parameters":[{"name":"limit","in":"query","description":"Page size (1–100). Defaults to 25.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"starting_after","in":"query","description":"Cursor for the next page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"ending_before","in":"query","description":"Cursor for the previous page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"type","in":"query","required":false,"schema":{"type":"string","enum":["gift_card","experience","ticket","membership","voucher"]}},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["draft","published","archived"]}}],"responses":{"200":{"description":"Paged list response.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/product"}},"has_more":{"type":"boolean"},"url":{"type":"string"}},"required":["object","data","has_more","url"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/products/{id}":{"get":{"tags":["Products"],"summary":"Retrieve a product","description":"Retrieve a single product by id. Returns 404 if no product with that id exists in the authenticated merchant's scope — the id space is per-merchant, so an id from a different merchant will not resolve. Requires the `products:read` scope.","security":[{"apiKey":["products:read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The resource.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/product"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/orders":{"get":{"tags":["Orders"],"summary":"List orders","description":"Returns a paginated list of orders owned by the authenticated merchant, ordered by creation time (newest first). Walk the cursor with `starting_after` / `ending_before` and a `limit` of up to 100 per page. Requires the `orders:read` scope.","security":[{"apiKey":["orders:read"]}],"parameters":[{"name":"limit","in":"query","description":"Page size (1–100). Defaults to 25.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"starting_after","in":"query","description":"Cursor for the next page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"ending_before","in":"query","description":"Cursor for the previous page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Paged list response.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/order"}},"has_more":{"type":"boolean"},"url":{"type":"string"}},"required":["object","data","has_more","url"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/orders/{id}":{"get":{"tags":["Orders"],"summary":"Retrieve a order","description":"Retrieve a single order by id. Returns 404 if no order with that id exists in the authenticated merchant's scope — the id space is per-merchant, so an id from a different merchant will not resolve. Requires the `orders:read` scope.","security":[{"apiKey":["orders:read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The resource.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/order"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/customers":{"get":{"tags":["Customers"],"summary":"List customers","description":"Returns a paginated list of customers owned by the authenticated merchant, ordered by creation time (newest first). Walk the cursor with `starting_after` / `ending_before` and a `limit` of up to 100 per page. Requires the `customers:read` scope.","security":[{"apiKey":["customers:read"]}],"parameters":[{"name":"limit","in":"query","description":"Page size (1–100). Defaults to 25.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"starting_after","in":"query","description":"Cursor for the next page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"ending_before","in":"query","description":"Cursor for the previous page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Paged list response.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/customer"}},"has_more":{"type":"boolean"},"url":{"type":"string"}},"required":["object","data","has_more","url"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/customers/{id}":{"get":{"tags":["Customers"],"summary":"Retrieve a customer","description":"Retrieve a single customer by id. Returns 404 if no customer with that id exists in the authenticated merchant's scope — the id space is per-merchant, so an id from a different merchant will not resolve. Requires the `customers:read` scope.","security":[{"apiKey":["customers:read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The resource.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/customer"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/gift_cards":{"get":{"tags":["Gift cards"],"summary":"List gift cards","description":"Returns a paginated list of gift cards owned by the authenticated merchant, ordered by creation time (newest first). Walk the cursor with `starting_after` / `ending_before` and a `limit` of up to 100 per page. Requires the `gift_cards:read` scope.","security":[{"apiKey":["gift_cards:read"]}],"parameters":[{"name":"limit","in":"query","description":"Page size (1–100). Defaults to 25.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"starting_after","in":"query","description":"Cursor for the next page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"ending_before","in":"query","description":"Cursor for the previous page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Paged list response.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/gift_card"}},"has_more":{"type":"boolean"},"url":{"type":"string"}},"required":["object","data","has_more","url"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/gift_cards/{id}":{"get":{"tags":["Gift cards"],"summary":"Retrieve a gift card","description":"Retrieve a single gift card by id. Returns 404 if no gift card with that id exists in the authenticated merchant's scope — the id space is per-merchant, so an id from a different merchant will not resolve. Requires the `gift_cards:read` scope.","security":[{"apiKey":["gift_cards:read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The resource.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/gift_card"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/gift_cards/{id}/redeem":{"post":{"tags":["Gift cards"],"summary":"Redeem a gift card","description":"Decrement a gift card's balance by `amount_cents`. The card must be `active` (not voided, not expired) and the amount must be ≤ the current balance — otherwise the call returns 400 with a structured error. Idempotent on the (card id, amount, note) tuple within a short window: safe to retry on network blips. Records a `gift_card_transactions` row and fires `gift_card.redeemed`. Requires the `gift_cards:redeem` scope.","security":[{"apiKey":["gift_cards:redeem"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"amount_cents":{"type":"integer","minimum":1,"maximum":100000000},"note":{"type":"string","maxLength":280}},"required":["amount_cents"]}}}},"responses":{"200":{"description":"Updated gift card + amount redeemed.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"gift_card":{"$ref":"#/components/schemas/gift_card"},"redeemed_amount_cents":{"type":"integer"}}}}}}}}},"/tickets":{"get":{"tags":["Tickets"],"summary":"List tickets","description":"Returns a paginated list of tickets owned by the authenticated merchant, ordered by creation time (newest first). Walk the cursor with `starting_after` / `ending_before` and a `limit` of up to 100 per page. Requires the `tickets:read` scope.","security":[{"apiKey":["tickets:read"]}],"parameters":[{"name":"limit","in":"query","description":"Page size (1–100). Defaults to 25.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"starting_after","in":"query","description":"Cursor for the next page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"ending_before","in":"query","description":"Cursor for the previous page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Paged list response.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/ticket"}},"has_more":{"type":"boolean"},"url":{"type":"string"}},"required":["object","data","has_more","url"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/tickets/{id}":{"get":{"tags":["Tickets"],"summary":"Retrieve a ticket","description":"Retrieve a single ticket by id. Returns 404 if no ticket with that id exists in the authenticated merchant's scope — the id space is per-merchant, so an id from a different merchant will not resolve. Requires the `tickets:read` scope.","security":[{"apiKey":["tickets:read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The resource.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ticket"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/tickets/{id}/redeem":{"post":{"tags":["Tickets"],"summary":"Redeem a ticket","description":"Flip the ticket's `redeemed_at` timestamp. One-shot — subsequent calls return 409 `already_redeemed_or_voided`. Fires the `ticket.redeemed` webhook event on success. Requires the `tickets:redeem` scope.","security":[{"apiKey":["tickets:redeem"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Updated ticket.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"ticket":{"$ref":"#/components/schemas/ticket"}},"required":["ok"]}}}},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Ticket is already redeemed, voided, or (for vouchers) expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/bookings":{"get":{"tags":["Bookings"],"summary":"List bookings","description":"Returns a paginated list of bookings owned by the authenticated merchant, ordered by creation time (newest first). Walk the cursor with `starting_after` / `ending_before` and a `limit` of up to 100 per page. Requires the `bookings:read` scope.","security":[{"apiKey":["bookings:read"]}],"parameters":[{"name":"limit","in":"query","description":"Page size (1–100). Defaults to 25.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"starting_after","in":"query","description":"Cursor for the next page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"ending_before","in":"query","description":"Cursor for the previous page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Paged list response.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/booking"}},"has_more":{"type":"boolean"},"url":{"type":"string"}},"required":["object","data","has_more","url"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/bookings/{id}":{"get":{"tags":["Bookings"],"summary":"Retrieve a booking","description":"Retrieve a single booking by id. Returns 404 if no booking with that id exists in the authenticated merchant's scope — the id space is per-merchant, so an id from a different merchant will not resolve. Requires the `bookings:read` scope.","security":[{"apiKey":["bookings:read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The resource.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/booking"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/bookings/{id}/redeem":{"post":{"tags":["Bookings"],"summary":"Redeem a booking","description":"Flip the booking's `redeemed_at` timestamp. One-shot — subsequent calls return 409 `already_redeemed_or_voided`. Fires the `booking.redeemed` webhook event on success. Requires the `bookings:redeem` scope.","security":[{"apiKey":["bookings:redeem"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Updated booking.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"booking":{"$ref":"#/components/schemas/booking"}},"required":["ok"]}}}},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Booking is already redeemed, voided, or (for vouchers) expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/vouchers":{"get":{"tags":["Vouchers"],"summary":"List vouchers","description":"Returns a paginated list of vouchers owned by the authenticated merchant, ordered by creation time (newest first). Walk the cursor with `starting_after` / `ending_before` and a `limit` of up to 100 per page. Requires the `vouchers:read` scope.","security":[{"apiKey":["vouchers:read"]}],"parameters":[{"name":"limit","in":"query","description":"Page size (1–100). Defaults to 25.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"starting_after","in":"query","description":"Cursor for the next page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"ending_before","in":"query","description":"Cursor for the previous page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Paged list response.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/voucher"}},"has_more":{"type":"boolean"},"url":{"type":"string"}},"required":["object","data","has_more","url"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/vouchers/{id}":{"get":{"tags":["Vouchers"],"summary":"Retrieve a voucher","description":"Retrieve a single voucher by id. Returns 404 if no voucher with that id exists in the authenticated merchant's scope — the id space is per-merchant, so an id from a different merchant will not resolve. Requires the `vouchers:read` scope.","security":[{"apiKey":["vouchers:read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The resource.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/voucher"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/vouchers/{id}/redeem":{"post":{"tags":["Vouchers"],"summary":"Redeem a voucher","description":"Records one redemption against the voucher. Single-use passes accept exactly one scan; multi-use packs (e.g. a 5-class yoga pass) accept up to `max_uses` scans before returning 409 `exhausted`. Atomicity is enforced by an underlying Postgres RPC that locks the parent row before counting + inserting, so two concurrent scans can't over-redeem a pack. Each successful call appends a row to the redemption history (`GET /v1/vouchers/{id}/redemptions`) and fires the `voucher.redeemed` webhook event — subscribers see every scan, not just the first. Requires the `vouchers:redeem` scope.","security":[{"apiKey":["vouchers:redeem"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Updated voucher + the new redemption event row.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"voucher":{"$ref":"#/components/schemas/voucher"},"redemption":{"$ref":"#/components/schemas/voucher_redemption"},"was_first":{"type":"boolean","description":"True iff this scan was the first one on the voucher (single-use scans always set this to true; multi-use only on the opening scan)."}},"required":["ok","voucher","redemption","was_first"]}}}},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Voucher cannot be redeemed. Distinct codes: `voided` (refunded), `expired` (`expires_at` has passed), `exhausted` (max_uses reached — either single-use already scanned, or multi-use pack fully spent).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["voided","expired","exhausted"]},"message":{"type":"string"}},"required":["error","message"]}}}}}}},"/vouchers/{id}/redemptions":{"get":{"tags":["Vouchers"],"summary":"List redemption events for a voucher","description":"Cursor-paginated append-only history of every scan against a voucher. Single-use passes will have at most one entry; multi-use packs (e.g. a 10-coffees pass) have up to `max_uses` entries. Newest first.","security":[{"apiKey":["vouchers:read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/StartingAfter"},{"$ref":"#/components/parameters/EndingBefore"}],"responses":{"200":{"description":"Paginated list of redemption events.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/voucher_redemption"}},"has_more":{"type":"boolean"},"url":{"type":"string"}},"required":["object","data","has_more","url"]}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/memberships":{"get":{"tags":["Memberships"],"summary":"List memberships","description":"Returns a paginated list of memberships owned by the authenticated merchant, ordered by creation time (newest first). Walk the cursor with `starting_after` / `ending_before` and a `limit` of up to 100 per page. Requires the `memberships:read` scope.","security":[{"apiKey":["memberships:read"]}],"parameters":[{"name":"limit","in":"query","description":"Page size (1–100). Defaults to 25.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"starting_after","in":"query","description":"Cursor for the next page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"ending_before","in":"query","description":"Cursor for the previous page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Paged list response.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/membership"}},"has_more":{"type":"boolean"},"url":{"type":"string"}},"required":["object","data","has_more","url"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/memberships/{id}":{"get":{"tags":["Memberships"],"summary":"Retrieve a membership","description":"Retrieve a single membership by id. Returns 404 if no membership with that id exists in the authenticated merchant's scope — the id space is per-merchant, so an id from a different merchant will not resolve. Requires the `memberships:read` scope.","security":[{"apiKey":["memberships:read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The resource.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/membership"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/memberships/{id}/checkins":{"post":{"tags":["Memberships"],"summary":"Record a membership check-in","description":"Append a check-in row for the given membership (gym visit, studio drop-in, etc.). For unlimited plans every call writes a new row — use the rate-limiting your client already enforces to dedupe; the server does not. For plans with a per-period cap (e.g. '5 classes a month' via `membership_plans.max_checkins_per_period`), the cap is enforced atomically: scans beyond the allowance return 409 `exhausted`. The allowance refills automatically when Stripe advances the billing period on renewal — no client-side cron needed. Responses on capped plans carry a `usage` block so you can render '3 of 5 used · refills May 1' without a follow-up GET. Fires the `membership.checkin.created` webhook event. Requires the `memberships:checkin` scope.","security":[{"apiKey":["memberships:checkin"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"note":{"type":"string","maxLength":280,"description":"Optional staff-facing note ('drop-in class', '+1 guest', etc.)."}}}}}},"responses":{"201":{"description":"Created check-in. Includes a `usage` block on capped plans (`max_checkins_per_period` set); omitted on unlimited plans.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/membership_checkin"}}}},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Check-in rejected. Common codes: `inactive` (membership is canceled / unpaid / incomplete), `exhausted` (per-period allowance is spent — a `usage` block is included with `period_end` telling the client when the allowance refills).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["inactive","exhausted"]},"message":{"type":"string"},"usage":{"$ref":"#/components/schemas/membership_usage"}},"required":["error","message"]}}}}}}},"/redemptions/lookup":{"get":{"tags":["Redemptions"],"summary":"Resolve a redemption token across artifact types","description":"Cross-artifact resolver for at-the-door staff. Takes a token (gift card `XXXX-XXXX-XXXX`, `T-…` ticket, `B-…` booking, `V-…` voucher, or `M-…` membership) and returns the matching record under an `object` envelope naming the artifact kind. The caller then POSTs to the type-specific redeem endpoint. Requires the read scope for **every** artifact type since the resolver walks all five tables — designed for trusted server-side integrations (kiosks, custom scanners) holding a broad key.","security":[{"apiKey":["gift_cards:read","tickets:read","bookings:read","vouchers:read","memberships:read"]}],"parameters":[{"name":"token","in":"query","required":true,"description":"The token to resolve. Prefix-encoded; case-insensitive.","schema":{"type":"string","minLength":1,"maxLength":64}}],"responses":{"200":{"description":"The resolved artifact. The `object` field names which schema is populated.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["gift_card","ticket","booking","voucher","membership"]},"gift_card":{"$ref":"#/components/schemas/gift_card"},"ticket":{"$ref":"#/components/schemas/ticket"},"booking":{"$ref":"#/components/schemas/booking"},"voucher":{"$ref":"#/components/schemas/voucher"},"membership":{"$ref":"#/components/schemas/membership"}},"required":["object"]}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/webhooks":{"get":{"tags":["Webhooks"],"summary":"List webhook endpoints","description":"List the merchant's registered webhook endpoints with cursor pagination. Each endpoint exposes its `signing_secret_prefix` (safe to log) but never the plaintext — that's only returned at `POST /webhooks` (or on rotation via `PATCH`).","security":[{"apiKey":["webhooks:read"]}],"parameters":[{"name":"limit","in":"query","description":"Page size (1–100). Defaults to 25.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"starting_after","in":"query","description":"Cursor for the next page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"ending_before","in":"query","description":"Cursor for the previous page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Paged list response.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/webhook_endpoint"}},"has_more":{"type":"boolean"},"url":{"type":"string"}},"required":["object","data","has_more","url"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}},"post":{"tags":["Webhooks"],"summary":"Register a webhook endpoint","description":"Register a new HTTPS endpoint to receive signed event payloads. The plaintext `signing_secret` is returned in the response **once** — store it somewhere safe; thereafter only `signing_secret_prefix` is readable. Pass `[\"*\"]` in `events` to subscribe to every current and future event, or a specific list (see `/v1/developers/webhooks/events` for the catalogue). Requires the `webhooks:write` scope.","security":[{"apiKey":["webhooks:write"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri","description":"Public HTTPS URL. Plain HTTP is rejected."},"events":{"type":"array","minItems":1,"items":{"type":"string","description":"Event name or `*`."}},"description":{"type":["string","null"],"maxLength":280}},"required":["url","events"]}}}},"responses":{"201":{"description":"Created endpoint plus the one-time plaintext `signing_secret`.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/webhook_endpoint"},{"type":"object","properties":{"signing_secret":{"type":"string"}},"required":["signing_secret"]}]}}}}}}},"/webhooks/{id}":{"get":{"tags":["Webhooks"],"summary":"Retrieve a webhook endpoint","description":"Retrieve a single webhook endpoint by id. Returns 404 if no webhook endpoint with that id exists in the authenticated merchant's scope — the id space is per-merchant, so an id from a different merchant will not resolve. Requires the `webhooks:read` scope.","security":[{"apiKey":["webhooks:read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The resource.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhook_endpoint"}}}},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"tags":["Webhooks"],"summary":"Update a webhook endpoint","description":"Partial update — only the fields you include are touched. Pass `rotate_signing_secret: true` to mint a new secret; the new plaintext is returned in `signing_secret` exactly once. Disable an endpoint without deleting it by sending `enabled: false`. Requires the `webhooks:write` scope.","security":[{"apiKey":["webhooks:write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","minItems":1,"items":{"type":"string"}},"enabled":{"type":"boolean"},"description":{"type":["string","null"],"maxLength":280},"rotate_signing_secret":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Updated endpoint. Includes `signing_secret` only when rotated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhook_endpoint"}}}},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"tags":["Webhooks"],"summary":"Delete a webhook endpoint","description":"Permanently delete the endpoint. In-flight deliveries are abandoned; new events will not target this URL. Requires the `webhooks:write` scope.","security":[{"apiKey":["webhooks:write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Endpoint deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string","format":"uuid"}},"required":["deleted","id"]}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/webhooks/{id}/deliveries":{"get":{"tags":["Webhooks"],"summary":"List deliveries for an endpoint","description":"Recent delivery attempts (succeeded / failed / pending / in-flight / abandoned) for the given endpoint, ordered by `next_attempt_at` desc. Useful for debugging which events failed and how many retries Zillo has tried. Requires the `webhooks:read` scope.","security":[{"apiKey":["webhooks:read"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","description":"Page size (1–100). Defaults to 25.","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"starting_after","in":"query","description":"Cursor for the next page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"ending_before","in":"query","description":"Cursor for the previous page (resource id).","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["pending","in_flight","succeeded","failed","abandoned"]}}],"responses":{"200":{"description":"Paged list of deliveries.","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/webhook_delivery"}},"has_more":{"type":"boolean"},"url":{"type":"string"}},"required":["object","data","has_more"]}}}},"404":{"$ref":"#/components/responses/NotFound"}}}},"/webhooks/{id}/deliveries/{delivery_id}/resend":{"post":{"tags":["Webhooks"],"summary":"Requeue a delivery for another attempt","description":"Reset a delivery's `next_attempt_at` to now and bump it back into the `pending` queue. Use this to re-fire a failed delivery after fixing the receiver, or to manually replay a succeeded one. Requires the `webhooks:write` scope.","security":[{"apiKey":["webhooks:write"]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"delivery_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Updated delivery row.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhook_delivery"}}}},"404":{"$ref":"#/components/responses/NotFound"}}}}}}