Guide: Keep your caseload in step
There is no synchronisation job in this integration, and that is the point of the design. This guide exists to explain why, and to show the small amount of work that does remain.
What you are not building
No nightly reconciliation. No diffing, no conflict resolution, no "which side wins", no repair script for the morning after a failed run.
The reason is that PlaySpace is not a second copy of your database. It holds a pointer to a person plus the artifacts it produced for them. There is no field we hold that you also hold and that could disagree, except a name — and a name that disagrees is fixed by writing yours again.
What you are building
Call upsert wherever a record changes in your system. That is the whole of it.
// wherever a staff record is created or edited
async function onStaffChanged(staff) {
await playspace.practitioners.upsert({
externalId: staff.id,
clinic: { externalId: staff.locationId },
firstName: staff.firstName,
lastName: staff.lastName,
email: staff.email,
country: staff.country,
role: staff.isAdmin ? 'administrator' : 'member',
})
}
// wherever a client record is created or edited
async function onClientChanged(client) {
await playspace.patients.upsert({
externalId: client.id,
clinic: { externalId: client.locationId },
firstName: client.firstName,
lastName: client.lastName,
dateOfBirth: client.dateOfBirth,
practitioners: [{ externalId: client.primaryClinicianId }],
})
}
upsert creates on first sight and updates thereafter, keyed on externalId. Calling it a hundred times with unchanged data is a hundred no-op updates, not a hundred duplicate records.
You never store our identifier
await playspace.patients.get({ externalId: 'client_55130' })
await playspace.sessions.create({ clinician: { externalId: 'staff_8842' }, ... })
await playspace.artifacts.list({ subject: { externalId: 'client_55130' } })
Every method accepts your identifier in place of ours, and every object echoes yours back. If adding a playspace_id column is inconvenient, do not add one.
The one exception worth caching is session.id, because you create a session and then mint tokens against it repeatedly. Store it on your appointment row.
Backfilling an existing caseload
You do not have to. Upsert lazily, at the point of first use:
async function ensureMapped(appointment) {
await playspace.practitioners.upsert({ externalId: appointment.clinicianId, ... })
await playspace.patients.upsert({ externalId: appointment.clientId, ... })
}
The first PlaySpace session for a client creates the mapping; the ten thousand clients who never use PlaySpace are never sent.
This is the recommended approach, and not only for effort. Sending a whole caseload to a third party in order to make a feature available to the fraction who will use it is a disclosure you did not need to make.
If you do want a bulk backfill, upserts are idempotent, so a failed run is safe to repeat. Move at a few hundred per minute and watch the rate-limit headers.
Caregivers, which are not optional for children
await playspace.caregivers.link({
patient: { externalId: child.id },
caregiver: { externalId: guardian.id },
relationship: 'parent',
receivesSessionLinks: true,
canRequestExport: true,
})
The caregiver must exist as a patient record in their own right first — upsert them like anyone else.
This link decides three things: who receives a session link, who may join alongside a child, and who may later request an export. canRequestExport is checked when an export is created, so setting it correctly now avoids a support conversation later.
A caregiver with three children on the caseload is three links, one per child, each with its own flags.
Departures and discharges
await playspace.practitioners.archive({ externalId: staff.id })
await playspace.patients.archive({ externalId: client.id })
Archiving is soft and immediate. The record becomes unreadable to every caller, including exports. It is not destroyed, so a discharge that turns out to be premature is reversible by support.
Archiving does not delete artifacts. A storybook made for a discharged client remains in the clinic's record and remains exportable by a clinician or administrator — which is usually what a discharge requires, not the opposite.
A clinic cannot be archived while it has active practitioners or patients.
Keeping the mapping honest
Two cheap checks worth running monthly.
Count drift. If your caseload has 4,000 active clients and PlaySpace has 4,180, you have archived people on your side without archiving them here.
const yours = await yourDb.countActiveClients(clinicId)
const ours = await playspace.patients.list({ clinic: { externalId: clinicId } }).all()
if (ours.length !== yours) log.warn('client count drift', { yours, ours: ours.length })
Missing country on practitioners. country is nullable and some surfaces are regionally gated and fail closed without it, which presents to a clinician as a feature that mysteriously does not appear.
for await (const p of playspace.practitioners.list()) {
if (!p.country) log.warn('practitioner missing country', { externalId: p.externalId })
}
What to do when a person exists twice
It happens: two records in your system for one child, merged after the fact.
There is no merge operation, deliberately — merging clinical records is a decision your platform makes with a human involved, not one an integration makes automatically.
The pattern that works:
- Decide which of your identifiers survives. That is your call, on your side.
- Export the artifacts attached to the losing identifier.
- Archive the losing record with
patients.archive. - Re-attach anything that should follow the client by generating or assigning it against the surviving identifier.
Artifacts do not move between subjects. An artifact records who it was made for at the time it was made, and rewriting that is falsifying a clinical record.