Guide: Using the API from other languages
@playspace-health/embed is TypeScript, and it is the only package PlaySpace publishes. Nothing about the platform requires it.
An integration has two halves, and neither is TypeScript-shaped:
- The server half is plain HTTPS and JSON, described by an OpenAPI document the platform generates from the same validation code that serves every request. A client in Python, PHP, Java, C# or anything else can be generated from that document rather than written.
- The browser half is an
<iframe>pointed at a PlaySpace URL. The frame reads its own credential out of that URL, so a page rendered by Django, Rails, Laravel or Spring can mount a PlaySpace surface with no build step and no script.
This page is the whole non-TypeScript path: generate a client, mint a token, frame a surface, and — if you want a session that outlives one token — the small amount of plain JavaScript that does what the SDK would have done.
Generate a client
The document is published at /openapi.json on this site. Point any OpenAPI generator at it. The two below were run against the live document while writing this guide and produced complete clients with no edits:
# Python
uvx openapi-python-client generate --url https://docs.playspace.health/openapi.json
# PHP (Java 11+ required by the generator itself)
npx @openapitools/openapi-generator-cli generate \
-g php -i https://docs.playspace.health/openapi.json -o playspace-php
openapi-generator covers Java (-g java), C# (-g csharp) and most other languages with the same command. Every resource group comes through, including minting embed tokens, and the required Idempotency-Key header appears as a typed parameter on every write.
What generators leave out: responses whose body is a file rather than JSON. Storybook covers, game thumbnails and PDF downloads are declared with their real media types, and most generators skip them. Fetch those with a plain GET and the bearer token; nothing else about them is special.
What no generator gives you
- The token request. Access tokens come from the Auth0 token endpoint, not from this API, so they are outside the document. It is one
POSTwith your client credentials, pluspractitioner_idwhen you need a delegated token. The exact request is in the quickstart, with a copy-pasteable pair ofcurlcalls in authentication. - Idempotency keys. Every
POST,PATCHandDELETEneeds a caller-chosenIdempotency-Key. Generate a UUID per logical operation and reuse it on retries. The retry contract is in conventions.
The server half: mint an embed token
One call, POST /v1/partner/embed-tokens, which your generated client already has. Send it a delegated access token — the acting practitioner is read from the token's own claim, so an organisation-wide token is refused — and it answers with a short-lived embed token, its expires_at, and the practitioner it was minted for.
POST /v1/partner/embed-tokens HTTP/1.1
Host: agentic-ps-dev.playspace.health
Authorization: Bearer <delegated access token>
Idempotency-Key: 4f81c2a9-7b3e-4d21-9f88-0c5a1e3b7d64
Content-Type: application/json
{
"capabilities": ["shell:read", "appointment:read", "client:read", "note:read"],
"origins": ["https://app.yourclinic.com"],
"ttl_seconds": 900
}
Three things decide whether the frame will load, and all three are settled here rather than in the browser:
capabilities— the narrowest set the screen needs. The vocabulary is in core concepts, and which set each surface wants is on that surface's own reference page: storybooks, forms, worksheets, games, the whole workspace. The SDK cannot widen them and neither can the iframe.origins— the scheme and host of the page that will hold the frame, no trailing slash and no path. This becomes theframe-ancestorsdirective on the embed document, so a page served from any other origin gets a blank rectangle and a browser console message rather than an error you can catch.http://localhost:3000is an ordinary value here and is accepted, so you can build against the frame before you have a deployed host.ttl_seconds— leave it alone. The default is fifteen minutes and the maximum is one hour. A long session is carried by re-minting, not by a longer token; see "Renewing a token without the SDK" below.
Everything above is language-neutral. The rest of this page is the browser.
The browser half: one iframe
The frame takes its token from the query string, so the whole mount is a URL your server renders into an attribute:
https://agentic-ps.playspace.health/embed/shell?token=EMBED_TOKEN
Every surface, and the URL that frames it
The SDK names these mode; without the SDK the mode IS the address. What each surface needs on its token, what it emits and what will bite you are on its own reference page, linked in the last column — this table is only the URL each one lives at.
| Surface | Path below your base URL | Identifier it needs | Reference |
|---|---|---|---|
| The whole workspace | /embed/shell |
none | The whole workspace |
| The whole workspace, opened on one area | /embed/shell/{area} |
an area segment — the full list is the area table on the whole workspace page | The whole workspace |
| Storybook authoring | /embed/storybooks |
none | Storybooks |
| One storybook, read-only | /embed/storybooks/{storybookId} |
storybook id | Storybooks |
| The clinician's forms, to pick from | /embed/forms |
none, and no template capability on the token | Forms |
| The clinician's whole forms library | /embed/forms |
none — the same path, reached by putting form:create, form:write, form:delete or form:compose on the token |
Forms |
| One form, answerable | /embed/forms/{formId} |
form id, and a patient_id on the token |
Forms |
| The form builder | /embed/forms/new |
none | Forms |
| Which rooms a form appears in | /embed/forms/{formId}/shelf |
form id | Forms |
| One worksheet, read-only | /embed/worksheets/{worksheetId} |
worksheet id | Worksheets |
| One worksheet in the editor | /embed/worksheets/{worksheetId}/edit |
worksheet id | Worksheets |
| A PDF becomes a worksheet | /embed/worksheets/upload |
none | Worksheets |
| A live sandtray or dollhouse session | /embed/games/{gameType} |
sandtray or dollhouse, matching the token's claim |
Games |
Percent-encode any identifier you interpolate. An area segment the workspace does not recognise renders a page inside the frame saying so, rather than a 404 — a blank frame and a broken embed are indistinguishable to the person looking at it, so the workspace never answers a bad area with nothing.
The attributes that matter
<iframe
src="https://agentic-ps.playspace.health/embed/shell?token=EMBED_TOKEN"
title="PlaySpace"
allow="camera; microphone; fullscreen; display-capture; autoplay; picture-in-picture"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
style="width: 100%; height: 820px; border: 0"
></iframe>
The allow list is not optional for anything that runs a session. The Permissions-Policy specification denies every feature in it to a cross-origin frame that was not granted it, and the failure is a NotAllowedError raised before any permission prompt appears — which looks exactly like your user clicking "Block". The list above is the union a live session needs: camera and microphone for the video pane, display-capture for screen share, fullscreen and picture-in-picture for the video controls, autoplay so the remote stream starts without a gesture. allowfullscreen is the legacy attribute older Safari honours instead of the fullscreen token.
A framed session is more than one document deep — your page holds the workspace, and the workspace opens the session inside its own content pane — and a feature dropped at your level is denied at every level below it. If your page or a CDN in front of it sends its own Permissions-Policy header, that header wins over this attribute and must name the PlaySpace origin for each feature; the exact header is under "Camera, microphone and screen share" on the SDK page.
Give the frame a height, and keep the src stable. PlaySpace never posts its height back to you — there is no resize negotiation on this channel — so the size is yours to decide and the frame scrolls internally below about 900px. Changing the src after the page has loaded remounts the frame and throws away whatever was in progress inside it, a half-answered form included, so re-render the page around the frame rather than through it.
What a bare frame gives up
Three things, all of which live on the host page rather than in the frame:
- Sessions longer than one token. An embed token lives for at most an hour, and the frame cannot renew itself. A bare iframe goes cold when its token expires — every request behind it starts answering
401while the page carries on showing whatever it last drew. Fine for a storybook a clinician reads in five minutes; not fine for the workspace, which a clinician sits in for a whole session. - Knowing what happened inside. The frame reports events — a storybook created, a form submitted, a clinician asking you to launch a session — by posting messages to your page. Nothing is listening unless you listen.
- Ending a session early. Removing the iframe does not revoke the token; it remains a live credential until it expires.
Each has a plain answer below. If you would rather not write any of them, skip to "Or load the SDK from a CDN" — the SDK does all three and needs no build step.
Renewing a token without the SDK
Mint a fresh token from your server before the current one expires, then post it into the running frame. The document is not reloaded, the clinician notices nothing, and every subsequent request the frame makes uses the new credential.
The frame accepts a message only when all of these hold, so send exactly this shape:
- it comes from the frame's own parent window — your page, not a sibling frame;
sourceis the stringplayspace-embed-host;typeistoken.update;tokenis a string with three dot-separated segments;- the new token names the same practitioner, patient, seat and game session as the one the frame is running under. A re-mint that changes any of them is not a refresh, it is a re-scope of a session in progress, and it is dropped.
Anything else on the channel is ignored silently, so a message that does not land will not tell you why. Send it with the PlaySpace origin as the target rather than '*'.
<iframe id="playspace" src="https://agentic-ps.playspace.health/embed/shell?token=EMBED_TOKEN"
allow="camera; microphone; fullscreen; display-capture; autoplay; picture-in-picture"
allowfullscreen referrerpolicy="strict-origin-when-cross-origin"
style="width:100%;height:820px;border:0"></iframe>
<script>
var PLAYSPACE_ORIGIN = 'https://agentic-ps.playspace.health'
var frame = document.getElementById('playspace')
// Your own route. It calls POST /v1/partner/embed-tokens with a delegated
// token and returns the embed token string. Your credential never leaves it.
function mintToken() {
return fetch('/playspace/embed-token', { method: 'POST' }).then(function (r) {
if (!r.ok) throw new Error('token route answered ' + r.status)
return r.text()
})
}
// Re-mint a minute before expiry, so a slow mint still lands in time.
function scheduleRefresh(token) {
var payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
var delay = Math.max(payload.exp * 1000 - Date.now() - 60000, 5000)
setTimeout(function () {
mintToken().then(function (next) {
frame.contentWindow.postMessage(
{ source: 'playspace-embed-host', type: 'token.update', token: next },
PLAYSPACE_ORIGIN
)
scheduleRefresh(next)
})
}, delay)
}
scheduleRefresh(EMBED_TOKEN)
</script>
Two details worth copying rather than re-deriving. Re-mint before expiry, not after: once the token is dead the frame is already failing. And re-send the current token whenever the frame reports ready, because a reload replays the original URL — including the original, possibly expired, token.
Listening for events without the SDK
The frame posts each event to the origins named on its token. Every message carries the same envelope:
{ "source": "playspace-embed", "version": 1, "type": "form.submitted", "payload": { "formId": "…", "submissionId": "…", "status": "completed" } }
Verify two things before you trust one, and verify both — the origin check alone is not enough, because any other frame on your page from the same origin could otherwise impersonate this one, and framing a storybook beside a form is an ordinary thing to do:
window.addEventListener('message', function (event) {
if (event.origin !== PLAYSPACE_ORIGIN) return
if (event.source !== frame.contentWindow) return
if (!event.data || event.data.source !== 'playspace-embed') return
switch (event.data.type) {
case 'ready':
// A reload replays the original URL token — push the current one.
break
case 'error':
// Branch on event.data.payload.code, never on its message text.
break
}
})
The full list of event types and their payloads is under "Events" on the SDK page, and the error codes are under "Errors" on the same page. No event ever carries clinical content — ids, counts and statuses only.
Three things a host writing its own listener should know:
- The workspace asks you to do things it cannot do itself. Launching a video session, opening or emailing a form for one patient: those arrive as
session.launch_requested,form.fill_requestedandform.send_requested, and each names ids and nothing else. A host that ignores them leaves controls inside the frame looking broken, because the clinician pressed something and nothing happened. (session.in_person_requestedis deprecated and never sent — the workspace starts that session itself.) - The
embed.*error codes are the SDK's, not the frame's. They describe what went wrong on the host page — a mint that failed, a refresh that gave up — so a bare-iframe host raises them itself or not at all. The one exception isembed.session_expired, which the frame does emit, from having actually been refused rather than from a clock. - The workspace signs itself out after a period of inactivity, and says nothing on this channel. The frame replaces itself with a signed-out panel; no event is posted. If your application tracks whether the embed is live, do not infer it from silence here.
Ending a session
Revoking is one HTTP call, authorised by the embed token itself rather than by your partner credential, so your server can make it as easily as your page can:
POST /api/embed/session/logout HTTP/1.1
Host: agentic-ps.playspace.health
Authorization: Bearer <embed token>
No request body, and a 204 with no response body on success. The call is idempotent: revoking a token that is already revoked answers 204 again, and a 401 means the token could not be verified at all — expired, malformed, or not one of ours. Treat either as done.
Revoke every token the frame has held, not just the current one: each re-mint issued a fresh credential that stays live until its own expiry. Then remove the iframe.
Or load the SDK from a CDN
The browser half of @playspace-health/embed is dependency-free ES modules with no framework requirement, so a server-rendered application in any language can load it straight from a registry mirror. It does the refresh, the verification, the events and the logout above:
<div id="playspace-workspace" style="height: 820px"></div>
<script type="module">
import { createShellEmbed } from 'https://cdn.jsdelivr.net/npm/@playspace-health/embed@0.1.1/dist/index.js'
createShellEmbed(document.getElementById('playspace-workspace'), {
baseUrl: 'https://agentic-ps.playspace.health',
fetchToken: () => fetch('/playspace/embed-token', { method: 'POST' }).then((r) => r.text()),
onEvent: (event) => console.log('playspace', event.type),
})
</script>
Your /playspace/embed-token route is the server half above: it calls POST /v1/partner/embed-tokens with your delegated token and returns the embed token string. Pin the version in the URL — a specifier without one follows the newest release, and an upgrade should be your decision. The package page documents the full API.
Where this stops
The generated clients above are yours to build and maintain; PlaySpace publishes @playspace-health/embed only. If a maintained package for your language would change whether you build on PlaySpace, tell your PlaySpace contact — that demand is what decides which packages come next.