Error reference
Every failure is an RFC 9457 problem document served as application/problem+json.
{
"type": "https://api.playspace.health/problems/forbidden",
"title": "Insufficient scope",
"status": 403,
"detail": "The presented token does not grant every scope this endpoint requires.",
"required_scopes": ["patients:write"],
"granted_scopes": ["patients:read", "appointments:read"]
}
type, title and status are always present. detail explains this occurrence, and problem-specific extension fields are spread onto the top level of the same object rather than nested.
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 X-Request-Id on every failure. It is on every response, success or error, and quoting one to PlaySpace addresses a single request in our audit history.
The type is an absolute URI under https://api.playspace.health/problems/. It identifies the problem class and is deliberately not something you fetch — do not resolve it at runtime.
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, that the token has not expired, and that it was issued for the audience PlaySpace gave you. An organisation whose access has been revoked also lands here, and re-fetching the token will not clear it.
forbidden — 403
Authenticated, but this credential may not do this.
On a scope failure, extensions required_scopes and granted_scopes carry both lists verbatim — the whole granted list, not just the missing subset — so the diagnosis is in the response.
The same status covers three other refusals that are not about scope: an organisation-level write attempted with a delegated token (any practitioner or clinic create, update or delete, and deleting a patient); an organisation-wide token attempting a delegated-only surface, which has no single clinician to act as; and an embed-token mint naming a patient who is not on the acting clinician's roster, which is one shared refusal for "no such patient" and "not your patient" alike so the endpoint cannot be used to test whether a patient exists.
partner-suspended — 403
Your organisation's account is suspended. Every request fails until it is restored. Contact PlaySpace; this is never a code problem.
delegation-not-enabled — 403
You presented a delegated token, but delegated access is not enabled for your organisation.
Delegation is an organisation-level setting turned on by PlaySpace during onboarding. Organisation tokens are unaffected. The response deliberately does not echo the practitioner identifier you named.
practitioner-disabled — 403
You presented a delegated token for a clinician your organisation has disabled.
Set status back to active with PATCH /v1/partner/practitioners/{id} and the token works again. Organisation tokens are unaffected, so you can always read the disabled practitioner and re-enable them. Like the previous one, the response never echoes the practitioner identifier.
not-found — 404
The resource is absent, deleted, belongs to another organisation, or lies outside the acting clinician's scope on a delegated token.
These four are deliberately indistinguishable, and detail never echoes the identifier you supplied. Distinguishing them is exactly the signal an enumeration attack needs. A repeated DELETE also lands here: only an idempotency replay of the original delete observes the prior success.
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.
partner-email-conflict — 409
The email supplied to POST /v1/partner/practitioners is already in use by an existing practitioner. Choose another.
clinic-name-conflict — 409
The clinic name supplied to POST /v1/partner/clinics is already held by another clinic in your organisation. Comparison is case-insensitive and scoped to you: the same name may legitimately exist under a different partner.
community-item-conflict — 409
The content you are sharing is already in the clinic community. Returned by POST /v1/partner/clinic-community/items when the same source is already shared with the same clinic. Not retriable with the same content: list the clinic community to find the existing item, or remove it first.
clinic-not-empty — 409
DELETE /v1/partner/clinics/{id} was called on a clinic that still has dependents. Extensions practitioner_count, patient_count and appointment_count carry the counts, so you can render an actionable message without a second round-trip.
Clear or reassign the dependents first. The delete is not retriable until the clinic is empty.
practitioner-owner-protected — 409
You attempted to disable or delete the practitioner who owns a clinic that has upgraded to a direct PlaySpace login.
That practitioner is the clinic's owner, and suspending or removing their PlaySpace login through this API would lock the clinic out of the account that runs it while its subscription keeps billing, so the request is refused rather than performed. Reassign owner to another practitioner first with PATCH /v1/partner/practitioners/{id} (role: owner), then retry the original disable or delete. Raised by PATCH /v1/partner/practitioners/{id} (status: disabled) and by DELETE /v1/partner/practitioners/{id}. The response deliberately does not echo the practitioner identifier or any name.
last-page-protected — 409
DELETE /api/embed/worksheets/{id}/pages/{pageId} was called on the only page a worksheet has left. A worksheet always keeps at least one page, because a pageless worksheet is not a state any PlaySpace surface can render.
Delete the worksheet itself with DELETE /api/embed/worksheets/{id} instead. The page delete is not retriable until the worksheet has a second page.
storybook-changed — 409
POST /api/embed/storybooks/{id}/pages or PATCH /api/embed/storybooks/{id}/pages found that the storybook's page order (or its deleted state) changed between the request's read and its write: another tab, a retrying host, or a delete landed in between. Nothing was written, including on the add-page verb, where the generated page is discarded rather than left unlisted.
Reload the storybook and decide again against its current pages. Retrying the identical request is not useful until it has been re-planned.
export-in-progress — 409
POST /v1/partner/exports was called for a clinician who already has an export running.
A clinician has one live export slot, and it is shared with the exports they start from PlaySpace themselves — so this can be raised by an export you never asked for and cannot see. There is no payload to change and nothing to retry differently: poll the running export to completion, then start another. The body deliberately does not identify the export that is blocking you, because it may be the clinician's own.
export-not-ready — 409
GET /v1/partner/exports/{id}/download was called on an export that has no archive to hand over yet — still building, or failed.
Deliberately not a 404: the export exists and belongs to the acting clinician. Poll GET /v1/partner/exports/{id} until status is ready and download then; retrying the download itself only repeats this.
export-expired — 410
The export finished, and its archive has since been deleted.
A finished archive stays downloadable for seven days. This is terminal for that export and not retriable at all — start a new one. Deliberately not a 404: the export still exists and still reads as a status, so "no such export" would be an answer you could not act on. Expiry is checked before readiness and fails closed, so an export whose retention stamp is missing or unreadable answers this rather than handing over a link.
validation-error — 422
Well-formed, but the contents are not acceptable.
Unknown request fields are rejected here rather than ignored, so a misspelled firstName where the API expects first_name fails immediately. Malformed values land here too: an origin on an embed-token mint that no browser would honour as a frame source is rejected at mint rather than producing a silently blank rectangle later.
idempotency-key-mismatch — 422
This Idempotency-Key was used with a different payload.
Almost always a bug in key generation — a key reused across two logically different calls. Keys are scoped to your organisation and live 24 hours.
same-clinic-mismatch — 422
An appointment must link a practitioner and a patient who belong to the same clinic as the appointment itself. One of the three does not match.
rate-limited — 429
Too many requests. Carries Retry-After in seconds, plus the remaining counts and reset times for both windows.
Honour Retry-After. A request that fails authentication does not consume budget, so an unauthenticated caller cannot deplete your quota.
internal-error — 500
Our fault. Retry once with the same idempotency key; if it persists, open a ticket quoting the X-Request-Id.
Every one of these is captured on our side against the same identifier, so a report is actionable immediately.
Embed-tier problem types
The routes an embed token calls — /api/embed/*, reached with the short-lived token you mint rather than with your partner credential — answer the same problem documents under the same type prefix, and carry X-Request-Id on success as well as failure. Everything in the catalogue above still reaches an embed caller: unauthorized, not-found, validation-error, rate-limited, partner-suspended, delegation-not-enabled and practitioner-disabled all mean there what they mean above. These are the slugs that exist only on this tier.
capability-missing — 403
The token verified, but it does not carry the capability this route needs. The extension required_capability names the missing one, so the fix is to add it to the mint rather than to infer it. The patient seat of a game session also lands here when it asks for something only the practitioner seat may do, carrying required_role instead.
feature-not-entitled — 403
The clinician this token names belongs to a clinic whose plan does not include the feature behind this surface; the extension required_feature says which. No capability you mint widens an entitlement — this one is settled between the clinic and PlaySpace, not in your code.
session-scope-missing — 403
The token is valid but carries no client or game-session scope, so a route that has to act on one has nothing to act on. Mint a token bound to a client — patientId on the mint — and retry.
recipient-address-missing — 422
You asked PlaySpace to email a client something and PlaySpace holds no email address for them. The request was correct and the stored state was not, which is why this is not validation-error: set email on the client with PATCH /v1/partner/patients/{id} and retry. The body names no value, not even the client's name.
Handling errors with the SDK
@playspace-health/embed throws PlaySpaceApiError for any failed call. It carries the problem verbatim, so you branch on status and problem.type rather than parsing a message.
import { PlaySpaceApiError } from '@playspace-health/embed/server'
try {
const embed = await playspace.mintEmbedToken({ capabilities, origins })
} catch (error) {
if (error instanceof PlaySpaceApiError) {
const type = String(error.problem.type ?? '')
if (error.status === 429) return backOffAndRetry()
if (type.endsWith('/practitioner-disabled')) return showDisabledNotice()
return reportToSupport({ status: error.status, type })
}
throw error
}
There is no family of typed error subclasses to catch individually — one class, and the problem document inside it is the vocabulary.
On the browser side, the embedded surface emits a single error event, carrying message plus code, severity, retryable, and requestId when the failure had one. Branch on code, not on message: the text is prose for a human reading a log and may be reworded, while the code is the contract. Treat a code you do not recognise as a generic failure of the given severity — new ones appear as surfaces gain failure paths. Handlers should be safe to run more than once.
Retry guidance
| Condition | Retry | How |
|---|---|---|
| 500 | Yes | Exponential backoff, same idempotency key |
| 429 | Yes | After Retry-After |
409 idempotency-conflict |
Yes | A few seconds later, same key |
409 export-in-progress, export-not-ready |
Not the call | Poll the export, then start or download once it settles |
409 clinic-not-empty, practitioner-owner-protected, last-page-protected, storybook-changed, and the two name conflicts |
No | Change the request |
410 export-expired |
No | Start a new export |
| 401 | Once | Re-fetch the access token first |
| 403, 404, 422 | No | Fix the request, the token, or the configuration |
Retrying a mutation is safe when the idempotency key is unchanged: a repeat replays the original response rather than performing the work twice. The exception is anything that mints a credential — an embed token, a set of session links — where a replay hands back a token that is already part-expired, so a genuine re-mint needs a fresh key.