Error model
The exact runtime envelope every SeatBuilder error response carries, the stable machine-readable code table, and what each HTTP status means.
Error model
Every error response from the SeatBuilder public API follows the same JSON envelope:
{
"statusCode": 400,
"error": "Bad Request",
"message": "Validation failed",
"code": "validation_failed",
"failed": [ /* only on seat hold/book/release 409s — see below */ ],
"errors": { /* only on 400 validation failures — see below */ }
}statusCode— the HTTP status code; mirrors the response status.error— the HTTP reason phrase forstatusCode(e.g."Bad Request","Not Found","Conflict").message— a stable, human-readable summary. Safe to surface verbatim to end users, but do not branch your integration logic on its exact wording — the copy can change between releases.code— a stable, snake_case machine-readable code. Branch oncode, notmessage.messagewording may change between releases;codewill not.failed— present only on the all-or-nothing 409s from/seats/hold,/seats/book, and/seats/release: the specificobjectLabels that blocked the batch, each with areason.errors— present only on400 validation_failed: a dot-path map of field-level Zod messages.
Error codes
code | Typical statusCode | Meaning |
|---|---|---|
validation_failed | 400 | The request body failed schema validation. See errors. |
unauthorized | 401 | Missing or invalid X-Api-Key. |
forbidden | 403 | Cross-workspace or cross-environment access denied. |
public_key_not_allowed | 403 | A publishable (pk_*) key called an endpoint that requires a secret key — book, extend, or reset. |
not_found | 404 | A generic resource (chart, webhook, …) doesn't exist in the authenticated workspace/environment. |
event_not_found | 404 | The eventKey in the URL doesn't exist in the authenticated workspace/environment. |
event_key_conflict | 409 | POST /events with an eventKey already used by a different chart, environment or workspace. Retrying with the same eventKey + chartKey is not an error: it returns the existing event with 200. |
event_has_reservations | 409 | DELETE /events/{eventKey} on an event that still has booked or live-held seats. Release them first. |
seat_unavailable | 409 | A seat in a /hold request is already held or booked by another token. |
not_held_by_token | 409 | A label in a /book or /release request is not (live-)held by the caller's holdToken — it belongs to another token, the token never held it (this includes booking a general-admission area without a GA hold), or (for /release) it's booked and the caller used a public key. |
hold_expired | 410 (book) / included in extend failed[] | The token's hold on this label (seat or GA area) has expired. On /book this is a 410 and nothing is booked; on /extend it's a failed[] reason, not a request failure. Reported after the expiry sweep too: an expired hold is remembered for its token until the seat is held again (by any token) or an operator force-releases or changes it; for a GA area the record stays for that token. |
hold_token_required | 400 | /release was called without holdToken from a public key, without holdToken or objectLabels at all, or against a general-admission area with no holdToken. |
ga_label_requires_quantity | 400 | A single-element objectLabels[] names a GA area but omits objectType: "generalAdmission" / quantity. |
ga_capacity_exceeded | 409 | A GA hold (new or resized) doesn't fit the area's remaining capacity. |
no_active_hold | 409 | /extend was called with a holdToken that has no hold (live or expired) or booking at all on this event. |
publish_affects_reservations | 409 | POST /charts/{chartKey}/publish would remove, re-type, re-categorise or over-shrink a booked or held label on an event of this chart. failed[] lists each one with eventKey, environment, reason, booked, held. Retry with ?force=true to publish anyway. |
rate_limited | 429 | Too many requests from your IP address. See Rate limits. |
plan_limit_reached | 402 | The workspace's plan limit was hit (e.g. chart or event count). |
sandbox_only | 403 | POST /events/{eventKey}/reset was called against a production event. |
conflict | 409 | A generic concurrency conflict — safe to retry the request as-is. |
bad_request | 400 | A generic malformed request with no more specific code. |
internal_error | 500 | An unhandled exception inside the platform. |
Validation errors (400, validation_failed)
Zod validation runs before any controller handler. When a request body violates a schema, the server returns:
{
"statusCode": 400,
"error": "Bad Request",
"message": "Validation failed",
"code": "validation_failed",
"errors": {
"name": ["String must contain at least 3 character(s)"],
"draftVersion": ["Expected object, received string"]
}
}The errors map has the following shape:
- Keys are dot-paths into the request body — e.g.,
name,draftVersion,sections.0.rows.2.label. The synthetic key_rootis used for whole-body violations (e.g., the request body itself is the wrong type). - Values are arrays of human-readable Zod messages. A single field can fail multiple constraints (e.g., min length AND format) and each failure is reported as a separate string.
Display these messages directly under the offending form field; the server has already formatted them for UI consumption.
Authentication errors (401, unauthorized)
Returned when the X-Api-Key header is missing, malformed, or hashes
to a key the server doesn't recognise.
{
"statusCode": 401,
"error": "Unauthorized",
"message": "Missing API key",
"code": "unauthorized"
}The same envelope is returned for the "invalid key" case
("message": "Invalid API key") — the server does not distinguish
unknown keys from forged keys to keep enumeration noisy.
Authorization errors (403)
Returned when the key authenticates but is not allowed to perform the requested action. Two distinct codes cover this:
forbidden— wrong workspace or wrong environment.public_key_not_allowed— a publishable (pk_*) key calledbook,extend, orreset, all of which are secret-key-only actions.
{
"statusCode": 403,
"error": "Forbidden",
"message": "This endpoint does not accept public keys",
"code": "public_key_not_allowed"
}Not found (404, not_found / event_not_found)
Returned when the requested chart, event, webhook, or seat does not exist inside the authenticated workspace and environment.
{
"statusCode": 404,
"error": "Not Found",
"message": "Event not found",
"code": "event_not_found"
}A 404 never leaks data — if a resource exists in a different workspace or environment but matches the requested key, the response is the same 404.
Conflict (409)
The most common family of write-endpoint errors. Every 409 from hold,
book, release, or extend is safe to inspect and react to
programmatically:
seat_unavailable(/hold) — one or more requested seats are already held or booked by a different token. Nothing was held (all-or-nothing);failed[]lists the blocking labels.not_held_by_token(/book,/release) — one or more labels are not live-held by the caller's token (someone else holds it, it's booked, or — for/releasefrom a public key — it's booked at all). Nothing was booked or released;failed[]lists the labels.ga_capacity_exceeded(/hold) — a GA hold (fresh or a same-token resize) doesn't fit the area's remaining capacity.no_active_hold(/extend) — the token has no hold or booking at all on this event.conflict— a generic concurrency conflict (e.g. a GA pool changed mid-request); safe to retry.
{
"statusCode": 409,
"error": "Conflict",
"message": "One or more seats could not be released",
"code": "not_held_by_token",
"failed": [{ "objectLabel": "A-13", "reason": "not_held_by_token" }]
}Retry only after re-prompting the buyer (or, for /extend, dropping the
failed labels) — a blind retry of the same request will not succeed.
Rate limits (429)
Limits apply per client IP address, before the request reaches the API:
| Endpoints | Sustained rate | Burst |
|---|---|---|
/api/v1/events/{eventKey}/seats/*, /api/v1/events/{eventKey}/status | 20 requests/s | 40 |
/api/v1/auth/* | 5 requests/s | 5 |
All other /api/v1/* endpoints, including POST /events and DELETE /events/{eventKey} | 30 requests/s | 60 |
A request over the limit is rejected immediately with HTTP 429, a
Retry-After: 1 header and the usual error body:
{ "statusCode": 429, "error": "Too Many Requests", "message": "Too many requests", "code": "rate_limited" }Wait at least Retry-After seconds, backing off further on repeats (for
example 1 s, 2 s, 4 s), and retry the same request. For bulk jobs
such as creating hundreds of events, stay under about 10 requests/s with
at most 5 in flight, and pass your own eventKey so retries are safe.
Hold expired (410, hold_expired)
Returned only from /book, when a label (seat or GA area) was held by
the caller's token but that hold's TTL has already elapsed. The hold is
already free for anyone else to take; nothing from the request was booked.
This holds both right after expiry and after the background expiry sweep
has run. An expired hold is remembered for its token until the seat is held again (by any token) or an operator force-releases or changes it. For a GA area the record stays for that token. Releasing an expired hold does not clear it (it is
already free, so /release reports it under alreadyFree). Once another
token holds the seat, your token gets 409 not_held_by_token for it
instead (someone else has it now).
{
"statusCode": 410,
"error": "Gone",
"message": "One or more holds have expired",
"code": "hold_expired",
"failed": [{ "objectLabel": "A-12", "reason": "hold_expired" }]
}/extend reports the same underlying condition differently: an expired
label comes back in the 200 response's failed[] array with
reason: "hold_expired" rather than failing the whole call — extend is
partial-success, not all-or-nothing.
Clear the buyer's client-side selection and prompt them to re-pick seats. See Hold seats and confirm at checkout for the full recovery flow.
Server errors (500, internal_error)
Returned for unhandled exceptions inside the platform. Retry with
exponential backoff; if the error persists, capture the request ID
(X-Request-Id response header) and contact support.
{
"statusCode": 500,
"error": "Internal Server Error",
"message": "An unexpected error occurred",
"code": "internal_error"
}