{"openapi":"3.1.0","info":{"title":"Reservory Public API","version":"1.0.0","description":"Public booking surface for embedding Reservory in your site, building integrations, and orchestrating bookings from Zapier or your own backend. Operator-side admin endpoints are documented at /api/docs but excluded from this spec for now.\n\nAuthentication: most endpoints are anon-callable (rate-limited) and accept an Idempotency-Key header to deduplicate retries. Operator-callable endpoints accept a tenant API key as `Authorization: Bearer rsv_<...>` (see /dashboard/settings/team).","contact":{"name":"Reservory Support","url":"https://reservory.com"}},"servers":[{"url":"https://app.reservory.com"}],"tags":[{"name":"Discovery","description":"Public read-only data about experiences + slots."},{"name":"Booking","description":"The hold → confirm flow customers use to book."},{"name":"Operator","description":"Booking actions usable from Zapier with an rsv_ API key."},{"name":"Waiver","description":"Anon-callable waiver sign flow (token-authed)."},{"name":"Payment","description":"Stripe payment-intent creation tied to a booking_token."}],"components":{"securitySchemes":{"idempotencyKey":{"type":"apiKey","in":"header","name":"Idempotency-Key","description":"Required on POSTs that mutate state. Retries with the same key return the cached response."},"tenantApiKey":{"type":"http","scheme":"bearer","bearerFormat":"rsv_<prefix>.<secret>","description":"Tenant-issued API key minted at /dashboard/settings/team. Manager or staff role."},"bookingToken":{"type":"apiKey","in":"header","name":"X-Booking-Token","description":"HMAC-signed booking token returned from POST /api/bookings. Required to mint a payment intent."}},"schemas":{"Slot":{"type":"object","required":["id","starts_at","ends_at","capacity","seats_remaining"],"properties":{"id":{"type":"string","format":"uuid"},"starts_at":{"type":"string","format":"date-time"},"ends_at":{"type":"string","format":"date-time"},"capacity":{"type":"integer","minimum":1},"seats_remaining":{"type":"integer","minimum":0},"price_cents":{"type":"integer","nullable":true,"description":"Slot-level override; falls back to experience.base_price_cents if null."}}},"Experience":{"type":"object","required":["id","name","slug","currency","base_price_cents","min_party_size","max_party_size","duration_minutes"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"slug":{"type":"string"},"description":{"type":"string","nullable":true},"currency":{"type":"string","minLength":3,"maxLength":3,"example":"USD"},"base_price_cents":{"type":"integer","minimum":0},"min_party_size":{"type":"integer","minimum":1},"max_party_size":{"type":"integer","minimum":1},"duration_minutes":{"type":"integer","minimum":1},"requires_waiver":{"type":"boolean"}}},"HoldRequest":{"type":"object","required":["slot_id","experience_id","seats"],"properties":{"slot_id":{"type":"string","format":"uuid"},"experience_id":{"type":"string","format":"uuid"},"seats":{"type":"integer","minimum":1,"maximum":200}}},"HoldResponse":{"type":"object","required":["hold_id","expires_at"],"properties":{"hold_id":{"type":"string","format":"uuid"},"expires_at":{"type":"string","format":"date-time","description":"10 minutes from creation."}}},"BookingRequest":{"type":"object","required":["hold_id","slot_id","experience_id","venue_id","customer","guest_count"],"properties":{"hold_id":{"type":"string","format":"uuid"},"slot_id":{"type":"string","format":"uuid"},"experience_id":{"type":"string","format":"uuid"},"venue_id":{"type":"string","format":"uuid"},"customer":{"type":"object","required":["email","first_name"],"properties":{"email":{"type":"string","format":"email"},"first_name":{"type":"string","minLength":1,"maxLength":100},"last_name":{"type":"string","minLength":1,"maxLength":100},"phone":{"type":"string","minLength":1,"maxLength":40}}},"guest_count":{"type":"integer","minimum":1,"maximum":200},"notes":{"type":"string","maxLength":2000},"gift_card_code":{"type":"string","minLength":4,"maxLength":32},"addons":{"type":"array","maxItems":20,"items":{"type":"object","required":["addon_id","qty"],"properties":{"addon_id":{"type":"string","format":"uuid"},"qty":{"type":"integer","minimum":1,"maximum":50}}}}}},"BookingResponse":{"type":"object","required":["booking_id","booking_token"],"properties":{"booking_id":{"type":"string","format":"uuid"},"booking_token":{"type":"string","description":"HMAC-signed; pass as X-Booking-Token on the payment-intent route. 1-hour TTL."},"waiver_url":{"type":"string","format":"uri","nullable":true,"description":"Present if the experience requires_waiver."},"gift_card_applied_cents":{"type":"integer","minimum":0},"payment_complete":{"type":"boolean","description":"True only when a gift card covered the full total — Stripe is bypassed."}}},"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string"},"detail":{}}}}},"paths":{"/api/widget/experience":{"get":{"tags":["Discovery"],"summary":"Resolve experience + upcoming slots for a tenant.","description":"Anon read. Used by the embedded widget to populate the slot grid. Returns the experience metadata and the next ~14 days of available slots filtered to ones with `seats_remaining >= min_party_size`.","parameters":[{"in":"query","name":"tenant","required":true,"schema":{"type":"string"},"description":"Tenant slug."},{"in":"query","name":"experience","required":true,"schema":{"type":"string"},"description":"Experience slug."}],"responses":{"200":{"description":"Experience metadata + upcoming bookable slots.","content":{"application/json":{"schema":{"type":"object","required":["experience","slots"],"properties":{"experience":{"$ref":"#/components/schemas/Experience"},"slots":{"type":"array","items":{"$ref":"#/components/schemas/Slot"}}}}}}},"404":{"description":"Tenant or experience not found / not publishable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/bookings/hold":{"post":{"tags":["Booking"],"summary":"Acquire a 10-minute soft hold on N seats.","description":"Anon-callable. Per-IP rate-limited (10/min). Capacity-aware: rejects with 409 if the slot is full at the requested seat count. Use the returned `hold_id` in the body of POST /api/bookings within 10 minutes.","security":[{"idempotencyKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HoldRequest"}}}},"responses":{"200":{"description":"Hold acquired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HoldResponse"}}}},"409":{"description":"Insufficient capacity at this seat count."},"429":{"description":"Rate limited."}}}},"/api/bookings":{"post":{"tags":["Booking"],"summary":"Convert a hold into a booking.","description":"Anon-callable. Per-IP rate-limited (5/min). The server re-validates capacity inside the same transaction that creates the booking — a stale hold will fail with `hold_expired` or `capacity_exceeded`. Returns a `booking_token` you must pass to the payment-intent route.","security":[{"idempotencyKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingRequest"}}}},"responses":{"201":{"description":"Booking created (status=held). Mint a payment intent next.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingResponse"}}}},"400":{"description":"invalid_body / invalid_guest_count / experience_slot_mismatch / addon_invalid / addon_not_found."},"404":{"description":"hold_not_found."},"409":{"description":"capacity_exceeded."},"410":{"description":"hold_expired."}}}},"/api/bookings/{id}/cancel":{"post":{"tags":["Operator"],"summary":"Cancel a confirmed booking.","description":"Operator (manager+) or tenant API key. Does NOT refund — call /api/payments/refunds separately to issue a refund. Useful for Zapier flows that cancel based on external triggers.","security":[{"tenantApiKey":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Cancelled."},"401":{"description":"unauthorized."},"404":{"description":"booking_not_found."}}}},"/api/bookings/{id}/check-in":{"post":{"tags":["Operator"],"summary":"Mark a booking as checked-in.","description":"Operator (staff+) or tenant API key. Sets `checked_in_at = now()` and emits booking.checked_in webhook. Idempotent — re-checking-in is a no-op.","security":[{"tenantApiKey":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Checked in."},"401":{"description":"unauthorized."},"404":{"description":"booking_not_found."}}}},"/api/bookings/check-in-by-token":{"post":{"tags":["Operator"],"summary":"Check a booking in by its QR token.","description":"Operator (staff+) or tenant API key. The desk scanner decodes a booking QR (a signed check-in token embedding the booking id) and POSTs it here. Tenant-scoped: a token is only honored for the operator's own tenant. Idempotent.","security":[{"tenantApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string","description":"Check-in token from the booking QR."}}}}}},"responses":{"200":{"description":"Checked in (or already_checked_in=true)."},"401":{"description":"invalid_token (bad signature / expired)."},"404":{"description":"booking_not_found in this tenant."},"409":{"description":"booking_not_checkable."}}}},"/api/embed/bookings/{id}/payment-intent":{"post":{"tags":["Payment"],"summary":"Mint a Stripe PaymentIntent for a held booking.","description":"Anon-callable when accompanied by the X-Booking-Token returned from POST /api/bookings. Returns a client_secret the embedded widget uses with Stripe.js to confirm the card payment.","security":[{"bookingToken":[],"idempotencyKey":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"PaymentIntent created.","content":{"application/json":{"schema":{"type":"object","required":["client_secret","publishable_key"],"properties":{"client_secret":{"type":"string"},"publishable_key":{"type":"string"},"amount_cents":{"type":"integer"},"currency":{"type":"string"}}}}}},"401":{"description":"invalid_token or token_expired."},"404":{"description":"booking_not_found."},"409":{"description":"booking_not_payable (already confirmed / cancelled / refunded)."}}}}},"webhooks":{"booking.created":{"post":{"summary":"Booking created","description":"A customer (or operator at POS) created a booking. The booking may still be pending payment — listen for booking.confirmed if you only care about paid bookings.\n\nFires: After a successful POST /api/bookings creates the row.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"booking.created"},"data":{"type":"object"}}},"example":{"event":"booking.created","data":{"booking_id":"00000000-0000-0000-0000-000000000001","experience_id":"00000000-0000-0000-0000-0000000000aa","venue_id":"00000000-0000-0000-0000-0000000000bb","slot_id":"00000000-0000-0000-0000-0000000000cc","customer_email":"sample@example.com","guest_count":2}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}},"booking.confirmed":{"post":{"summary":"Booking confirmed","description":"Payment cleared (Stripe payment_intent.succeeded, cash recorded at POS, or full gift-card coverage). The slot is now reserved.\n\nFires: Stripe webhook handler after payment_intent.succeeded, POS cash route, or POST /api/bookings when a gift card covers the full amount.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"booking.confirmed"},"data":{"type":"object"}}},"example":{"event":"booking.confirmed","data":{"booking_id":"00000000-0000-0000-0000-000000000001","experience_id":"00000000-0000-0000-0000-0000000000aa","venue_id":"00000000-0000-0000-0000-0000000000bb","slot_id":"00000000-0000-0000-0000-0000000000cc","customer_email":"sample@example.com","method":"card","net_paid_cents":4500}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}},"booking.cancelled":{"post":{"summary":"Booking cancelled","description":"An operator cancelled the booking. This does not imply a refund — listen for refund.created if you also care about money movement.\n\nFires: POST /api/bookings/[id]/cancel by an operator with role manager+.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"booking.cancelled"},"data":{"type":"object"}}},"example":{"event":"booking.cancelled","data":{"booking_id":"00000000-0000-0000-0000-000000000001","cancelled_by_user_id":"00000000-0000-0000-0000-0000000000dd"}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}},"booking.reissued":{"post":{"summary":"Gift card reissued","description":"An operator cancelled a booking and reissued the paid amount as a new gift card instead of a Stripe refund.\n\nFires: POST /api/bookings/[id]/reissue-gift-card.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"booking.reissued"},"data":{"type":"object"}}},"example":{"event":"booking.reissued","data":{"booking_id":"00000000-0000-0000-0000-000000000001","new_gift_card_id":"00000000-0000-0000-0000-000000000abc","new_gift_card_code":"GFTC-SAMPLE","amount_cents":4500,"actor_user_id":"00000000-0000-0000-0000-0000000000dd"}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}},"booking.rescheduled":{"post":{"summary":"Booking rescheduled","description":"An operator moved a confirmed booking to a different slot for the same experience. The payment is unchanged.\n\nFires: POST /api/bookings/[id]/reschedule by an operator with role manager+.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"booking.rescheduled"},"data":{"type":"object"}}},"example":{"event":"booking.rescheduled","data":{"booking_id":"00000000-0000-0000-0000-000000000001","new_slot_id":"00000000-0000-0000-0000-0000000000cc","new_slot_starts_at":"2026-06-01T14:00:00.000Z","new_slot_ends_at":"2026-06-01T15:00:00.000Z","rescheduled_by_user_id":"00000000-0000-0000-0000-0000000000dd","reason":null}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}},"booking.transferred":{"post":{"summary":"Booking transferred","description":"An operator re-attributed a confirmed booking to a new customer. Subsequent emails will go to the new address.\n\nFires: POST /api/bookings/[id]/transfer by an operator with role manager+.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"booking.transferred"},"data":{"type":"object"}}},"example":{"event":"booking.transferred","data":{"booking_id":"00000000-0000-0000-0000-000000000001","to_email":"newcustomer@example.com","transferred_by_user":"00000000-0000-0000-0000-0000000000dd","reason":"Gift from original purchaser"}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}},"booking.refunded":{"post":{"summary":"Booking refunded","description":"Reserved for full-refund summaries (currently emitted as refund.created).\n\nFires: Reserved — subscribe to refund.created today.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"booking.refunded"},"data":{"type":"object"}}},"example":{"event":"booking.refunded","data":{"booking_id":"00000000-0000-0000-0000-000000000001","amount_cents":4500}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}},"refund.created":{"post":{"summary":"Refund created","description":"A refund was issued through Stripe. amount_cents is the requested amount; status reflects Stripe’s response.\n\nFires: POST /api/payments/refunds.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"refund.created"},"data":{"type":"object"}}},"example":{"event":"refund.created","data":{"refund_id":"re_sample","payment_intent_id":"pi_sample","amount_cents":4500,"reason":"requested_by_customer","status":"succeeded"}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}},"refund.partial_no_cancel":{"post":{"summary":"Refund without cancellation","description":"A partial (or full) refund was issued via the keep-booking-active flow. The booking stays confirmed and the seat stays reserved — money was returned but the customer is still expected to attend.\n\nFires: POST /api/refunds/partial.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"refund.partial_no_cancel"},"data":{"type":"object"}}},"example":{"event":"refund.partial_no_cancel","data":{"refund_id":"re_sample","payment_intent_id":"pi_sample","booking_id":"00000000-0000-0000-0000-000000000001","amount_cents":1500,"refund_total_cents":1500,"reason":"requested_by_customer","status":"succeeded"}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}},"waiver.signed":{"post":{"summary":"Waiver signed","description":"A guest signed the digital waiver for their booking. signer_name is what they typed on the signature page.\n\nFires: POST /api/waivers/[token]/sign.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"waiver.signed"},"data":{"type":"object"}}},"example":{"event":"waiver.signed","data":{"waiver_id":"00000000-0000-0000-0000-0000000000ee","booking_id":"00000000-0000-0000-0000-000000000001","signer_name":"Sample Guest"}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}},"gift_card.sold":{"post":{"summary":"Gift card sold","description":"Cashier sold a gift card at POS. Code is the human-readable redemption code.\n\nFires: POST /api/gift-cards/sell.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"gift_card.sold"},"data":{"type":"object"}}},"example":{"event":"gift_card.sold","data":{"gift_card_id":"00000000-0000-0000-0000-000000000abc","gift_card_code":"GFTC-SAMPLE","amount_cents":5000,"currency":"USD","recipient_email":"recipient@example.com","payment_id":"00000000-0000-0000-0000-000000000fff","actor_user_id":"00000000-0000-0000-0000-0000000000dd"}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}},"shift.opened":{"post":{"summary":"POS shift opened","description":"A cashier opened the POS register with a starting cash count.\n\nFires: POST /api/pos/shifts/open.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"shift.opened"},"data":{"type":"object"}}},"example":{"event":"shift.opened","data":{"shift_id":"00000000-0000-0000-0000-000000000111","opened_at":"2026-05-19T13:00:00.000Z","opened_by_user_id":"00000000-0000-0000-0000-0000000000dd","starting_cash_cents":20000,"venue_id":null}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}},"shift.closed":{"post":{"summary":"POS shift closed","description":"A manager closed the POS register. variance_cents is ending − expected: positive = over, negative = short.\n\nFires: POST /api/pos/shifts/close.","requestBody":{"description":"Signed delivery (X-Reservory-Signature header). Return 2xx to acknowledge.","content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"shift.closed"},"data":{"type":"object"}}},"example":{"event":"shift.closed","data":{"shift_id":"00000000-0000-0000-0000-000000000111","opened_at":"2026-05-19T13:00:00.000Z","closed_at":"2026-05-19T21:30:00.000Z","closed_by_user_id":"00000000-0000-0000-0000-0000000000dd","starting_cash_cents":20000,"collected_cash_cents":45000,"expected_cash_cents":65000,"ending_cash_cents":64500,"variance_cents":-500}}}}},"responses":{"2XX":{"description":"Acknowledged."}}}}}}