API conventions

Everything that is true of every endpoint, stated once. Operation-specific behaviour lives in the endpoint summary and in the interactive reference.


Envelopes

Every success response with a body has the same two keys.

{
  "data": { "id": "d1c2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f" },
  "meta": {
    "request_id": "8f1c9b40-6c1e-4a2f-9a44-0d5e2b7c3a11",
    "generated_at": "2026-05-28T15:30:00.000Z"
  }
}

List responses add meta.pagination and data is an array.

{
  "data": [],
  "meta": {
    "request_id": "8f1c9b40-6c1e-4a2f-9a44-0d5e2b7c3a11",
    "generated_at": "2026-05-28T15:30:00.000Z",
    "pagination": { "next_cursor": null, "limit": 25, "has_more": false }
  }
}

204 No Content carries no body. The asset endpoints answer with bytes rather than an envelope — a storybook or worksheet download, a page image, a cover, a game-save thumbnail. Everything else has a body, including every error.

request_id is also returned as the X-Request-Id header on every response, error responses included. Every response is Cache-Control: no-store.


Identifiers

PlaySpace identifiers are UUIDs. They are assigned by the platform, opaque, and not sortable; do not parse them, do not derive meaning from them, and do not generate one yourself for a resource you are creating.

Store the identifier a create returns. There is no external-identifier addressing on this API: your own key for a person cannot be substituted for the PlaySpace one in a path, so the mapping between your records and ours lives in your database.

A create is a create. Repeating one produces a second record unless the Idempotency-Key matches an earlier call, which is the mechanism below and the only replay-safety the API offers.


Idempotency

POST, PATCH and DELETE require an Idempotency-Key header. A missing key is a 422, not a warning.

POST /v1/partner/patients
Idempotency-Key: 4f81c2a9-7b3e-4d21-9f88-0c5a1e3b7d64

Use a version-4 identifier, or any opaque string unique to one logical operation.

Situation Result
Same key, same payload, within 24 hours The original response is replayed verbatim; the operation is not performed twice
Same key, different payload 422, problem type idempotency-key-mismatch
Same key, original still in flight 409, problem type idempotency-conflict — retry once the original settles
Same key, original failed The retry proceeds. Failures are retriable

Keys are scoped to your organisation and live 24 hours; after that the same key starts a fresh operation. A crashed original is treated as retriable after sixty seconds, so an idempotency-conflict clears itself.

Why 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.

One case where a fresh key matters more than a stable one. Anything that mints a credential — an embed token, a set of session links — replays the stored response for a repeated key, which hands back a token that is already part-expired. Use a new key per mint. @playspace-health/embed generates one per call for exactly this reason.


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 is not hypothetical.

GET /v1/partner/patients?limit=50
GET /v1/partner/patients?limit=50&cursor=<next_cursor>

limit is 1 to 100 and defaults to 25. Follow meta.pagination.next_cursor until it is null, or watch has_more. Treat a cursor as opaque and never construct one by hand. Any filters you supply are applied before pagination, so they hold consistently across pages.

@playspace-health/embed returns a page at a time — { data, nextCursor, hasMore } — and you pass nextCursor back on the following call. It does not walk the list for you.


Rate limits

Two windows per organisation, per minute and per hour. Both are evaluated on every request and the more restrictive one wins.

X-RateLimit-Minute-Remaining: 573
X-RateLimit-Minute-Reset: 2026-05-28T15:31:00.000Z
X-RateLimit-Hour-Remaining: 19204
X-RateLimit-Hour-Reset: 2026-05-28T16:00:00.000Z

The shipped default is 600 per minute and 20,000 per hour. Higher limits are available; ask.

Exceeding either window returns 429 with problem type rate-limited and a Retry-After in seconds. Honour it.

A request that fails authentication does not consume budget, so an unauthenticated caller cannot deplete your quota.


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."
}

type, title and status are always present; detail and problem-specific extension fields appear where they help. 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
204 Success with no body
401 Not authenticated — missing, malformed or expired token, or a revoked organisation
403 Authenticated, not permitted — scope, suspension, delegation, or an organisation-level write on a delegated token
404 Absent, deleted, another organisation's, or outside a delegated token's scope. Deliberately indistinguishable
409 Conflict — an idempotency claim in flight, or a genuine collision
422 Well-formed but not acceptable — validation, a missing idempotency key, a payload mismatch
429 Rate limited
500 Our fault. Retry; if it persists, quote the X-Request-Id

404 covers four cases on purpose. Absent, deleted, belonging to another organisation, and outside the acting clinician's scope all return the same response, and its detail never echoes the identifier you supplied. That difference is exactly the signal an enumeration attack needs.


Deletion

Deletes are soft. A deleted resource is de-listed rather than destroyed: it returns 404 on a get and is omitted from lists, unless the list endpoint supports include_deleted=true and you set it, in which case it comes back carrying deleted_at.

Repeating a DELETE on the same identifier returns that same 404. Only an idempotency replay of the original delete observes the prior success.

Deleting a clinic that still holds practitioners, patients or appointments is refused with 409 and problem type clinic-not-empty, carrying the dependent counts so you can act on the answer without a second round-trip.


Dates and times

Timestamps are ISO 8601 with an explicit offset, always in coordinated universal time on the wire.

{ "created_at": "2026-05-28T15:30:00.000Z" }

Dates without a time — a date of birth — are YYYY-MM-DD, unzoned.

Durations in request parameters are integer seconds and suffixed _seconds, as in ttl_seconds on an embed-token mint.

No monetary amounts appear on this API. Billing is not a partner-facing surface.


Field conventions

Wire format is snake_case throughout, in both directions. @playspace-health/embed returns the same field names unchanged; only its pagination wrapper is renamed, to nextCursor and hasMore.

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 nullable field; omitting it does not.

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. Sending firstName where the API expects first_name fails immediately.

Unknown fields in a response are additive and safe. Preserve what you do not recognise rather than stripping it — round-tripping an object through your own storage should not lose information.

Enumerated values only grow. A new value may appear in a response at any time, and one place says so explicitly: the content type on a playroom or toolkit shelf is closed inbound — an unknown value is a 422 — and open outbound, so a client must ignore a content_type it does not recognise rather than throwing.


Versioning

The API is versioned in the path. Everything lives under /v1/partner.

The published specification is at version 0.1.0. There has been no public release series and nothing has been deprecated, so there is no deprecation policy to quote yet — no notice period has been committed to, and this page will not pretend otherwise. What we can tell you is how the surface has been growing: new endpoints, new optional request fields, new response fields, new enumerated values and new problem types are added within /v1 without a version bump, which is why the two response rules above matter.

If you are integrating against a contract you need held stable for a fixed period, raise it with PlaySpace and it becomes a commitment in your agreement rather than an assumption from a documentation page.


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. Filters on the list endpoints are identifiers and flags, never names. 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. Error documents, audit rows and rate-limit headers carry identifiers, statuses, counts 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.

Every authenticated request writes exactly one row to a tamper-evident audit log, hash-chained to the previous row in your organisation's chain, holding identifiers only.

Please hold the same line on your side. An X-Request-Id in a support ticket is helpful; a client's name in one is a disclosure.