Authentication
Every call to the Partner API carries an OAuth2 client-credentials access token issued by Auth0 for your organisation, sent as Authorization: Bearer <token>. There are no API keys, no key prefixes and no per-endpoint secrets: one credential pair, exchanged for a bearer token, on every request.
Where the API lives
| Environment | Base URL |
|---|---|
| Production | https://agentic-ps.playspace.health |
| Development | https://agentic-ps-dev.playspace.health |
Every path sits under /v1/partner, so the patient list in production is https://agentic-ps.playspace.health/v1/partner/patients.
There is no sandbox environment. The development host is a real deployment with its own data rather than a synthetic one, and a credential is bound to the environment it was issued for: a development credential is not accepted by production and the reverse is also true. Apply separately on each host you intend to use, and apply on development before you build.
What PlaySpace hands you
You apply at /partner-register on the host you want a credential for. PlaySpace reviews the application, and on approval provisions a machine-to-machine application for your organisation and emails you a one-time link to the credential. The whole path, step by step, is get your credentials.
The credential page shows four values, once:
- a client identifier
- a client secret, shown once and not recoverable through the product afterwards
- the audience the token is bound to,
https://playspace-ehr-api - the base URL of the PlaySpace host the credential is for
It does not show the token endpoint. Each environment has its own, and both are listed in step 5 of get your credentials; the development one is what the interactive reference on this site declares. Every example on this page uses the development endpoint, so swap the host when you move to production.
Store the secret in your secret manager before you leave the page. The link opens once and expires 72 hours after approval. If it expired before you retrieved the credential, reply to the approval email: PlaySpace can reveal the same secret again while that window is still open, and can rotate it for a fresh one afterwards. Rotation invalidates the previous secret at the authorisation server, so plan a short cutover rather than assuming two secrets can be live at once.
Getting an access token
curl --request POST \
--url "https://dev-hcp1velit44csg3n.us.auth0.com/oauth/token" \
--header 'content-type: application/json' \
--data '{
"client_id": "<client id>",
"client_secret": "<client secret>",
"audience": "https://playspace-ehr-api",
"grant_type": "client_credentials"
}'
The response is a standard OAuth2 token response; take access_token and present it on every request.
GET /v1/partner/patients HTTP/1.1
Host: agentic-ps-dev.playspace.health
Authorization: Bearer eyJhbGciOiJSUzI1NiIs…
An access token lives 24 hours. Cache and reuse one until it is close to expiry rather than minting per request.
Check the credential before you build against it. GET /v1/partner/health needs no scopes and answers 200 with your organisation identifier and a server timestamp whenever the token is accepted, so it separates "my credential is wrong" from "my request is wrong" in one call.
Trying a call before you write code
The interactive reference on this site renders the specification faithfully, but it sends no requests and offers no field to paste a credential into. That is deliberate, and for three independent reasons:
- A browser would refuse the call anyway. This documentation site and the API are different origins, and the Partner API sends no cross-origin resource sharing headers. A request issued from a page on this domain is blocked by your browser before it ever reaches PlaySpace. That rule is enforced by browsers, on browsers — it has no bearing on a call your backend makes, which is why a server integration is completely unaffected by it.
- Neither credential belongs in a browser. A client-credentials access token carries your organisation's whole grant and lives for hours, and the client secret that mints it is password-equivalent. Typing either into a page on a public documentation domain is the wrong habit to build, and this site declines to teach it.
- This host serves no Partner API and never forwards your credential. Making the console send requests would mean forwarding your credential either through this domain or through a third party's. A documentation site should do neither.
So the reference here is for reading. There are three ways to make a call that actually runs.
1. curl
Take the token from getting an access token above and probe the health endpoint. It needs no scopes, so it separates "my credential is wrong" from "my request is wrong":
curl -i "https://agentic-ps-dev.playspace.health/v1/partner/health" \
--header "authorization: Bearer <access token>"
2. The hosted console, on the API host itself
The same reference is served by the API's own deployment, where the page and the endpoints share an origin — so its console does issue real requests.
| Console | |
|---|---|
| Development | https://agentic-ps-dev.playspace.health/docs |
| Production | https://agentic-ps.playspace.health/docs |
The practitioner surface has its own console alongside it, at /docs/practitioner on the same hosts.
Use the development console unless you mean to touch real clinical data — a credential is bound to its environment, so a development credential simply will not authenticate against the production one.
Mint the token in a terminal and paste it in — do not try to authorize from the panel. The authentication panel offers a Bearer Token field for exactly this, and that is the field to use. It also documents the OAuth2 client-credentials exchange, but the console cannot perform it: Auth0 returns no cross-origin resource sharing headers on its token endpoint for a client-secret grant, deliberately, because a client secret must never live in a browser. Pressing Authorize produces a blocked request whose failure surfaces as a bare console error, and it looks exactly like an authentication problem with your credential when it is nothing of the kind.
So the loop is:
- Get a token from getting an access token. For anything under worksheets, storybooks, playrooms, toolkits, forms, game saves, game sessions or embed tokens, make it a delegated token by adding
practitioner_id— see organisation tokens and delegated tokens. Two thirds of the endpoints on the page are in that group, and an organisation token gets403from every one of them. - Paste it into the Bearer Token field once. It applies to every operation on the page.
- Open an operation, press Test Request, fill in any path or query parameters, then Send Request.
Do not add scopes in the panel. Your grant is fixed on the credential and travels inside the token; requesting one you do not hold fails the exchange with 403 access_denied before the API is reached at all.
Swapping between an organisation token and a delegated one is just re-pasting the Bearer Token field — nothing else on the page changes.
3. Your own client
Import openapi.json or openapi.yaml into Postman, Insomnia, Bruno or an equivalent. Those are applications rather than web pages, so the cross-origin rule above does not apply to them either, and you get request history and environment switching for free.
Trying an embedded surface
None of the above puts a PlaySpace surface in a browser. When that is what you want to try, the credential to reach for is an embed capability token — the one credential in this system designed to be held by a browser, because it is short-lived, narrow and pinned to the origins you name. See embed capability tokens below for the exchange, including a copy-pasteable pair of calls.
Scopes
Your granted scopes are fixed on the credential at approval and are carried inside the token; you never send them. The vocabulary is one scope per resource and verb, across four resources.
| Resource | Read | Write | Delete |
|---|---|---|---|
| Clinics | clinics:read |
clinics:write |
clinics:delete |
| Practitioners | practitioners:read |
practitioners:write |
practitioners:delete |
| Patients | patients:read |
patients:write |
patients:delete |
| Appointments | appointments:read |
appointments:write |
appointments:delete |
That is the whole vocabulary — twelve strings, and nothing outside them is a scope. Content and embedded surfaces do not have scopes of their own: the storybook, worksheet, form, playroom, toolkit and game-save endpoints are gated on practitioners:read, and their writes on practitioners:write. Each operation in the reference names the exact scope it requires.
appointments:delete exists in the vocabulary but no endpoint consumes it — cancelling an appointment is PATCH /v1/partner/appointments/{id} with status: "cancelled", which needs appointments:write.
A call outside your granted scopes returns 403 with problem type forbidden, carrying both required_scopes and granted_scopes in full, so the diagnosis is in the response rather than in a support conversation. To change what you hold, ask PlaySpace; scopes are edited on the credential rather than requested per call.
Organisation tokens and delegated tokens
The token above is an organisation token. It sees every clinic, practitioner, patient and appointment your organisation owns, which is what you want for back-office synchronisation and administration.
If your integration acts on behalf of one clinician, request a delegated token instead. Add a practitioner_id — the id from any /v1/partner/practitioners response — to the same client-credentials call:
curl --request POST \
--url "https://dev-hcp1velit44csg3n.us.auth0.com/oauth/token" \
--header 'content-type: application/json' \
--data '{
"client_id": "<client id>",
"client_secret": "<client secret>",
"audience": "https://playspace-ehr-api",
"grant_type": "client_credentials",
"practitioner_id": "<practitioner id>"
}'
The authorisation server confirms the practitioner belongs to your organisation and binds it into the token. Delegation is on by default for an organisation that registered through the application form; PlaySpace can turn it off. While it is off, a delegated token is refused with 403 and problem type delegation-not-enabled.
What a delegated token narrows. Reads return only that clinician's data: the patient list and get return only their roster, the appointment list and get only their appointments. The practitioner and clinic directories are unchanged — both stay organisation-wide on either token. Writes must be self-attributed: creating an appointment requires partner_practitioner_id to equal the token's clinician, and creating a patient requires created_by_partner_practitioner_id to equal it, or the request is rejected with 422. Updates are limited to that clinician's own appointments and roster patients.
What a delegated token cannot do. Organisation-level writes — any practitioner or clinic create, update or delete, and deleting a patient — require an organisation token and return 403 on a delegated one.
What only a delegated token can do. Everything a clinician personally owns is delegated-only, because an organisation-wide token has no single clinician to act as and is refused: minting an embed token, the storybook, worksheet and form surfaces, playrooms and toolkits and their content shelves, game saves, starting a game session, and reading the clinician's clinical notes.
An identifier outside a delegated token's scope is indistinguishable from one that never existed — both return 404 — so the response cannot be used to probe what lies outside the clinician's data.
Per-clinician tokens multiply token issuance, one per clinician rather than one per organisation. Cache one delegated token per clinician and reuse it until shortly before it expires.
Suspending one clinician's access
Set a practitioner's status to disabled with PATCH /v1/partner/practitioners/{id} and every delegated token naming that clinician is rejected with 403 and problem type practitioner-disabled until you set status back to active. Disabling is reversible and keeps every record, which is what makes it the right tool for suspending access; DELETE is for off-boarding. Organisation tokens are unaffected, so you can always read a disabled practitioner and re-enable them.
Tokens a browser may hold
Neither token above may reach a browser. Both carry your organisation's full grant and a long life; an iframe source is exposed to browser history, Referer headers and anything that logs a URL. Two narrower credentials exist for that job, and your server mints both.
Embed capability tokens
POST /v1/partner/embed-tokens exchanges a delegated token for a short-lived token that authorises one embedded PlaySpace surface. The acting practitioner is read from the token's claim and never from the request body, so an organisation-wide token is refused.
POST /v1/partner/embed-tokens
Authorization: Bearer <delegated token>
Idempotency-Key: 4f81c2a9-7b3e-4d21-9f88-0c5a1e3b7d64
Content-Type: application/json
{
"capabilities": ["storybook:read", "storybook:create"],
"origins": ["https://app.yourclinic.com"],
"ttl_seconds": 900
}
The response carries the token, its expires_at, and the practitioner_id and partner_practitioner_id it was minted for, so you can record attribution against your own records rather than trusting what you sent.
The same exchange, copy-pasteable. Two calls: a delegated token, then the embed token it buys. An organisation-wide token is refused at the second step, so the practitioner_id in the first call is not optional here.
# 1. A DELEGATED access token — note practitioner_id in the token request.
curl --request POST \
--url "https://dev-hcp1velit44csg3n.us.auth0.com/oauth/token" \
--header 'content-type: application/json' \
--data '{
"client_id": "<client id>",
"client_secret": "<client secret>",
"audience": "https://playspace-ehr-api",
"grant_type": "client_credentials",
"practitioner_id": "<practitioner id>"
}'
# 2. Exchange it for a short-lived token that authorises ONE embedded surface.
curl --request POST \
--url "https://agentic-ps-dev.playspace.health/v1/partner/embed-tokens" \
--header "authorization: Bearer <delegated access token>" \
--header "idempotency-key: $(uuidgen)" \
--header 'content-type: application/json' \
--data '{
"capabilities": ["storybook:read", "storybook:create"],
"origins": ["https://app.yourclinic.com"],
"ttl_seconds": 900
}'
Set origins to the origin of the page that will host the frame — scheme and host, no trailing slash and no path. It becomes the frame-ancestors directive on the embedded document, so a page served from anywhere else cannot render the frame at all. For a first try that means your own local or preview origin, not this documentation domain: nothing here frames a PlaySpace surface.
With the token in hand, the frame itself is @playspace-health/embed — Embed a sandtray walks the whole path from mint to mounted frame, and Using the API from other languages walks it without the package.
Three things about the mint are worth knowing before you write it:
- Capabilities are per action, not per surface. Storybooks take
storybook:read,storybook:create,storybook:write,storybook:deleteandstorybook:share, wherestorybook:writeedits a book that already exists andstorybook:deleteretires one from the library, neither implied bystorybook:create; worksheets takeworksheet:read,worksheet:create,worksheet:write,worksheet:deleteandworksheet:share; forms takeform:read,form:create,form:write,form:delete,form:submit,form:composeandform:send, whereform:writerewrites a form that already exists andform:deleteretires one from the library, neither of them implied byform:create; playrooms takeplayroom:readandplayroom:write; the clinician's own data is read withnote:read,client:readandappointment:read, and the two write halves a framed clinician can hold over it arenote:write(edit, sign and unlock their own note) andappointment:write(book, reschedule and cancel), neither implied by its read half; a live game session takesgames:play; and framing the whole workspace takesshell:readalongside the capability for each area inside it. Ask for the narrowest set the screen needs. originsbecomes the frame-ancestors directive on the embed document, so a host whose origin is not listed cannot render the frame at all. A wildcard is accepted only as a whole leading label (https://*.yourclinic.com); a wildcard inside a label is rejected at mint, because a browser would discard it and refuse to frame the page from anywhere.patient_idis required when the capabilities includeform:submit,form:send,storybook:shareorworksheet:share. A recorded submission, a form PlaySpace emails, and a storybook or worksheet PlaySpace emails are each about one named client, and each must have a subject. The patient must belong to your organisation and be on that clinician's roster; a patient outside either is rejected with403rather than silently ignored.
Mint per page load rather than per session, and call it server-side. @playspace-health/embed wraps the whole exchange — see the SDK reference.
Session links
POST /v1/partner/appointments/{id}/session-links returns both join links for an appointment: patient_video_url and clinician_video_url. This is the hosted whole-page alternative to embedding, and it needs appointments:read.
Each is a short https://<host>/join/<code> address that redirects to a session token, so the link itself is a credential — treat the code as the secret it is, not as an appointment identifier. Deliver the patient link to the patient and the clinician link to the assigned clinician, over a secure channel, and keep both out of your logs. An in-person appointment returns clinician_video_url alone, with patient_video_url as null: it opens the PlaySpace session without video, for the clinician and the child sitting in the same room.
Restricting where a credential may be used
An address allowlist can be attached to your organisation, so a leaked secret is useless off your network. Both IPv4 and IPv6 ranges are supported. It is configured by PlaySpace rather than self-serve — send us the ranges your backend calls from.
What to do when something is refused
| Status | Type | Cause | Fix |
|---|---|---|---|
| 401 | unauthorized |
Missing, malformed or expired token, or a revoked organisation | Re-fetch the token. Check you sent the literal Bearer prefix |
| 403 | forbidden |
Scope not granted, or a delegated token attempting an organisation-level write | Compare the two lists in the problem document |
| 403 | partner-suspended |
Organisation suspended | Contact PlaySpace; this is never a code problem |
| 403 | delegation-not-enabled |
Delegated token, delegation not enabled for your organisation | Ask PlaySpace to enable it |
| 403 | practitioner-disabled |
Delegated token naming a clinician you have disabled | Set status back to active |
| 404 | not-found |
Absent, deleted, another organisation's, or outside a delegated token's scope | Deliberately indistinguishable — check the identifier and the token |
| 422 | validation-error |
Well-formed but not acceptable | The problem document names the fields |
| 429 | rate-limited |
Too many requests | Honour Retry-After |
Every response, success or failure, carries an X-Request-Id header. Log it. Quoting one to PlaySpace addresses a single request in our audit history and turns a day of diagnosis into a few minutes.
Full catalogue: the error reference. Everything true of every endpoint: conventions.