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>.

KindShapeWhere it may liveWhat it may call
secreta42_ + 64 hexyour server, never a pagethe whole v1 surface, as the studio
publishablea42pk_ + 64 hexa public web pagethe publishable routes below, from its allowed origins, rate-limited, as the restricted api_public role
embeddeda42pk_ + 64 hexa public web pageidentical to publishable at runtime; minted by the connect handshake for one platform on exactly one origin
tokenIdempotency-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

MethodPathAuthRate class
POST/api/v1/assistant/messagespublishable / embedded, or secretwrite
POST/api/v1/assistant/proposals/:proposalIdpublishable / embedded, or secretwrite
GET/api/v1/bookings/:bookingIdpublishable / embedded, or secret + booking tokenread
POST/api/v1/bookings/:bookingId/cancelpublishable / embedded, or secret + booking tokenwrite
POST/api/v1/bookings/:bookingId/checkoutpublishable / embedded, or secret + booking tokenwrite
POST/api/v1/bookings/:bookingId/confirmpublishable / embedded, or secret + booking tokenwrite
POST/api/v1/bookings/:bookingId/refundsecret—
POST/api/v1/codespublishable / embedded, or secretwrite
POST/api/v1/codes/verifypublishable / embedded, or secretwrite
POST/api/v1/connect/exchangenone (the connect code is the credential)—
POST/api/v1/connect/revokethe embedded key itself—
GET/api/v1/coursespublishable / embedded, or secretread
POST/api/v1/courses/:courseId/holdspublishable / embedded, or secretwrite
GET/api/v1/credit-packspublishable / embedded, or secretread
POST/api/v1/credit-packs/:packId/checkoutpublishable / embedded, or secretwrite
GET/api/v1/healthnone—
GET/api/v1/managepublishable / embedded, or secret + booking tokenread
POST/api/v1/manage/preferencepublishable / embedded, or secret + booking tokenwrite
GET/api/v1/membership-planspublishable / embedded, or secretread
POST/api/v1/membership-plans/:planId/checkoutpublishable / embedded, or secretwrite
GET/api/v1/merchantpublishable / embedded, or secretread
GET/api/v1/packagespublishable / embedded, or secretread
POST/api/v1/packages/:packageId/checkoutpublishable / embedded, or secretwrite
POST/api/v1/push/subscriptionspublishable / embedded, or secret + booking tokenwrite
GET/api/v1/push/vapid-public-keypublishable / embedded, or secretread
GET/api/v1/refund-attentionsecret—
POST/api/v1/schedulessecret—
GET/api/v1/sessionspublishable / embedded, or secretread
GET/api/v1/sessions/:sessionIdpublishable / embedded, or secretread
POST/api/v1/sessions/:sessionId/checkoutpublishable / embedded, or secretwrite
POST/api/v1/sessions/:sessionId/holdspublishable / embedded, or secrethold
POST/api/v1/sessions/:sessionId/waitlistpublishable / embedded, or secretwrite
POST/api/v1/waitlist-offers/:offerId/claimpublishable / embedded, or secret + booking tokenwrite

Errors common to every route

StatuscodeWhen
401unauthorizedthe key is missing, malformed, unknown or revoked
403origin_not_alloweda publishable key used from an origin it was not minted for
403merchant_not_livethe studio is not live (draft, or paused where paused is not allowed)
404not_founda publishable key on a route it may not call; an id that is not this studio's; a bad booking token
422invalid_requesta body that is not JSON, a missing or out-of-bounds field (the message says which)
429rate_limitedover the key's per-minute limit; wait Retry-After seconds
500internal_errorour 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).

SQLSTATEHTTPcodemessageMeaning
P0001422invalid_qtyThe requested quantity is invalid.invalid_qty
P0002404not_foundNot found.session_not_found -> opaque 404 so ids cannot be probed across tenants
P0003409session_not_liveThis session is not open for booking.session_not_live
P0004409sold_outThis session is sold out.sold_out
P0005500internal_errorSomething went wrong. Please try again.merchant_scope_missing -> internal; the caller should never trigger this
P0006404not_foundNot found.booking_not_found -> opaque 404
P0007409invalid_stateThis 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
P0008422invalid_requestA valid checkout session id is required.invalid_request -> missing/blank Stripe checkout session id
P0009404not_foundNot found.payment_not_found -> opaque 404 so payment refs cannot be probed across tenants
P0010409invalid_stateThis payment can't move to that status.invalid_payment_transition -> illegal status move (e.g. refund a pending payment)
P0011409invalid_stateThis booking already has a completed payment.payment_already_paid -> one-paid-per-booking guard tripped
P0012409invalid_stateThis payment has already been recorded.duplicate_provider_ref -> (provider, provider_ref) already recorded
P0013422invalid_requestThe payment request is invalid.invalid_request -> bad payment request shape (provider/purpose/amount/discriminator)
P0014500internal_errorSomething 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.
P0015422invalid_requestThis 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).
P0016409invalid_stateThis 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).
P0017409booking_not_confirmableThis 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).
P0018409booking_not_authorizedThis booking has not been authorized.booking_not_authorized -> no entitlement source (payment, credits, or membership) authorized this booking (db/22).
P0019422no_customer_identityThis 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).
P0020402insufficient_creditsThere 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.
P0022409refund_exceeds_paymentThis 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).
P0023409purchase_not_configuredThis 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).
P0024422schedule_has_no_slotsA 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).
P0025422invalid_scheduleThis 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).
P0026404schedule_not_foundNot found.schedule_not_found -> opaque 404, so schedule ids cannot be probed across tenants. Raised by the ownership-checked generation wrapper (db/34).
P0027409merchant_not_verifiedConnect 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).
P0028403merchant_not_liveThis 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.
P0029403merchant_user_not_memberYou 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).
P0030409last_ownerA 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).
P0031404not_foundNot 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).
P0032409location_is_defaultSet 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).
P0033409waitlist_offer_closedThis 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).
P0034409setup_intake_openYou 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).
P0035422invalid_credit_scopeThat 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).
P0036422schedule_change_policy_requiredChoose 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.
P0037409capacity_below_bookedThat 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).
P0038409session_not_editableThis 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).
P0039500internal_errorSomething 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.
P0040404not_foundNot 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.
P0041409environment_lockedThis 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).
P0042402billing_suspendedOnline 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.
P0043409invalid_stateOnly 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).
P0044409class_not_bookable_no_payment_accountThis 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.
P0046404not_foundNot 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.
P0047409course_not_liveThis 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.
P0048404not_foundNot 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.
P0049429rate_limitedThe 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.
P0050429too_many_codesToo 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.