Authentication
Two credential types, for two different places. Confusing them is the most common integration mistake and the one with the worst consequences, so the distinction is worth thirty seconds.
| Client credentials | Session token | |
|---|---|---|
| Obtained from | Partner console | POST /tokens |
| Lives | Your server, only | A browser |
| Scope | Your whole organisation | One person, one session, one role |
| Lifetime | Until rotated | 15 minutes by default, 60 maximum |
| Authorises | The REST API | A rendered surface |
Client credentials never reach a browser. They authorise everything your organisation can do. A session token is the browser-safe substitute: narrow, short-lived, and scoped to one person.
Client credentials
Issued once, on approval, through the partner console. The secret is shown exactly once and is not recoverable — store it in your secret manager before closing the dialog.
Client ID psc_live_4f81c2a9
Client secret pss_live_… shown once
Getting an access token
POST /v1/oauth/token
Content-Type: application/json
{
"client_id": "psc_live_4f81c2a9",
"client_secret": "pss_live_…",
"grant_type": "client_credentials"
}
{
"access_token": "eyJhbGciOiJSUzI1NiIs…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "identity:write sessions:write content:read content:write generation:write exports:write"
}
GET /v1/patients
Authorization: Bearer eyJhbGciOiJSUzI1NiIs…
Access tokens live one hour. Cache and reuse one until it expires rather than minting per request — the token endpoint is rate-limited more tightly than the rest of the API, precisely to discourage that pattern.
The server SDK does all of this for you and you will never see an access token.
Scopes
Requested at approval and fixed on the credential. Ask for what you need; you can request a change later, and narrowing is instant while widening is reviewed.
| Scope | Grants |
|---|---|
identity:read |
Read clinics, practitioners, patients, caregiver links |
identity:write |
Create and update them |
sessions:read |
Read sessions and summaries |
sessions:write |
Create sessions, mint tokens and links, end and cancel |
content:read |
Read playrooms, toolkits, games, storybooks, worksheets, forms |
content:write |
Create and update content, attach it to playrooms, assign it |
generation:write |
Every generation endpoint. Spends quota |
artifacts:read |
Read and download artifacts |
exports:write |
Create exports |
notes:read |
Read clinical notes. Requires the notes capability on the organisation |
changes:read |
Read the change feed |
A call outside your granted scopes returns 403 forbidden with both the required and the granted lists in the problem document, so the fix is visible in the error rather than requiring a support conversation.
Rotation
Two credentials can be live at once, which is what makes zero-downtime rotation possible:
- Issue a second credential in the console. Both work.
- Deploy the new one.
- Confirm traffic has moved — the console shows last-used per credential.
- Revoke the old one.
Rotate on any suspicion of exposure, and on staff departure if your secret manager does not make that unnecessary.
Internet-protocol allowlisting
Optional and recommended. Restrict a credential to the addresses your backend calls from, and a leaked secret is useless off your network. Configured in the console as a list of address ranges; both IPv4 and IPv6 are supported.
Session tokens
Minted by your backend, consumed by a browser.
POST /v1/tokens
Authorization: Bearer <access token>
Idempotency-Key: 6f1c…
Content-Type: application/json
{
"session": "sess_2Nk8pQvR7xLm",
"subject": { "external_id": "staff_8842" },
"role": "clinician",
"origins": ["https://app.example-practice.com"],
"ttl": "15m"
}
{
"data": {
"id": "tok_9Xm2Kd",
"value": "eyJhbGciOiJFUzI1NiIs…",
"role": "clinician",
"surfaces": ["sandtray", "dollhouse", "whiteboard", "games"],
"expires_at": "2026-09-02T15:15:00Z"
}
}
The token endpoint you write
Twenty lines, and the only piece of this that must live on your backend.
// app/api/playspace/token/route.ts
export async function POST(request: Request) {
const user = await yourAuth(request) // your session, your rules
const { sessionId } = await request.json()
if (!(await userMayJoin(user, sessionId))) {
return new Response('Forbidden', { status: 403 })
}
const token = await playspace.tokens.issue({
session: sessionId,
subject: { externalId: user.externalId },
role: user.isClinician ? 'clinician' : 'patient',
origins: [process.env.APP_ORIGIN],
ttl: '15m',
})
return Response.json({ token: token.value, expiresAt: token.expiresAt })
}
Authorise before you mint. PlaySpace checks that the subject belongs to your organisation and that the session exists. It cannot check that this browser should be that subject — only you know that. The userMayJoin line is the whole security boundary.
Three gates at mint time
All three fail loudly rather than issuing a token that will not work:
Surfaces within the session's ceiling. Requesting more than the session permits returns 422 capability-missing.
Origins a browser will honour. Every origin is checked against what can validly appear in a frame-ancestors directive. A trailing slash, a path, a wildcard host or a plain hostname without a scheme is rejected at mint with 422 origin-not-allowed. This converts the single most frustrating failure in embedded software — a silently blank rectangle — into a message naming the offending value.
The organisation licensed for the surface. Otherwise 403 entitlement-denied, naming the surface.
Lifetime and renewal
Fifteen minutes by default, sixty maximum, and deliberately short: a token sitting in a tab a clinician left open over lunch should not still be live after lunch.
You do not write renewal. The React SDK calls your fetchToken again at eighty percent of the lifetime and swaps the new token in underneath a live surface without remounting it. If you are on the browser SDK directly, the same callback contract applies.
Revocation
DELETE /v1/tokens/tok_9Xm2Kd
Immediate. Call it when a clinician signs out of your application mid-session. Cancelling a session revokes every token issued against it.
Sandbox
Sandbox credentials are prefixed psc_sandbox_ and pss_sandbox_ and are issued self-serve — no agreement, no review. They are valid only against https://api.sandbox.playspace.health/v1 and are rejected by production, and production credentials are rejected by the sandbox. There is no configuration under which sandbox traffic can reach real clinical data.
What to do when something is refused
| Status | Type | Cause | Fix |
|---|---|---|---|
| 401 | unauthorized |
Missing, malformed or expired access token | Re-fetch. Check you sent Bearer |
| 403 | forbidden |
Scope not granted | Compare the two lists in the problem document |
| 403 | entitlement-denied |
Organisation not licensed for a surface | Commercial, not technical |
| 403 | organisation-suspended |
Account suspended | Contact partner engineering |
| 422 | capability-missing |
Token requests more than the session permits | Widen the session or narrow the token |
| 422 | origin-not-allowed |
An origin no browser will honour | The problem document names the value |
| 429 | rate-limited |
Too many requests | Honour Retry-After |
Log the request_id on every failure. Quoting one to partner engineering addresses a single request in our audit history and turns a day of diagnosis into a few minutes.