Core concepts
The ideas the rest of this site assumes. If you understand these, everything else is reference material you can look up when you need it.
The organisation is the tenancy boundary
Your credentials are issued to one partner organisation, and every request is scoped to it. You never send an organisation identifier: it is read from the token.
The consequence is that another organisation's data is not merely refused, it is unaddressable. An identifier belonging to someone else behaves exactly like an identifier that never existed, and both answer 404. There is no configuration, no scope and no parameter that widens the view past your own organisation.
A patient created by your organisation is yours alone. The same child may exist independently under another organisation, and the two records never meet.
The clinic is the partition inside it
Everything else hangs off a clinic. A practitioner belongs to exactly one clinic; a patient names the clinic it belongs to and the practitioner who created it; an appointment links one practitioner and one patient who belong to the same clinic.
That is the whole hierarchy. There is nothing above the clinic — no practice, no region, no group — and nothing beside the patient, because caregiver and dependent links are internal to PlaySpace and have no partner surface yet.
Deleting is soft and constrained. A clinic that still holds active practitioners, patients or appointments answers 409 with problem type clinic-not-empty and the counts of what is blocking it, so you clear or reassign before you retire.
Two credentials, and the difference matters
The organisation token is an OAuth2 client-credentials access token issued by Auth0 for your organisation. It sees every clinic, practitioner, patient and appointment your organisation owns. It lives on your server and nowhere else.
The delegated token is the same request with a practitioner_id parameter naming one of your practitioners. The authorisation server confirms that practitioner belongs to you and binds it into the token, so the token can only ever touch that clinician's data. It is the minimum-necessary credential for a per-clinician integration, and for several surfaces it is the only credential that works at all.
A delegated token narrows both reads and writes:
- Patient and appointment lists return only that clinician's roster and calendar. The practitioner and clinic directories stay organisation-wide on either token.
- Writes must be self-attributed. Creating an appointment requires
partner_practitioner_idto equal the token's clinician, and creating a patient requirescreated_by_partner_practitioner_idto equal it. Anything else is a422. - Organisation-level writes are refused. Any practitioner or clinic create, update or delete, and deleting a patient, need an organisation token and answer
403on a delegated one. - An identifier outside the delegated scope answers
404, indistinguishable from one that never existed, so the response cannot be used to probe what lies beyond the clinician's data.
Anything a clinician personally owns requires delegation. Playrooms, toolkits, published forms, storybooks, worksheets, saved game scenes, game sessions and embed tokens all belong to one practitioner. An organisation-wide token has nobody to act as, and is refused rather than guessing.
You stay in control of each clinician's access. Set a practitioner's status to disabled and every delegated token for them is rejected with 403 and the practitioner-disabled problem type until you set it back to active. Disabling is reversible and keeps every record, which makes it the right tool for suspension; DELETE is for off-boarding. For a clinic that has upgraded to PlaySpace, disabled also suspends the practitioner's direct PlaySpace login until you set them back to active. One exception: disabling or deleting the owner of an upgraded clinic answers 409 with problem type practitioner-owner-protected, because that practitioner is the clinic's owner. Reassign owner to someone else first.
Delegated tokens multiply issuance, one per clinician rather than one per organisation, so cache one per clinician and reuse it until shortly before it expires.
Scopes are one per resource and verb
The scope claim on your token is a space-separated list drawn from a flat vocabulary: clinics, practitioners, patients and appointments, each with :read, :write and :delete.
| Scope | Grants |
|---|---|
clinics:read / clinics:write / clinics:delete |
Read, create and update, soft-delete clinics |
practitioners:read / practitioners:write / practitioners:delete |
The same for practitioners |
patients:read / patients:write / patients:delete |
The same for patients |
appointments:read / appointments:write |
Read appointments and their session links; create and update them |
Scopes are fixed on the credential when it is issued and can be changed by asking. A call outside your granted set returns 403 carrying both required_scopes and granted_scopes in full, so the missing entry is visible in the error rather than needing a support conversation.
The per-clinician surfaces carry no organisation scope at all. They are authorised by the delegation claim itself, which is why the scope column is blank against them in the endpoint summary.
The embed token is a deliberately weaker credential
Your Partner API token carries your organisation's whole scope set and is long-lived. An iframe URL is exposed to browser history, Referer headers, and anything that logs a URL. Those two facts cannot meet.
So the powerful credential stays on your server and you exchange it for a narrow one:
POST /v1/partner/embed-tokens HTTP/1.1
Host: agentic-ps.playspace.health
Authorization: Bearer <delegated access token>
Content-Type: application/json
{
"capabilities": ["form:read", "form:submit"],
"origins": ["https://app.yourclinic.com"],
"patient_id": "3f1b0e2a-9c4d-4f4b-8a1e-2d6c5b7a9e01",
"ttl_seconds": 900
}
What reaches the browser can do only what you asked for, for as long as you asked, framed only by the origins you named. Four things are worth understanding about that exchange.
The acting practitioner comes from your token, never from the request. There is no practitioner field in the body, so a caller cannot mint a token acting as someone it was not delegated to. The response echoes practitioner_id back so you can record attribution against your own records.
The patient, by contrast, is named here. One clinician sees many patients, so there is nothing to infer. A form submission, a form PlaySpace emails, and a storybook or worksheet PlaySpace emails are each about one named client, which is why form:submit, form:send, storybook:share or worksheet:share without a patient_id is a 422 rather than a token that would record answers attached to nobody or email a link to no one. The patient is checked at mint time against both your organisation and that clinician's roster, so a patient outside either is a 403 here rather than a token that fails later.
Capabilities are the narrowest set the screen needs, each checked per request. A read-only viewer should not carry a create capability. The vocabulary, by the thing it acts on:
| Acts on | Capabilities |
|---|---|
| The framed workspace | shell:read |
| Storybooks | storybook:read, storybook:create, storybook:write, storybook:delete, storybook:share |
| Worksheets | worksheet:read, worksheet:create, worksheet:write, worksheet:delete, worksheet:generate, worksheet:share |
| Forms | form:read, form:create, form:write, form:delete, form:submit, form:compose, form:send |
| Playrooms | playroom:read, playroom:write |
| Clinician data | note:read, note:write, client:read, appointment:read, appointment:write |
| A live game session | games:play, session:launch |
| The clinician's whole record | data:export |
note:write and appointment:write are never implied by their read halves: a seat bought to look at a calendar or a note has not thereby bought one that books into a clinician's book or signs a clinical record. Neither creates or deletes anything — note:write edits, signs and unlocks a note the acting clinician already owns. The three form authoring verbs are separate for the same reason the worksheet ones are: form:create adds a template, form:write rewrites one patients may already have answered, and form:delete retires one from the library — a soft delete that also takes it off every room shelf and leaves every recorded response readable. Neither write verb can promote a draft to published, and neither shelves. form:submit and form:send are the two that bind a patient. shell:read frames the product and grants no data of its own: mint it alongside the capability for each area you want reachable. games:play is minted by POST /v1/partner/game-sessions rather than by the embed-token mint.
Which of these a given screen actually wants is on that screen's own reference page: storybooks, forms, worksheets, games and the whole workspace.
Origins become the frame's frame-ancestors directive, and are validated when the token is minted rather than when the frame fails to load. A wildcard is accepted only as a whole leading label. This turns the most frustrating failure in embedded software, a silently blank rectangle, into a message naming the offending value.
Tokens default to fifteen minutes and are capped at one hour. Do not stretch the lifetime to cover a session; pass fetchToken to the embed components and let them re-mint into the running frame.
Every mutation carries an idempotency key
POST, PATCH and DELETE all require an Idempotency-Key header: an opaque string of your choosing, unique to one logical operation.
| Situation | Result |
|---|---|
| Same key, same payload, within 24 hours | The original response is replayed and the operation is not performed twice |
| Same key, different payload | 422 with problem type idempotency-key-mismatch |
| Same key while the original is still in flight | 409 with problem type idempotency-conflict. Retry once it settles |
Keys live 24 hours, after which the same string starts a fresh operation. A missing key on a mutation is a 422, not a warning.
The reason it is required rather than optional: a network timeout on a create leaves you unable to tell whether the record exists. With a key, the retry is safe and the answer is definitive.
Lists are cursor-paginated
Pass limit to set the page size. It defaults to 25 and is capped at 100. To fetch the next page, pass the cursor value from meta.pagination.next_cursor; next_cursor is null when the list is fully consumed.
GET /v1/partner/patients?limit=50 HTTP/1.1
GET /v1/partner/patients?limit=50&cursor=<next_cursor> HTTP/1.1
Treat a cursor as opaque and never construct one. Offsets are not supported and will not be added: they produce duplicates and gaps when a list mutates mid-walk, which for a caseload is not hypothetical.
Filters are applied before pagination, so they hold consistently across pages. Every filter on this API is an identifier, an enumerated value or a timestamp — there is no free-text search parameter anywhere on it, which is deliberate, because a platform's own request logs record the query string of every request before anything at the application layer can act on it.
Rate limits are two windows
Limits apply per organisation across a per-minute and a per-hour window, and the more restrictive one governs. Every response carries the remaining count and the refill time for both:
X-RateLimit-Minute-Remaining, X-RateLimit-Minute-Reset
X-RateLimit-Hour-Remaining, X-RateLimit-Hour-Reset
Exceeding either returns 429 with Retry-After. Honour it. The single most common way to hit a limit that has nothing to do with your real traffic is minting a fresh access token per request instead of caching one.
Every failure is a problem document
Non-2xx responses are RFC 9457 problem documents served as application/problem+json, carrying type, title, status and detail, plus extension members specific to the problem — required_scopes on a 403, dependent counts on a 409.
{
"type": "https://api.playspace.health/problems/forbidden",
"title": "Insufficient scope",
"status": 403,
"detail": "The presented token does not grant every scope this endpoint requires.",
"required_scopes": ["patients:write"],
"granted_scopes": ["patients:read", "appointments:read"]
}
Branch on type, never on title or detail, which are prose for a human reading a log and may be reworded. The problem-type identifiers are stable and deliberately not dereferenceable. The full catalogue is in the error reference.
Every response, success or failure, carries an X-Request-Id header. Log it, and quote it when you contact us: it addresses one request in our audit history and turns a day of diagnosis into a few minutes.
Deletion is soft, and 404 is one answer to three questions
Deletes de-list rather than destroy. A deleted resource answers 404 on a read and is omitted from lists, unless the list endpoint offers include_deleted=true and you ask for it.
A nonexistent identifier, another organisation's identifier, and a deleted identifier all return one identical 404, and its detail never echoes the identifier you sent. That is the point: distinguishing them is exactly the signal an enumeration attack needs. Repeating a DELETE returns that same 404, and only an idempotency-key replay of the original observes the prior success.
Several resources have no delete at all. Retire an appointment by setting status to cancelled, and a playroom or toolkit by setting published to false; anything already referencing it keeps working.
Identifiers and payload shape
Identifiers are version-4 UUIDs assigned by PlaySpace. There is no way to address a record by your own identifier yet, so store the identifier each create returns.
Every success response is an envelope of data and meta, where meta carries the request identifier and, on a list, meta.pagination. Wire format is snake_case throughout. Timestamps are ISO-8601 with an explicit offset; a date of birth is YYYY-MM-DD and unzoned.
The full set of conventions that hold across every endpoint is in API conventions.