Error reference

Every failure is an RFC 9457 problem document served as application/problem+json.

{
  "type": "https://api.playspace.health/problems/quota-exceeded",
  "title": "Generation quota exceeded",
  "status": 429,
  "detail": "The storybook generation allowance for this period is exhausted.",
  "resource": "storybooks",
  "resets_at": "2026-10-01T00:00:00Z",
  "request_id": "req_8Kp2mQ"
}

Branch on type. It is a stable identifier: the set only grows, and an existing slug never changes meaning. title and detail are prose for a human reading a log and may be reworded at any time.

Log request_id on every failure. Quoting one to partner engineering addresses a single request in our audit history.


The catalogue

Slugs are relative to https://api.playspace.health/problems/.

unauthorized — 401

Missing, malformed, or expired credentials.

Check that you sent Authorization: Bearer <token> with the literal Bearer prefix, and that the access token has not passed its one-hour lifetime. A sandbox credential against production, or the reverse, also lands here.

forbidden — 403

Authenticated, but this credential may not do this.

Extensions required_scopes and granted_scopes carry both lists verbatim, so the diagnosis is in the response.

{
  "type": "…/forbidden",
  "status": 403,
  "required_scopes": ["generation:write"],
  "granted_scopes": ["identity:write", "sessions:write", "content:read"]
}

Also returned when a patient-role token reaches a clinician-only operation.

organisation-suspended — 403

Your organisation's account is suspended. Every request fails until it is restored. Contact partner engineering; this is never a code problem.

entitlement-denied — 403

Your organisation is not licensed for the surface or capability requested. Extension surface names it.

Commercial rather than technical. Read GET /organisation/surfaces and render your own entry points from that list so a clinician never clicks something that cannot work.

not-found — 404

The resource is absent, archived, or belongs to another organisation.

These three are deliberately indistinguishable. Distinguishing them is exactly the signal an enumeration attack needs. If a record you created yesterday returns 404 today, it was archived.

conflict — 409

A genuine collision — a session already live for that appointment, or a content item already attached to that playroom.

idempotency-conflict — 409

A request with this Idempotency-Key is still in flight. Retry in a few seconds; do not change the key.

A crashed original is treated as retriable after sixty seconds, so this clears itself.

idempotency-key-mismatch — 422

This Idempotency-Key was used with a different request body.

Almost always a bug in key generation — a key reused across two logically different calls. Keys are scoped to your organisation and retained 24 hours.

validation-error — 422

Well-formed, but the contents are not acceptable. Extension fields locates each problem.

{
  "type": "…/validation-error",
  "status": 422,
  "fields": [
    { "path": "pages", "message": "must be between 4 and 24" },
    { "path": "characters[2].role", "message": "must be one of protagonist, supporting, background" }
  ]
}

Unknown request fields are rejected here rather than ignored, and named in fields. A silently dropped misspelling is a production bug; a rejected one is a development bug.

capability-missing — 422

A token requested a surface the session does not permit. Extensions requested and session_surfaces carry both lists.

Either widen the session with PATCH /sessions/{id} or narrow the token. The session's list is always the ceiling.

origin-not-allowed — 422

An origin in the mint request is not one a browser will honour as a frame source. Extension origin names the offending value.

Common causes: a trailing slash, a path component, a plain hostname with no scheme, a wildcard host, or http on a non-localhost host.

https://app.example.com          valid
https://app.example.com/         trailing slash
https://app.example.com/session  path component
app.example.com                  no scheme
https://*.example.com            wildcard
http://app.example.com           insecure, non-localhost

Validating this at mint rather than at frame load is deliberate: it turns a silently blank rectangle into a message naming the value.

session-conflict — 409

A session already exists for that appointment identifier, or the session is in a state that forbids the operation — ending a cancelled session, minting links for a completed one.

rate-limited — 429

Too many requests. Carries Retry-After in seconds and the remaining counts for both windows.

Honour Retry-After. Retrying sooner extends the window rather than shortening it. Failed-authentication requests do not consume budget, and a change-feed poll that finds nothing does not either.

quota-exceeded — 429

The generation allowance for this period is exhausted. Extensions resource and resets_at.

Distinct from a rate limit. Waiting will not help until resets_at. Read GET /usage and warn in your own interface at eighty percent — a clinician who hits this mid-session with a seven-year-old will contact you, not us.

A failed generation does not consume quota, so a retry after a failure is free.

export-not-permitted — 403

The requested_by party has no standing to make this disclosure.

A clinician may export their own clients; an administrator any client in their clinic; a caregiver a dependent whose link carries can_request_export. Notes additionally require the notes capability, an explicit notes in include, and a clinician or administrator requester.

surface-unavailable — 503

A surface is temporarily unavailable. Carries Retry-After. The surface renders its own notice to the user, so no interface work is needed on your side.

internal-error — 500

Our fault. Retry once with the same idempotency key; if it persists, open a ticket with the request_id.

Every one of these is captured on our side with the same identifier, so a report is actionable immediately.


Mapping to SDK errors

The TypeScript SDK throws typed errors carrying the same fields.

Problem type SDK error Notable properties
unauthorized AuthenticationError
forbidden, entitlement-denied, organisation-suspended, export-not-permitted PermissionError requiredScopes, grantedScopes, surface
not-found NotFoundError
conflict, session-conflict, idempotency-conflict ConflictError
validation-error, idempotency-key-mismatch, capability-missing, origin-not-allowed ValidationError fields, origin, requested
rate-limited RateLimitError retryAfter
quota-exceeded QuotaExceededError resource, resetsAt
surface-unavailable, internal-error ServiceError retryAfter

Every one extends PlaySpaceError and carries type, status and requestId.

try {
  await playspace.storybooks.generate({ ... })
} catch (error) {
  if (error instanceof QuotaExceededError) return scheduleRetryAfter(error.resetsAt)
  if (error instanceof ValidationError)    return reportToClinician(error.fields)
  if (error instanceof RateLimitError)     return retryAfter(error.retryAfter)
  throw error
}

Browser error codes

The React and browser SDKs surface a fixed set of codes through onError. They are not problem types — they describe failures a frame can have, some of which never reach our servers.

Code Cause Action
token_expired The token lapsed and renewal did not run Usually a static token prop where fetchToken was needed
token_invalid Malformed, revoked, or another organisation's Log request_id, check the mint
token_refresh_failed fetchToken threw three times Your token endpoint is down
origin_not_allowed This page's origin is not on the token Add it at mint
capability_missing The token does not permit this surface Widen at mint, within the session's ceiling
entitlement_denied Organisation not licensed Commercial
role_not_permitted A patient token rendered a clinician-only component Branch on role from usePlaySpace()
surface_unavailable Temporarily down Retry; the surface shows its own notice
network_error Transport failure Retry; surfaces recover their own state on reconnect
render_blocked A browser extension or storage restriction blocked the frame Not your bug. Surface the message rather than debugging it

Handlers must be idempotent. An error may fire more than once for the same underlying cause.


Retry guidance

Condition Retry How
500, 503 Yes Exponential backoff, same idempotency key
429 Yes After Retry-After
quota-exceeded Not usefully After resets_at
409 idempotency-conflict Yes Few seconds, same key
401 Once Re-fetch the access token first
400, 403, 404, 422 No Fix the request

Retrying a mutation is safe when the idempotency key is unchanged: a repeat replays the original response rather than performing the work twice. The server SDK does all of this by default and only retries mutations where the key makes it safe.