API reference
Base URL: https://app.account42.com. Every route lives under /api/v1/. Requests and responses are JSON (Content-Type: application/json).
Conventions
Envelope. A success is { "data": … }. A refusal is { "error": { "code", "message" } }, sometimes with extra fields inside error (never beside it). code is a stable snake_case word to branch on; message is for a person and may change.
Ids are UUIDs. Money is integer minor units (amount_cents, price_cents) with a lowercase ISO 4217 currency; nothing is formatted server-side. Times are ISO 8601 in UTC, with the class's IANA timezone beside them where a class is involved. Phones are E.164 (+15551234567).
Opaque 404. A booking, class, offer or payment that belongs to another studio, or does not exist, answers 404 not_found — the same answer in both cases, so ids cannot be probed across studios.
Keys and auth kinds
Every request except the health check carries Authorization: Bearer <key>.
| Kind | Shape | Where it may live | What it may call |
|---|---|---|---|
| secret | a42_ + 64 hex | your server, never a page | the whole v1 surface, as the studio |
| publishable | a42pk_ + 64 hex | a public web page | the publishable routes below, from its allowed origins, rate-limited, as the restricted api_public role |
| embedded | a42pk_ + 64 hex | a public web page | identical to publishable at runtime; minted by the connect handshake for one platform on exactly one origin |
| token | Idempotency-Key-less; see below | — | a route that takes a connect code or a booking token in place of (or in addition to) a key |
| none | — | — | GET /api/v1/health |
A publishable key proves only "some visitor to this studio's site". So every publishable call that touches an existing booking also needs a booking token the server minted for that booking, in the X-Booking-Token header: the hold route returns one with the hold (booking_token, scope hold, good until the hold expires plus 15 minutes), the waitlist join returns one with the entry (waitlist_token), and a confirmed booking returns a manage-scope token (good until 24 hours after the class starts) — the one the confirmation email's manage link carries. A hold token can never cancel. Any failure of a token (absent, malformed, tampered, expired, another booking's, the wrong scope) is the same 404 not_found.
A secret key needs no token and sees fields a publishable key does not (attendee name and contact).
The routes a publishable key may call are listed in the source file lib/api/public-routes.ts; the table below is built from it. A publishable key on any other route is 404 before any lookup.
Rate limits
Publishable and embedded keys are counted per key per minute, in three classes: read (every GET), write (every POST except holds) and hold (creating a hold, also counted per class). Defaults are 120 reads, 30 writes and 10 holds per class per minute. Every counted response carries:
RateLimit-Limit: 120
RateLimit-Remaining: 117
RateLimit-Reset: 42
and a refusal is 429 rate_limited with Retry-After in seconds. Refusals for an unknown route, a bad key or a wrong origin are not counted and carry no RateLimit headers. Secret keys are not counted here.
Idempotency keys
Every route that creates or moves money or seats (holds, checkouts, cancel, refund, schedules) accepts an Idempotency-Key header (up to 255 characters). Repeating a request with the same key within 24 hours replays the stored answer and does no second work; the same key with a different request is 422 invalid_request ("This Idempotency-Key was already used for a different request", SQLSTATE P0015). Without the header a key is derived from the request (studio, class or booking, attendee, action) and honored for 60 seconds, and for a checkout for as long as its booking is still pending, so a double-click never books twice and a retry resumes a request that died half-way. The payment provider is given its own per-attempt key, so a retry resumes the same Checkout session rather than opening a second one.
CORS
Every /api/v1/ route answers a preflight OPTIONS with 204, Access-Control-Allow-Origin echoing the request's Origin, Access-Control-Allow-Methods: GET, POST, OPTIONS, Access-Control-Allow-Headers: Authorization, Content-Type, X-Booking-Token, X-Device-Token, Idempotency-Key, Access-Control-Max-Age: 600 and Vary: Origin. Real responses carry the same origin echo plus Access-Control-Expose-Headers: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, Retry-After, so a page can read a 429's wait. Credentials mode is never enabled: there are no cookies. Whether a given origin may use a publishable key is decided by the key's origin list, not by CORS.
Routes
| Method | Path | Auth | Rate class |
|---|---|---|---|
POST | /api/v1/assistant/messages | publishable / embedded, or secret | write |
POST | /api/v1/assistant/proposals/:proposalId | publishable / embedded, or secret | write |
GET | /api/v1/bookings/:bookingId | publishable / embedded, or secret + booking token | read |
POST | /api/v1/bookings/:bookingId/cancel | publishable / embedded, or secret + booking token | write |
POST | /api/v1/bookings/:bookingId/checkout | publishable / embedded, or secret + booking token | write |
POST | /api/v1/bookings/:bookingId/confirm | publishable / embedded, or secret + booking token | write |
POST | /api/v1/bookings/:bookingId/refund | secret | — |
POST | /api/v1/codes | publishable / embedded, or secret | write |
POST | /api/v1/codes/verify | publishable / embedded, or secret | write |
POST | /api/v1/connect/exchange | none (the connect code is the credential) | — |
POST | /api/v1/connect/revoke | the embedded key itself | — |
GET | /api/v1/courses | publishable / embedded, or secret | read |
POST | /api/v1/courses/:courseId/holds | publishable / embedded, or secret | write |
GET | /api/v1/credit-packs | publishable / embedded, or secret | read |
POST | /api/v1/credit-packs/:packId/checkout | publishable / embedded, or secret | write |
GET | /api/v1/health | none | — |
GET | /api/v1/manage | publishable / embedded, or secret + booking token | read |
POST | /api/v1/manage/preference | publishable / embedded, or secret + booking token | write |
GET | /api/v1/membership-plans | publishable / embedded, or secret | read |
POST | /api/v1/membership-plans/:planId/checkout | publishable / embedded, or secret | write |
GET | /api/v1/merchant | publishable / embedded, or secret | read |
GET | /api/v1/packages | publishable / embedded, or secret | read |
POST | /api/v1/packages/:packageId/checkout | publishable / embedded, or secret | write |
POST | /api/v1/push/subscriptions | publishable / embedded, or secret + booking token | write |
GET | /api/v1/push/vapid-public-key | publishable / embedded, or secret | read |
GET | /api/v1/refund-attention | secret | — |
POST | /api/v1/schedules | secret | — |
GET | /api/v1/sessions | publishable / embedded, or secret | read |
GET | /api/v1/sessions/:sessionId | publishable / embedded, or secret | read |
POST | /api/v1/sessions/:sessionId/checkout | publishable / embedded, or secret | write |
POST | /api/v1/sessions/:sessionId/holds | publishable / embedded, or secret | hold |
POST | /api/v1/sessions/:sessionId/waitlist | publishable / embedded, or secret | write |
POST | /api/v1/waitlist-offers/:offerId/claim | publishable / embedded, or secret + booking token | write |
Errors common to every route
| Status | code | When |
|---|---|---|
| 401 | unauthorized | the key is missing, malformed, unknown or revoked |
| 403 | origin_not_allowed | a publishable key used from an origin it was not minted for |
| 403 | merchant_not_live | the studio is not live (draft, or paused where paused is not allowed) |
| 404 | not_found | a publishable key on a route it may not call; an id that is not this studio's; a bad booking token |
| 422 | invalid_request | a body that is not JSON, a missing or out-of-bounds field (the message says which) |
| 429 | rate_limited | over the key's per-minute limit; wait Retry-After seconds |
| 500 | internal_error | our fault; safe to retry with the same Idempotency-Key |
GET /api/v1/health
Auth: none. 200 { "data": { "ok": true } }. With ?deep=1, also db: { "ok": true, "ms": 12 } after a select 1 through the database pool with a two-second timeout; 503 with db: { "ok": false } when it does not answer. Nothing in the body needs protecting.
GET /api/v1/merchant
Auth: secret or publishable. The studio's widget configuration. For a publishable key: business_name, logo_url, brand_color, currency, country, locale, timezone, contact_required (email | phone | either), msg_email_enabled, msg_sms_enabled, msg_default_channel, msg_push_enabled, languages (the 19 widget language codes) and hosted_url. A secret key gets the studio's own row. This is also the "is my key valid" check for a connector (401 otherwise).
GET /api/v1/sessions
Auth: secret or publishable. The studio's bookable classes, soonest first, including classes that started or were canceled in the last 12 hours (shown as such). Each (publishable view): id, title, description, starts_at, duration_minutes, timezone, seat_capacity, seats_remaining, price_cents, currency, status, custom_question, credit_cost, membership_included, credit_scope ({ slug, label } or null), location (name and address fields, or null), package_ids, instructor_name (the lead instructor's display name, or null — never an email), instructor_names (every instructor's display name, the lead first; [] when nobody is named), course_id and course_title (the course the class belongs to, or null), and policy — the resolved cancellation policy ({ kind: "non_refundable" } or { kind: "window", deadline, windowMinutes, feeCents, feePercent, currency }), the same computation the confirmation email states. A secret key gets the narrower row (id, title, description, starts_at, timezone, price_cents, currency, seats_remaining, custom_question).
GET /api/v1/sessions/:sessionId
Auth: secret or publishable. One class, same shape as the list. 404 for another studio's class.
POST /api/v1/sessions/:sessionId/holds
Auth: secret or publishable. Hold seats. Body: qty (1–100), attendee_name (required, ≤200), attendee_email and/or attendee_phone (at least one; E.164 phone), custom_answer (≤2000, when the class asks a question), hold_minutes (1–120, default the studio's), msg_pref (email | phone | both). 201 { data: { id, status: "pending", expires_at, qty, session_id, seats_remaining, booking_token } } (booking_token for a publishable key only). 409 sold_out when the seats are gone; 409 session_not_live when the class is not open. Idempotent.
A free class through a publishable key needs a verified contact before it is booked (see confirm below), so its hold needs attendee_email (422 email_required otherwise) and lives at most 10 minutes unless the browser is remembered for that email (X-Device-Token). Holds are braked: at most 2 unconfirmed free holds open per email (429 too_many_holds); more than 3 an hour from one browser, 5 from one address or 20 on one key answer 428 bot_check_required with site_key (Cloudflare Turnstile) — send the check's token as bot_token in the body to retry. seats_remaining here and on the session reads is capacity minus confirmed seats and holds that have not lapsed.
POST /api/v1/sessions/:sessionId/checkout
Auth: secret or publishable. Hold seats and start a paid checkout in one call. Body as for holds, plus provider (stripe | paypal, required only when the studio has both connected), hold_minutes 35–120, and for an embedded widget return_url (the page the customer is on; must be on the key's origin). 201 { data: { booking_id, payment_id, checkout_url, expires_at, amount_cents, currency, qty, booking_token } } — send the customer to checkout_url. The price is read from the class, never the body. The booking is confirmed by the provider's webhook; the return page is UX only. 409 checkout_not_configured when the studio has no provider connected (or, for a secret key, no success URL); 422 invalid_request for a free class (use holds + confirm). Idempotent.
POST /api/v1/sessions/:sessionId/waitlist
Auth: secret or publishable. Join the line for a full class. Body: email and/or phone, name, qty (default 1), msg_pref. 201 { data: { entry_id, session_id, status: "waiting", qty, waitlist_token } } (waitlist_token for a publishable key). Joining twice is a no-op that returns the existing entry. Nothing is reserved; a seat reaches a waiter only through an offer.
GET /api/v1/courses
Auth: secret or publishable. The studio's courses open for enrollment: 200 { data: [ { id, title, description, price_cents, currency, seats, status: "live", partial_cancel_policy, session_ids, instructors } ] } (by title), where session_ids are the course's live classes still to come, soonest first, instructors is { names, others } — the earliest of those classes' instructors by public name, and whether any other date has different ones ("With Maria" / "With Maria and others"), and partial_cancel_policy (refund | credit | makeup) is what happens to an enrollment when the studio cancels ONE of those dates. A course is one enrollment at one price for every class in session_ids; enroll with the course hold below.
POST /api/v1/courses/:courseId/holds
Auth: secret or publishable. Enroll in a course: hold a seat in every live class of it still to come, as one enrollment. Body as for a class hold (qty, attendee_name, attendee_email and/or attendee_phone, custom_answer, hold_minutes, msg_pref). 201 { data: { id, status: "pending", expires_at, qty, session_id, course_id, booking_group_id, bookings: [ { id, session_id, status } ], booking_token } } — id is the anchor (the earliest class's booking): confirm it (POST /bookings/:id/confirm, a free course) or pay for it (POST /bookings/:id/checkout, charged the course's price) and every class of the enrollment follows. The hold is atomic: a full class answers 409 sold_out and holds nothing. 409 course_not_live (draft, archived, or no class still to come), 409 class_not_bookable_no_payment_account (a paid course at a studio with no payment account), 404 not_found for a course that is not this studio's. Idempotent.
GET /api/v1/bookings/:bookingId
**Auth: secret, or publishable + X-Booking-Token (hold or manage).** 200 { data: { id, session_id, status, qty, created_at, confirmed_at, attendee_name, attendee_email } }; the two attendee fields only for a secret key. status is pending | confirmed | cancelled | expired | refunded.
POST /api/v1/bookings/:bookingId/confirm
**Auth: secret, or publishable + X-Booking-Token (hold or manage).** No body. Confirms a held booking from exactly one entitlement source, tested in order: a completed payment, a free class, an active membership, credits, a class bundle (package). 200 { data: { id, status: "confirmed", confirmed_at, session_id, qty, authorized_via, manage_token } } (authorized_via ∈ free | payment | membership | credits | package, and manage_token, for a publishable key). An already-confirmed booking replays 200 and takes no second seat. Through a publishable key a free seat confirms only for a verified contact: unless the browser's X-Device-Token is remembered for the hold's email, the answer is 409 verification_required — ask for the code with POST /api/v1/codes { booking_id } and confirm with POST /api/v1/codes/verify. 409 booking_not_confirmable (wrong state), 409 booking_not_authorized (a paid class with nothing covering it — go to checkout), 402 insufficient_credits, 422 no_customer_identity.
POST /api/v1/bookings/:bookingId/checkout
**Auth: secret, or publishable + X-Booking-Token (hold).** Pay for a booking that already holds its seat — the case a waitlist claim on a paid class answers 402 payment_required with. Body: provider (optional), return_url (embedded). Same answer and rules as the class checkout, minus the hold: 201 { data: { booking_id, payment_id, checkout_url, expires_at, amount_cents, currency, qty, booking_token } }. 409 booking_not_confirmable when the booking is not a pending, still-held seat; 409 waitlist_offer_closed when its offer has lapsed.
POST /api/v1/bookings/:bookingId/cancel
**Auth: secret, or publishable + X-Booking-Token (manage only). No body. Cancels a pending or confirmed booking and releases its seats. If the booking qualifies under the studio's refund window, the provider is asked** to refund (the fee, if any, deducted); the cancellation proceeds even if the refund call fails. 200 { data: { id, status: "cancelled", session_id, qty, seats_remaining, refund } } where refund is { requested: true, reason: "refund_requested", amount_cents, fee_cents }, or { requested: false, reason, amount_cents: null, fee_cents } where reason names why (the studio's eligibility rule, already_cancelled, or refund_failed when the provider did not accept the request). The refund itself is recorded when the provider's webhook confirms it. Idempotent.
POST /api/v1/bookings/:bookingId/refund
Auth: secret only. The studio's refund door: ignores the refund window, charges no fee, does not cancel the booking. Body: { "amount_cents" } optional for a partial refund; omitted refunds the remaining refundable amount. 200 { data: { booking_id, refund: { requested: true, provider_ref, provider_refund_id, amount_cents, currency } } }. 409 invalid_state when there is no refundable payment; 409 refund_exceeds_payment; 422 invalid_request when nothing is left to refund. Idempotent per amount.
POST /api/v1/waitlist-offers/:offerId/claim
**Auth: secret, or publishable + X-Booking-Token (the entry's waitlist token).** No body. Claims a seat that was offered to a waiter (the claim link in their email). A free class: 201 { data: { offer_id, booking_id, status: "confirmed", authorized_via, outcome: "claimed", booking_token } } (200 with outcome: "already_claimed" on a repeat). A paid class with nothing covering it: 402 { error: { code: "payment_required", message, booking_id, booking_token } } — the seat stays held under the offer; pay for it with POST /api/v1/bookings/:bookingId/checkout. 409 waitlist_offer_closed once the offer expired or the class is inside the studio's claim cutoff.
GET /api/v1/manage
**Auth: secret or publishable, plus a manage-scope token as ?token=.** What the manage-booking page shows: 200 { data: { booking: { id, status, qty, session_id, created_at, confirmed_at }, session: { id, title, starts_at, duration_minutes, timezone, status, location }, policy, policy_text, cancel_allowed, msg_pref, refund_if_cancelled: { eligible, reason, refundable_cents, fee_cents, currency } } }. The policy text is the same the confirmation email states.
POST /api/v1/manage/preference
**Auth: secret or publishable, plus X-Booking-Token (manage).** Body { "msg_pref": "email" | "phone" | "both" }. 200 { data: { msg_pref } } — stored on the customer's row at this studio. 422 invalid_request when the studio offers one channel only; 422 no_customer_identity for a booking with no customer row.
POST /api/v1/codes
Auth: secret or publishable. Body { "email": "…" }, or { "booking_id": "…" } with the hold's X-Booking-Token (a free hold waiting for its contact: the code goes to the hold's own email and the answer is 202 { data: { requested: true, minutes: 10, email } }; a browser already remembered for that email, by X-Device-Token, is confirmed at once: 200 { data: { confirmed: { … } } }). The email form asks for a one-time code to see or cancel your bookings without the emailed link. If that address has a booking with this studio, a six-digit code is emailed to it; it works for 10 minutes and takes 5 tries. The answer is the same 202 { data: { requested: true, minutes: 10 } } whether or not the address has a booking, so it never tells who is a customer. 422 invalid_request for a malformed address; 429 too_many_codes after three requests for one address within an hour.
POST /api/v1/codes/verify
Auth: secret or publishable. Body { "email": "…", "code": "123456" }, or { "booking_id": "…", "code": "123456" } with the hold's X-Booking-Token. A right code verifies the person: one identity across every studio on account42, and the browser sending X-Device-Token is remembered for that email for 30 days. It answers 200 { data: { bookings: [{ booking_id, manage_token }], device_days: 30 } } — the same manage token the confirmation email's link carries, for each of that address's bookings with this studio; use it with GET /api/v1/manage and POST /api/v1/bookings/:bookingId/cancel. With booking_id the hold is confirmed too and confirmed carries the confirm answer. A wrong, expired or used-up code is always the same 404 invalid_code. Opening a booking with its manage token (GET /api/v1/manage) also remembers the browser for that booking's email.
POST /api/v1/assistant/messages
Auth: publishable (the widget's key). The Guide, the booking widget's chat panel (the "Ask the Guide" button). Body { "text": "…", "thread_id"?: "…", "visitor": "<64 hex characters, random per browser>", "locale"?: "fr", "manage_tokens"?: ["…"] }. Streams newline-delimited JSON events: thread, text, tool, card (a cancel to confirm), checkout (a held paid seat to pay for), booked, waitlisted, verify (show the code field), fallback (show the form), done. Every action the Guide takes is one of the routes on this page, with the same key. 422 invalid_request without a visitor token or a message.
POST /api/v1/assistant/proposals/:proposalId
Auth: publishable. Confirm or decline a card from the Guide (the chat panel). Body { "thread_id": "…", "decision": "confirm" | "decline", "visitor": "…", "manage_tokens": ["…"] }. 200 { data: { outcome: "done" | "already_confirmed" | "declined" | "expired" | "failed", result } }. Another visitor's card, or an unknown one, is 404 not_found.
GET /api/v1/credit-packs
Auth: secret or publishable. Active packs: [{ id, name, description, credits, price_cents, currency, expires_days }].
POST /api/v1/credit-packs/:packId/checkout
Auth: secret or publishable. Buy a pack. Body: email (required), name, phone, provider, return_url (embedded). 201 { data: { credit_pack_id, customer_id, payment_id, checkout_url, credits, amount_cents, currency } }. Credits are granted by the provider's webhook. 409 purchase_not_configured when the pack is archived. Idempotent.
GET /api/v1/membership-plans
Auth: secret or publishable. Active plans: [{ id, name, description, price_cents, currency, interval, interval_count }].
POST /api/v1/membership-plans/:planId/checkout
Auth: secret or publishable. Start a subscription (Stripe only; paypal is 422). Body as for packs. 201 { data: { membership_plan_id, customer_id, payment_id, checkout_url, amount_cents, currency } }. 409 purchase_not_configured when the plan has no Stripe price yet. Idempotent.
GET /api/v1/packages
Auth: secret or publishable. Active class bundles — a prepaid set of specific classes, booked one at a time; package is the API's name for a class bundle: [{ id, name, price_cents, currency, session_ids }] — session_ids are the classes the bundle covers.
POST /api/v1/packages/:packageId/checkout
Auth: secret or publishable. Buy a class bundle. Body as for packs. 201 { data: { package_id, customer_id, payment_id, checkout_url, amount_cents, currency } }. Idempotent.
POST /api/v1/push/subscriptions
**Auth: secret, or publishable + X-Booking-Token (a booking or waitlist token).** Register a browser's push subscription. Body { email?, phone?, subscription: { endpoint, keys: { p256dh, auth } } } — for a publishable key the contact is taken from the token's booking and the body's email/phone are ignored. 201 { data: { subscriptionId } }.
GET /api/v1/push/vapid-public-key
Auth: secret or publishable. 200 { data: { publicKey } } — the applicationServerKey for PushManager.subscribe(). One key pair serves every studio.
POST /api/v1/schedules
Auth: secret only. Create a recurring schedule and generate its first classes. Body: title, seat_capacity, timezone, start_date (YYYY-MM-DD), bound_type (count | until_date | open) with bound_count or bound_until, slots (1–14 of { day_of_week, local_time }, day_of_week 0–6 from Sunday, local_time HH:MM), and optional description, price_cents, currency, custom_question, credit_cost, membership_included, cancellation_fee_cents, cancellation_fee_percent, refund_window_minutes, refunds_disabled, interval_weeks, booking_window_days. 201 { data: { schedule, sessions_generated } }. 422 schedule_has_no_slots, 422 invalid_schedule. Idempotent.
GET /api/v1/refund-attention
Auth: secret only. Money the studio is holding that probably should not be: paid-but-unconfirmed bookings and stale pending payments (the shape a missed webhook takes). ?stale_minutes= 0–10080, default 120. 200 { data: [{ category, payment_id, booking_id, session_id, provider, provider_ref, amount_cents, currency, payment_status, booking_status, session_title, starts_at, payment_age }] }.
POST /api/v1/connect/exchange
Auth: none — the one-time connect code is the credential. Body { "code": "<64 hex>" } from the connect handshake. 200 { data: { key, merchant: { id, slug, name, hosted_url } } }. Anything wrong with the code (malformed, unknown, used, expired, its key revoked since, a body that is not JSON) is one 404 not_found.
POST /api/v1/connect/revoke
Auth: an embedded key, revoking itself. No body. 200 { data: { revoked: true } }. Any other kind of key, an unknown or already-revoked key, or no key: 404 not_found.
Error codes
Every public error code the database can raise, with the HTTP status and code the API answers. Generated from lib/api/errors.ts; the SQLSTATE column is the database's own code (it appears in server logs, never in a response).
| SQLSTATE | HTTP | code | message | Meaning |
|---|---|---|---|---|
P0001 | 422 | invalid_qty | The requested quantity is invalid. | invalid_qty |
P0002 | 404 | not_found | Not found. | session_not_found -> opaque 404 so ids cannot be probed across tenants |
P0003 | 409 | session_not_live | This session is not open for booking. | session_not_live |
P0004 | 409 | sold_out | This session is sold out. | sold_out |
P0005 | 500 | internal_error | Something went wrong. Please try again. | merchant_scope_missing -> internal; the caller should never trigger this |
P0006 | 404 | not_found | Not found. | booking_not_found -> opaque 404 |
P0007 | 409 | invalid_state | This booking can't be checked out — it is no longer pending. | booking_not_pending -> same public code the confirm/cancel routes already return for a booking in the wrong state |
P0008 | 422 | invalid_request | A valid checkout session id is required. | invalid_request -> missing/blank Stripe checkout session id |
P0009 | 404 | not_found | Not found. | payment_not_found -> opaque 404 so payment refs cannot be probed across tenants |
P0010 | 409 | invalid_state | This payment can't move to that status. | invalid_payment_transition -> illegal status move (e.g. refund a pending payment) |
P0011 | 409 | invalid_state | This booking already has a completed payment. | payment_already_paid -> one-paid-per-booking guard tripped |
P0012 | 409 | invalid_state | This payment has already been recorded. | duplicate_provider_ref -> (provider, provider_ref) already recorded |
P0013 | 422 | invalid_request | The payment request is invalid. | invalid_request -> bad payment request shape (provider/purpose/amount/discriminator) |
P0014 | 500 | internal_error | Something went wrong. Please try again. | payment_not_paid -> the webhook confirmed a booking before advancing its payment to 'paid'. Our orchestration bug, never the caller's. |
P0015 | 422 | invalid_request | This Idempotency-Key was already used for a different request. | idempotency_key_reused -> the same Idempotency-Key was sent for a genuinely different request. A caller bug we surface rather than hide (db/16). |
P0016 | 409 | invalid_state | This booking has no refundable payment. | no_refundable_payment -> merchant asked to refund a booking with no paid payment to refund (db/18, merchant refund path). |
P0017 | 409 | booking_not_confirmable | This booking can't be confirmed in its current state. | booking_not_confirmable -> the resolver was asked to confirm a booking that is not in a confirmable state (db/22, api_authorize_booking). |
P0018 | 409 | booking_not_authorized | This booking has not been authorized. | booking_not_authorized -> no entitlement source (payment, credits, or membership) authorized this booking (db/22). |
P0019 | 422 | no_customer_identity | This booking has no customer identity. | no_customer_identity -> the booking has no resolvable customer to charge credits or a membership against (db/21, db/22). |
P0020 | 402 | insufficient_credits | There are not enough credits to cover this booking. | insufficient_credits -> the customer's credit balance cannot cover the session's credit_cost (db/23). |
P0021 | — | — | — | P0021 is DELIBERATELY UNUSED — reserved for a membership error the design concluded should not exist (an inactive membership falls through to the next entitlement source rather than erroring). Do not reuse this number. |
P0022 | 409 | refund_exceeds_payment | This refund exceeds the remaining refundable amount. | refund_exceeds_payment -> the requested refund is larger than the payment's remaining refundable amount (db/25, db/26). |
P0023 | 409 | purchase_not_configured | This item is not available for purchase. | purchase_not_configured -> the pack or plan exists but is not sellable: archived, or a membership plan with no provider price yet (db/28). |
P0024 | 422 | schedule_has_no_slots | A schedule needs at least one weekly slot. | schedule_has_no_slots -> a schedule with an empty slot list is a dead template: it would generate no classes at all (db/31). |
P0025 | 422 | invalid_schedule | This schedule is invalid. | invalid_schedule -> the gateway refused the schedule's shape: bound type vs bound fields, booking window, interval, capacity, timezone, or a slot's weekday/time value (db/31). |
P0026 | 404 | schedule_not_found | Not found. | schedule_not_found -> opaque 404, so schedule ids cannot be probed across tenants. Raised by the ownership-checked generation wrapper (db/34). |
P0027 | 409 | merchant_not_verified | Connect a payment provider before going live. | merchant_not_verified -> api_go_live refused: no 'connected' payment provider row yet. The identity gate is about who they are (db/52). |
P0028 | 403 | merchant_not_live | This business is not live. | merchant_not_live -> the identity gate (db/52): the merchant is draft, or paused where paused is not allowed. Nothing public until live. |
P0029 | 403 | merchant_user_not_member | You are not a member of this business. | merchant_user_not_member -> the signed-in user holds no accepted membership at the merchant named by X-Merchant-Id (db/52). |
P0030 | 409 | last_owner | A business must have exactly one owner. | last_owner -> a membership change would leave the merchant with other than exactly one owner (db/52's deferred owner invariant). |
P0031 | 404 | not_found | Not found. | location_not_found -> opaque 404, so location ids cannot be probed across tenants. Raised by the three location doors and by api_create_schedule when a schedule names another merchant's location (db/53). |
P0032 | 409 | location_is_default | Set another location as the default first. | location_is_default -> the merchant's default location cannot be deactivated or un-defaulted; move the default first (db/53). |
P0033 | 409 | waitlist_offer_closed | This waitlist offer can no longer be claimed. | waitlist_offer_closed -> the claim window has passed: the offer expired, or the class is now inside the merchant's claim cutoff (db/54). |
P0034 | 409 | setup_intake_open | You already have a setup request in progress. | setup_intake_open -> this merchant already has a done-for-you setup submission that is not finished. One open job at a time, so an operator is never handed two versions of what was asked for (db/56). |
P0035 | 422 | invalid_credit_scope | That is not one of this business's credit scopes. | invalid_credit_scope -> this merchant names its credit scopes, and the value offered is not one of them. While a merchant has named none, credit_scope stays free text and this is never raised (db/57). |
P0036 | 422 | schedule_change_policy_required | Choose what happens to the people already booked: move or cancel_and_refund. | schedule_change_policy_required -> an edit would change the when or where of a class people have already booked, and named no policy (db/59). The raise CARRIES the would-stay list as JSON in its DETAIL, which lib/api/cancellation.ts's policyRefusal surfaces; this mapping is the fallback for any path that does not. |
P0037 | 409 | capacity_below_booked | That capacity is lower than the number of seats already taken. | capacity_below_booked -> the new capacity is smaller than the number of seats already taken. Those people hold them (db/59). |
P0038 | 409 | session_not_editable | This class can't be changed: it has already started, or it is canceled. | session_not_editable -> the class has already started, or is canceled or completed. It is history (db/59). |
P0039 | 500 | internal_error | Something went wrong. Please try again. | invalid_rate_class (db/62): api_count_key_request was asked to count a class other than read | write | hold, or a hold with no session. Only lib/api/ratelimit.ts calls it, so this is OUR bug, never the caller's: a 500, listed explicitly so it is a decision and not a fall-through. |
P0040 | 404 | not_found | Not found. | connect_code_invalid (db/72): api_exchange_connect_code was given a code that is malformed, unknown, expired, already used, or whose embedded key was revoked since. One opaque 404 for all five, so a caller probing the exchange route learns nothing about which codes exist. |
P0041 | 409 | environment_locked | This business already has payments; its environment can no longer be changed. | environment_locked (db/72): the operator asked to move a merchant between live and sandbox after money has already moved (a payments row exists). |
P0042 | 402 | billing_suspended | Online booking is paused while the subscription is sorted out. Please try again later. | billing_suspended (db/76, session 16): the api key presented is SUSPENDED (api_keys.suspended_at) -- the shop's Shopify App Pricing subscription lapsed past its grace (7 days frozen, or canceled past its date), so the studio's embedded key stops taking bookings until the subscription is active again. A reversible state, distinct from a revoked key's 401. RESERVED for the app's key resolver (lib/api/auth.ts lookupKey): raised by no database function; listed here so the error table and the widget (which shows its "not taking online bookings right now" state) know it. |
P0043 | 409 | invalid_state | Only a confirmed booking can be checked in. | booking_not_confirmed (db/83, session 17): api_check_in_booking was asked to check in a booking that is not confirmed (pending, expired, canceled, refunded). The same public code the other wrong-state refusals carry; the dashboard shows it in the person's language (apiError.invalid_state). |
P0044 | 409 | class_not_bookable_no_payment_account | This class cannot be booked yet: the business has not connected a payment account. | class_not_bookable_no_payment_account (db/84, session 17, prompt 4): a paid class or course at a studio that has no way to be paid -- no connected provider and no platform install in platform checkout mode (merchant_has_payment_path). The hold (api_reserve_seats, api_reserve_course_seats) refuses; the class stays listed. The studio connects a payment account and the class becomes bookable at once. |
P0046 | 404 | not_found | Not found. | P0045 is not assigned (db/84 skipped it; P0021 stays reserved). course_not_found (db/84): a course id that does not exist OR belongs to another merchant -- the same opaque 404, so course ids cannot be probed. |
P0047 | 409 | course_not_live | This course is not open for enrollment. | course_not_live (db/84): the course is draft or archived, or has no live class still to come, so there is nothing to enroll in. |
P0048 | 404 | not_found | Not found. | assistant_not_found (db/88): a Guide thread or card of another business, another user or another thread. One opaque 404, so ids cannot be probed. |
P0049 | 429 | rate_limited | The Guide is resting for now. Everything still works from the forms. | assistant_resting (db/88): the business has spent this month's (or today's) Guide allowance; the database refused the turn before the model was called. Everything still works from the forms. The box never sees this mapping (the runner turns P0049 into its resting line); a route that ever passed it through answers the same 429 rate_limited every client already knows. |
P0050 | 429 | too_many_codes | Too many codes were asked for. Please try again later. | db/89 (session 19, prompt 2): a fourth one-time code for one contact within an hour |
Routes also answer 401 unauthorized, 402 billing_suspended (the key is suspended while the studio's Shopify App Pricing subscription is sorted out; it comes back on its own), 403 origin_not_allowed, 429 rate_limited, 409 checkout_not_configured, 402 payment_required, 503 sandbox_not_configured (a sandbox studio on a deployment with no Stripe test key) and 502 payment_provider_error (the provider did not answer; retry with the same Idempotency-Key) from the route itself, as described above.