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

See Portability and exports.


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.