API conventions
Everything that is true of every endpoint, stated once. Operation-specific behaviour lives in the specification.
Envelopes
Every success response has the same two keys.
{
"data": { "id": "pt_9Kd2mXwF", "external_id": "client_55130" },
"meta": {
"request_id": "req_8Kp2mQ",
"generated_at": "2026-09-14T10:22:03Z"
}
}
List responses add meta.pagination. data is an array.
{
"data": [ ... ],
"meta": {
"request_id": "req_8Kp2mQ",
"generated_at": "2026-09-14T10:22:03Z",
"pagination": { "cursor": null, "next_cursor": "cur_eyJ…", "limit": 25, "has_more": true }
}
}
204 No Content responses carry no body. Everything else does, including every error.
Identifiers
PlaySpace identifiers are prefixed and opaque. pt_9Kd2mXwF, sess_2Nk8pQvR7xLm, job_5Rp1wKz. The prefix tells you the type. Do not parse anything after it, do not assume a length, and do not assume ordering — they are not sequential and are not sortable.
| Prefix | Resource |
|---|---|
clin_ |
Clinic |
prac_ |
Practitioner |
pt_ |
Patient |
sess_ |
Session |
tok_ |
Session token |
job_ |
Job |
sb_ |
Storybook |
ws_ |
Worksheet |
frm_ |
Form |
exp_ |
Export |
art_ |
Artifact without a more specific type |
Your identifiers work everywhere ours do, prefixed ext:.
GET /v1/patients/pt_9Kd2mXwF
GET /v1/patients/ext:client_55130
This is why you never need to store a PlaySpace identifier. If adding a column is inconvenient, do not add one.
external_id is unique within your organisation per resource type. A clinic and a patient may share the string 12345 without colliding.
Upsert semantics
Write verbs on identity resources — clinics, practitioners, patients — are upserts keyed on external_id.
- First sight:
201 Created, with aLocationheader. - Thereafter:
200 OKwith the updated record.
Safe to call on every synchronisation pass. This is deliberate and it is why there is no reconciliation story in this documentation: there is nothing to reconcile.
PATCH /{id} exists too, for a partial update when you do not have the full record to hand. It is not an upsert and returns 404 for an unknown identifier.
Idempotency
POST, PATCH and DELETE require an Idempotency-Key header.
POST /v1/sessions
Idempotency-Key: 4f81c2a9-7b3e-4d21-9f88-0c5a1e3b7d64
Use a version-4 identifier, or any unique string up to 255 characters.
| Situation | Result |
|---|---|
| Same key, same body | The original response is replayed byte for byte |
| Same key, different body | 422 idempotency-key-mismatch |
| Same key, original still running | 409 idempotency-conflict — retry shortly |
| Same key, original failed | The retry proceeds. Failures are retriable |
Keys are retained 24 hours, scoped to your organisation. A missing key on a mutation is 422, not a warning.
Why it is required rather than optional. A network timeout on POST /sessions leaves you unable to tell whether a session was created. With a key, the retry is safe and the answer is definitive. The server SDK derives a key from the call's arguments automatically, so this is invisible unless you want to control it.
Pagination
Cursor-based. Offsets are not supported and will not be added — they produce duplicates and gaps when a list mutates mid-walk, which for a caseload list is not hypothetical.
GET /v1/patients?limit=50
GET /v1/patients?limit=50&cursor=cur_eyJ…
limit is 1 to 100, default 25. Follow meta.pagination.next_cursor until has_more is false.
A cursor is opaque, encodes its own sort order, and is stable across pages: a record inserted mid-walk will not shift a later page. Cursors expire after 24 hours; a stale one returns 422.
The server SDK exposes every list as an async iterator that pages transparently.
Rate limits
Two windows per organisation. Both are evaluated on every request and the more restrictive one wins.
X-RateLimit-Minute-Remaining: 573
X-RateLimit-Minute-Reset: 2026-09-14T10:23:00Z
X-RateLimit-Hour-Remaining: 19204
X-RateLimit-Hour-Reset: 2026-09-14T11:00:00Z
Default is 600 per minute and 20,000 per hour. Higher limits are available; ask.
A 429 rate-limited carries Retry-After in seconds. Honour it — retrying sooner extends the window rather than shortening it.
Two things do not consume budget. A request that fails authentication, so an unauthenticated caller cannot deplete your quota. And a change-feed poll that returns nothing, so a tight poll loop is cheap.
Rate limits and generation quota are different things. A rate limit is requests per unit time and clears in seconds. A quota is generations per period and clears at resets_at. Both surface as 429; the type distinguishes them.
Errors
RFC 9457 problem documents, served as application/problem+json.
{
"type": "https://api.playspace.health/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "One or more fields are invalid.",
"fields": [{ "path": "pages", "message": "must be between 4 and 24" }],
"request_id": "req_8Kp2mQ"
}
Branch on type, never on title or detail — those are prose written for a human reading a log and may be reworded. The full catalogue is in the error reference.
Status codes
| Code | Meaning |
|---|---|
| 200 | Success with a body |
| 201 | Created. Carries Location |
| 202 | Accepted. Asynchronous work has started; a job is in data |
| 204 | Success with no body |
| 400 | Malformed request — unparseable body, bad header |
| 401 | Not authenticated |
| 403 | Authenticated, not permitted |
| 404 | Absent, archived, or another organisation's. Deliberately indistinguishable |
| 409 | Conflict — idempotency in flight, or a genuine collision |
| 422 | Well-formed but not acceptable |
| 429 | Rate limited or quota exhausted |
| 500 | Our fault. Retry; if it persists, quote the request_id |
| 503 | Temporary. Honour Retry-After |
404 covers three cases on purpose. Absent, archived, and belonging to another organisation return the same response so that no caller can determine which — that difference is exactly the signal an enumeration attack needs.
Dates, times and money
Timestamps are RFC 3339 with an explicit offset, always in coordinated universal time on the wire.
{ "created_at": "2026-09-14T10:22:03Z" }
Dates without a time — a date of birth — are YYYY-MM-DD, unzoned.
Durations in payloads are integer milliseconds and suffixed _ms. Durations in request parameters are short strings: 15m, 60m, 24h.
No monetary amounts appear on this API. Generation is metered in counts, not currency, and billing is not a partner-facing surface.
Field conventions
Wire format is snake_case throughout. The TypeScript SDK exposes camelCase and converts at the boundary.
Nullable and absent are different. null means known-to-be-empty. An absent key on a PATCH means leave alone. Sending null explicitly clears a field; omitting it does not.
Unknown fields in a response are additive and safe. New fields ship without a version bump. Preserve what you do not recognise rather than stripping it — round-tripping an object through your own storage should not lose information.
Unknown fields in a request are rejected, with 422 naming them. This is stricter than ignoring them, and deliberately so: a misspelled field name that is silently dropped is a bug you find in production, whereas one that is rejected is a bug you find in development.
Enumerated values only grow. A new value may appear in a response at any time. Handle an unrecognised enumerated value as unknown rather than throwing, or a future addition breaks your integration.
Versioning
The API is versioned in the path. /v1 is stable.
A breaking change means /v2 alongside a /v1 that keeps working. Breaking means: removing a field or endpoint, changing a field's type, making an optional request field required, changing an existing enumerated value's meaning, or altering a status code for an unchanged condition.
Non-breaking, and shipped continuously without notice: new endpoints, new optional request fields, new response fields, new enumerated values, new problem types, new change-feed types.
Twelve months' notice before any deprecation, announced in the changelog and by direct email to every organisation calling the affected endpoint. A deprecated endpoint returns a Deprecation header and a Sunset header with the retirement date while it remains live.
Nothing on /v1 has ever been deprecated.
Protected health information
Two rules the platform enforces on itself, worth understanding because they shape the interface.
No person's name and no free-text search term travels in a URL. Not in a query string, not in a path segment, in either direction. Person search is POST /patients/search with a body and there is no GET equivalent. The reason is not squeamishness: a platform's own request logs record path and query for every request before anything at the application layer can act on them, so the only durable answer is to keep it out of the URL entirely. That is a routing decision, not a redaction one.
Identifiers, not values, in every observable channel. Change-feed payloads, error documents, download links and audit rows carry identifiers, statuses, counts, durations and enumerated values. Never a name, a date of birth, a page of story text, a form answer or a note body. If you need the content, fetch the object.
Please hold the same line on your side. A request_id in a support ticket is helpful; a client's name in one is a disclosure.