API reference
https://api.playspace.health/v1 — production
https://api.sandbox.playspace.health/v1 — sandbox
Everything the SDKs do is available over HTTP. Use this directly if you are in a language we have not shipped yet, or if you would rather not take a dependency.
The complete machine-readable contract is at https://api.playspace.health/v1/openapi.json (OpenAPI 3.1). Every SDK in Language support is generated from it.
Conventions
Authentication. Bearer token, obtained from the token endpoint with your client credentials.
POST /v1/oauth/token
Content-Type: application/json
{ "client_id": "psc_...", "client_secret": "pss_...", "grant_type": "client_credentials" }
GET /v1/patients
Authorization: Bearer eyJhbGci...
Success envelope. Every successful response has the same shape.
{
"data": { },
"meta": {
"request_id": "req_8Kp2mQ",
"generated_at": "2026-09-14T10:22:03Z",
"pagination": { "next_cursor": "cur_...", "limit": 25, "has_more": true }
}
}
Errors are RFC 9457 problem documents with Content-Type: 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.",
"resets_at": "2026-10-01T00:00:00Z",
"request_id": "req_8Kp2mQ"
}
Idempotency is required on every mutation. POST, PATCH and DELETE need an Idempotency-Key header. A repeat with the same key and the same body replays the original response; the same key with a different body is rejected. Keys are retained twenty-four hours.
Pagination is cursor-based. ?limit= up to 100, default 25. Follow meta.pagination.next_cursor. Offsets are not supported and never will be.
External identifiers work everywhere a PlaySpace identifier does. Prefix with ext:.
GET /v1/patients/ext:client_55130
Rate limits. Per organisation, two windows. Every response carries X-RateLimit-Minute-Remaining, X-RateLimit-Minute-Reset, X-RateLimit-Hour-Remaining and X-RateLimit-Hour-Reset. A 429 carries Retry-After.
Every response carries X-Request-Id. Log it.
Identity
GET /v1/clinics list
POST /v1/clinics upsert, keyed on external_id
GET /v1/clinics/{id} get
PATCH /v1/clinics/{id} update
DELETE /v1/clinics/{id} archive
GET /v1/practitioners list
POST /v1/practitioners upsert
GET /v1/practitioners/{id} get
PATCH /v1/practitioners/{id} update
DELETE /v1/practitioners/{id} archive
GET /v1/patients list
POST /v1/patients upsert
POST /v1/patients/search search — body, never a query string
GET /v1/patients/{id} get
PATCH /v1/patients/{id} update
DELETE /v1/patients/{id} archive
GET /v1/patients/{id}/caregivers list a client's caregivers
POST /v1/caregivers/link link a dependent to a caregiver
DELETE /v1/caregivers/link/{id} unlink
GET /v1/organisation your organisation
GET /v1/organisation/surfaces which surfaces you are licensed for
GET /v1/organisation/groups practice hierarchy above the clinic
Person search is a POST on purpose. A name or a free-text term in a query string is captured by platform request logs before any application-level protection can act on it. There is no GET equivalent and there will not be one.
Sessions and tokens
GET /v1/sessions list
POST /v1/sessions create
GET /v1/sessions/{id} get
PATCH /v1/sessions/{id} update
POST /v1/sessions/{id}/end end
POST /v1/sessions/{id}/cancel cancel
GET /v1/sessions/{id}/summary what happened
POST /v1/sessions/{id}/links re-mint both hosted links
POST /v1/tokens issue a session token
DELETE /v1/tokens/{id} revoke immediately
POST /v1/sessions
{
"appointment": { "external_id": "appt_11923", "scheduled_at": "2026-09-02T15:00:00Z" },
"clinician": { "external_id": "staff_8842" },
"participants": [{ "patient": { "external_id": "client_55130" } }],
"playroom": "child-default",
"surfaces": ["sandtray", "dollhouse", "whiteboard", "games"],
"notify": { "patient": true, "clinician": false }
}
201 with the session, including links.clinician and links.patient.
POST /v1/tokens
{
"session": "sess_2Nk8pQvR7xLm",
"subject": { "external_id": "staff_8842" },
"role": "clinician",
"surfaces": ["sandtray", "whiteboard"],
"origins": ["https://app.example-practice.com"],
"ttl": "15m"
}
201 with { value, expires_at, role, surfaces }. Response carries Cache-Control: no-store.
422 if a requested surface exceeds the session's ceiling, or if an origin is one no browser will honour as a frame source. Both are validated at mint time so a misconfiguration is a message rather than a blank frame.
Content library
GET /v1/playrooms POST /v1/playrooms GET|PATCH|DELETE /v1/playrooms/{id}
GET /v1/toolkits POST /v1/toolkits GET|PATCH|DELETE /v1/toolkits/{id}
GET /v1/games GET /v1/games/{slug}
GET /v1/storybooks POST /v1/storybooks GET|PATCH|DELETE /v1/storybooks/{id}
GET /v1/worksheets POST /v1/worksheets GET|PATCH|DELETE /v1/worksheets/{id}
GET /v1/forms POST /v1/forms GET|PATCH|DELETE /v1/forms/{id}
POST /v1/playrooms/{id}/contents attach content, ordered
DELETE /v1/playrooms/{id}/contents/{cid} detach
POST /v1/storybooks/{id}/assign personal copy that tracks reading position
POST /v1/worksheets/{id}/assign assign as homework
POST /v1/forms/{id}/assign assign, optionally routed to the caregiver
GET /v1/worksheets/copies completed copies
GET /v1/forms/responses structured responses
GET /v1/storybooks/{id}/pages
PATCH /v1/storybooks/pages/{page_id}
POST /v1/storybooks/pages/{page_id}/regenerate
Generation
Every generation endpoint returns 202 with a job.
POST /v1/storybooks/generate
POST /v1/worksheets/generate
POST /v1/forms/generate
POST /v1/forms/extract multipart, from an uploaded document
POST /v1/studio/generate a playable game
POST /v1/models/generate a three-dimensional figure, from text or an image
GET /v1/jobs list
GET /v1/jobs/{id} status and result
POST /v1/jobs/{id}/cancel
POST /v1/storybooks/generate
{
"clinician": { "external_id": "staff_8842" },
"subject": { "external_id": "client_55130" },
"prompt": "A story about starting at a new school.",
"pages": 8,
"age_group": "5-8",
"style": "watercolour",
"characters": [
{ "name": "Nia", "role": "protagonist", "description": "seven, box braids, yellow raincoat" }
]
}
{
"data": {
"id": "job_5Rp1wKz",
"type": "storybook",
"status": "queued",
"progress": 0,
"estimated_seconds": 180
}
}
clinician is required on every generation endpoint. subject is optional and is what makes the result personal rather than library content.
Artifacts
GET /v1/artifacts filter by subject, created_by, session, type
GET /v1/artifacts/{id}
GET /v1/artifacts/{id}/download?format= a short-lived, single-use link
type is one of storybook, worksheet, worksheet_copy, form_response, sandtray_save, dollhouse_save, whiteboard_snapshot, generated_game, model, session_summary, note.
Exports
POST /v1/exports create
GET /v1/exports/{id} status
GET /v1/exports/{id}/download when complete
GET /v1/exports history
Clinical notes
Read-only, and available only to organisations with the notes capability enabled.
GET /v1/notes list
GET /v1/notes/{id} the active version
GET /v1/notes/{id}/versions version history
GET /v1/notes/templates available documentation styles
There is no write endpoint. This is a product decision, not a gap.
Change feed
GET /v1/changes?since={cursor}&limit=100
{
"data": [
{ "sequence": 88421, "type": "artifact.ready", "occurred_at": "…",
"artifact": { "id": "sb_7Hn3xQ", "type": "storybook",
"subject": { "external_id": "client_55130" } } }
],
"meta": { "next_cursor": "cur_…", "has_more": false }
}
Change feed events
| Type | Carries |
|---|---|
session.created |
session, appointment external identifier |
session.started |
session, participants |
session.complete |
session, duration, surfaces used |
session.cancelled |
session, reason |
artifact.created |
artifact identifier and type |
artifact.ready |
artifact identifier, type, subject |
artifact.deleted |
artifact identifier |
job.progress |
job identifier, percent |
job.complete |
job identifier, resulting artifact |
job.failed |
job identifier, reason |
form.submitted |
response identifier, form, subject, score |
worksheet.completed |
copy identifier, subject |
note.available |
note identifier, session |
export.ready |
export identifier |
quota.threshold |
resource, percent consumed |
Payloads carry identifiers, statuses, counts and timestamps. Never a name, never clinical content, never a page of story text or a form answer. Fetch the object if you need it.
The cursor is opaque and monotonic. Store it, resume from it, and you cannot miss an entry or process one twice. Entries are retained thirty days.
Usage
GET /v1/usage quota consumption this period
GET /v1/usage/by-clinician per-clinician spend
GET /v1/usage/history previous periods
Problem types
Stable slugs under https://api.playspace.health/problems/.
unauthorized · forbidden · not-found · validation-error · idempotency-key-mismatch · idempotency-conflict · rate-limited · quota-exceeded · entitlement-denied · capability-missing · role-not-permitted · origin-not-allowed · session-conflict · surface-unavailable · organisation-suspended · export-not-permitted · internal-error
The set only grows. An existing slug never changes meaning, so it is safe to branch on.