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.