Level 4: the Partner API directly
No frame. Your backend talks to ours and renders nothing PlaySpace made.
This is what you reach for when there is no screen involved: keeping your records and ours in agreement, minting a session link into your own appointment view, reading back what a clinician produced, generating a storybook overnight. It is also the level underneath the other three — every embed token is minted here, and every session link comes from here.
The resource families
The organisation tier is reached with an organisation token and sees everything your organisation owns.
- Clinics — the partition everything else belongs to. Create, read, update, soft-delete.
- Practitioners — clinic-bound provider records, and the seat every delegated call acts as. Create, read, update, soft-delete.
- Patients — per-organisation, named against a clinic and the practitioner who created them. Create, read, update, soft-delete.
- Appointments — list, get, create, update. Cancel by updating
status, never by deleting. - Session links — minted on demand against an appointment. This is the hosted whole-page alternative to framing anything.
The per-clinician tier needs a delegated token, because everything in it belongs to one practitioner.
- Playrooms and toolkits — the themed space a session runs in, and what is on its shelf.
- Forms — the clinician's published forms, and the submissions recorded against them.
- Storybooks — list, read, generate, make shelf-ready, download.
- Worksheets — list, read, create, delete, download.
- Game sessions and game saves — start a sandtray or dollhouse session, and read or remove the scenes it left behind.
- Notes — the acting clinician's clinical notes, read-only, including the verbatim session transcript behind a drafted one when you ask for it.
- Embed tokens — the bridge from this level to Levels 1 to 3.
Organisation configuration is the odd one out: /v1/partner/session-config is organisation-wide, read and updated with an organisation token, and decides the video provider, the waiting room, the camera default, recording, branding, which features a session offers, and whether the framed workspace offers eligible clinic owners the Upgrade to PlaySpace page (upgrade_enabled, off unless you turn it on).
The complete list of operations with the scope each needs is the endpoint summary, and the parameters, request bodies and response schemas are in the interactive reference. This page does not restate them.
Two rules that catch most first integrations
Anything a clinician owns needs a delegated token. An organisation token has nobody to act as, so it is refused rather than guessed at, with 403. Add practitioner_id to the same client-credentials request to obtain one — the practitioner identifier is the id from any /v1/partner/practitioners response. Delegation is on by default for a self-registered organisation.
Every write carries an Idempotency-Key header. POST, PATCH and DELETE all require one: an opaque string of your choosing, unique to one logical operation. Omit it and the request is refused with 422 and problem type idempotency-key-mismatch — a refusal, not a warning. Reuse the same key with the same payload and the original response is replayed rather than the operation repeated. Reuse it with a different payload and the refusal is the same 422 with the same idempotency-key-mismatch type; only the human-readable title distinguishes the two, and title is prose you must not branch on. Reuse it while the original is still in flight and you get 409 with idempotency-conflict.
Key on something stable in your own system — the appointment the storybook is for, not the attempt number.
Which half of that 422 you are holding is decided by what you sent, not by what came back. Sent no key at all: nothing ran, so add one and send the request again. Sent a key you have already used, under a payload that differs from the one you used it with: the earlier request under that key ran to completion, so re-send the original payload under the same key and read the response that is replayed before you decide anything — a stored response counts as completed whether it succeeded or answered an error, so the replay is the only way to learn what the first attempt did. Never mint a fresh key for that second case: a fresh key is a new operation, and on POST /v1/partner/patients it creates a second patient record, because partner-managed patients carry no uniqueness check to refuse the duplicate. Accepting the refusal and leaving the write undone is a legitimate outcome — the in-person handler on Level 2 does exactly that rather than book twice.
The worked flow
Everything below assumes an access token from Get your credentials, and a clinic and practitioner already created — the quickstart covers those.
Create a patient
A patient names both the clinic it belongs to and the practitioner who created it. These records carry protected health information: PlaySpace asks for the minimum that lets a clinician recognise the right person in a list, and you should send no more than that.
POST /v1/partner/patients HTTP/1.1
Host: agentic-ps-dev.playspace.health
Authorization: Bearer <access token>
Idempotency-Key: 9d7e0c15-8f3a-4d6b-a2c9-5e1b4f8a7d20
Content-Type: application/json
{
"partner_clinic_id": "<clinic id>",
"created_by_partner_practitioner_id": "<practitioner id>",
"first_name": "A.",
"last_name": "R.",
"date_of_birth": "2018-04-11",
"timezone": "America/Toronto"
}
Patients are per-organisation. The same child may exist independently under another organisation and the two records never meet. Email is deliberately not unique, because families share an inbox.
Book an appointment
An appointment links one practitioner and one patient who belong to the same clinic. session_type decides what a session link will carry: virtual provisions a video session, in_person does not.
POST /v1/partner/appointments HTTP/1.1
Host: agentic-ps-dev.playspace.health
Authorization: Bearer <access token>
Idempotency-Key: 2b5c8e31-7a04-4f9d-b1e6-3c7d9a0f5b84
Content-Type: application/json
{
"partner_clinic_id": "<clinic id>",
"partner_practitioner_id": "<practitioner id>",
"partner_patient_id": "<patient id>",
"start_at": "2026-09-02T15:00:00.000Z",
"end_at": "2026-09-02T15:50:00.000Z",
"session_type": "virtual",
"timezone": "America/Toronto",
"notify_patient": false
}
There is no delete. Cancel by sending PATCH with "status": "cancelled", which also removes the appointment from the practitioner's connected calendars.
Telling the patient is opt-in. notify_patient defaults to false on a create and on an update alike, so a cancellation that sends only the status changes the book and emails nobody. Send the flag when the client should hear about it.
PATCH /v1/partner/appointments/<appointment id> HTTP/1.1
Host: agentic-ps-dev.playspace.health
Authorization: Bearer <access token>
Idempotency-Key: 7d2f4a10-9c63-4e8b-a5d7-1f0e6b3c9482
Content-Type: application/json
{
"status": "cancelled",
"notify_patient": true
}
The flag is not stored on the appointment, so it decides what this one request does and nothing else. A body carrying notify_patient alone is refused with 422: it is not a change on its own.
Hand out the session links
One call, two links, no interface work.
POST /v1/partner/appointments/<appointment id>/session-links HTTP/1.1
Host: agentic-ps-dev.playspace.health
Authorization: Bearer <access token>
Idempotency-Key: 8c0b1e77-2d44-4a19-9f31-6a5c4d2e7b90
{
"data": {
"clinician_video_url": "https://agentic-ps-dev.playspace.health/join/Zq7wX4vT1sR8nM5kJ2hG0f",
"patient_video_url": "https://agentic-ps-dev.playspace.health/join/aB3dE6fG9hJ2kL5mN8pQ1r",
"video_provider": "Whereby",
"waiting_room_enabled": true
}
}
There is one link per person per appointment, so this call returns the same two addresses every time — fetch them on demand rather than caching them, because what you would be caching is a credential. Each code redirects to that participant's session credential, which makes the short link itself a credential: hand the clinician link to the clinician and the patient link to the patient, and keep both out of your logs. Do not put one in an email whose links your provider rewrites for click tracking; a tracked rewrite is a plain-http redirect that link scanners pre-fetch, and the pre-fetch follows through to the credential.
Open the two as two people and you have the whole live experience — the playroom, the sandtray, the dollhouse, the whiteboard, the worksheets and the games — on PlaySpace's domain.
In-session worksheets are editable, not just drawable. From the activity shelf the clinician gets the same builder controls as the desktop editor: a slides rail with add and delete, reset view and zoom, and AI artwork. The patient seat follows every page the clinician adds or removes, live. Nothing is written to the record until the clinician chooses "Save to… → Client's profile", which files a copy with the added pages included; page ORDER is fixed for the duration of a session.
An in-person appointment answers a different shape. One link, not two: clinician_video_url alone, with patient_video_url as null and video_provider as "None". One clinician, one device, one room, no video call and no waiting room.
Generate a storybook from your backend
POST /v1/partner/storybooks starts a book without a frame — an overnight batch, or a template a clinic applies after every intake. It needs a delegated token: the owning practitioner comes from the token's claim, never from the body.
// playspace/partner-api.ts — server only. No frame, no SDK, one helper.
const PLAYSPACE_BASE_URL = 'https://agentic-ps-dev.playspace.health'
/** Your cached client-credentials exchanges. Pass a practitioner id for the delegated one. */
declare function getOrganisationToken(): Promise<string>
declare function getDelegatedPartnerToken(practitionerId: string): Promise<string>
export class PartnerApiError extends Error {
constructor(
readonly status: number,
readonly type: string,
readonly requestId: string | null
) {
super(`${status} ${type}`)
}
}
export async function partnerRequest<T>(
path: string,
options: {
accessToken: string
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
body?: unknown
/** Key this on something stable in your own system, not on a fresh value per retry. */
idempotencyKey?: string
}
): Promise<T> {
const method = options.method ?? 'GET'
const headers: Record<string, string> = { authorization: `Bearer ${options.accessToken}` }
if (options.body !== undefined) headers['content-type'] = 'application/json'
if (method !== 'GET') headers['idempotency-key'] = options.idempotencyKey ?? crypto.randomUUID()
const response = await fetch(`${PLAYSPACE_BASE_URL}${path}`, {
method,
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
})
if (!response.ok) {
const problem = (await response.json()) as { type: string }
throw new PartnerApiError(response.status, problem.type, response.headers.get('x-request-id'))
}
const envelope = (await response.json()) as { data: T }
return envelope.data
}
export interface Storybook {
id: string
status: 'generating' | 'ready' | 'failed'
}
/**
* Generate a storybook from your own backend, and wait for it.
*
* The idempotency key is what stops a retried job paying twice, so key it on
* the thing the book is for rather than on the attempt.
*/
export async function generateStorybook(
practitionerId: string,
prompt: string,
idempotencyKey: string
): Promise<Storybook> {
const accessToken = await getDelegatedPartnerToken(practitionerId)
let book = await partnerRequest<Storybook>('/v1/partner/storybooks', {
accessToken,
method: 'POST',
idempotencyKey,
body: {
prompt,
settings: {
target_age: '6-12',
number_of_pages: 'short',
lines_per_page: 'standard',
style: 'emotional',
image_style: 'watercolor',
},
},
})
// There is no change feed and no callback, so polling is the mechanism.
while (book.status === 'generating') {
await new Promise((resolve) => setTimeout(resolve, 10_000))
book = await partnerRequest<Storybook>(`/v1/partner/storybooks/${book.id}`, { accessToken })
}
return book
}
/** The organisation tier: everything a session runs against, with no clinician to act as. */
export async function createClinic(name: string, timezone: string): Promise<{ id: string }> {
const accessToken = await getOrganisationToken()
return partnerRequest<{ id: string }>('/v1/partner/clinics', {
accessToken,
method: 'POST',
body: { name, timezone },
})
}
The 201 carries the book in its generating state, with an empty page list and a Location header pointing at it. Illustration takes a minute or two. When it is ready, GET /v1/partner/storybooks/{id}/download returns the book as a PDF and GET /v1/partner/storybooks/{id}/pages returns the text with signed image links. Those links are short-lived: read them when you need them rather than caching them.
The practice must have storybook generation enabled. It is a per-clinic entitlement checked in the service rather than a switch in an interface, so a script meets exactly the 403 a person would.
Where to go next
Every operation, with its scope, is the endpoint summary, and the schemas are in the interactive reference.
What holds across every endpoint — envelopes, pagination, timestamps, soft deletes — is conventions.
Every refusal, by type is the error reference.
What is not on this surface, stated plainly, is on what you can build and content and generation.