Resources

Medplum Appointment Status: Two Layers, Not One

By Federico Ruiz Cassarino, CEO and founder, Puppeteer AI · · 6 min read

The mistake that shows up most often in a Medplum scheduling integration isn't a bad endpoint or a malformed body. It's reading one status where the resource actually carries two.

An appointment has a status. Every participant on that appointment carries a separate one. They answer different questions, they move on their own schedules, and treating them as a single field is how a slot ends up looking confirmed when nobody involved has agreed to it.

The booking layer

The appointment's own status covers the full FHIR R4 set: proposed, pending, booked, arrived, checked-in, fulfilled, cancelled, noshow, entered-in-error, and waitlist.

This is the layer everyone reaches for first, and on its own it answers a narrow question: what is happening with the slot. It says nothing about whether the person sitting in it has actually said yes.

The person layer

Each entry in the appointment's participant array carries its own status, from a much smaller set: accepted, declined, tentative, needs-action.

That answers a different question: has this specific person agreed to it, not what state the slot is in.

The gap between the two is ordinary, not a bug. An appointment can sit at status: "booked" while the patient participant is still at needs-action. That's the normal shape right after staff books a visit and before the patient responds: the slot is committed, the person hasn't weighed in yet.

Read only the appointment status and that patient reads as confirmed. Read the participant status and you learn they haven't been asked, or were asked and haven't answered either way. For anything reminder-shaped, the participant field is the one that actually matters, because it's the only place on the resource where the patient's own answer lives.

A room answers for itself too

Participants aren't only people. A location gets appended to the same array, as an actor reference (Location/{id}) carrying a status of its own: needs-action, and it never moves off that value.

A room can't accept an invitation, so FHIR leaves it at the neutral default forever. That quietly breaks any check written as "are all participants accepted": it will return false on an appointment where every human involved has said yes, purely because the location entry is sitting where it always sits.

The fix is to filter by actor type before checking status, not to check status across the whole array. Skip that filter and a confirmation check never passes, on any appointment that has a room attached.

What else rides along on a participant

Beyond status, a participant entry can carry three more fields worth using.

  • actor. A reference plus an optional display name, so a list of who's involved can render without resolving every reference first.
  • required. Whether the appointment can proceed without this person. It separates the treating clinician from an optional interpreter or a supervising provider who's nice to have but not blocking.
  • type. A coding with a system and a code, describing the participant's role rather than their identity.

required and type are the two fields most integrations skip and the two that make an appointment readable later without extra lookups. Leave type off and working out which participant is the patient means resolving every reference and checking what it points at. Set it once and the role sits right on the entry.

Two fields read like free text, one doesn't

Three fields carry the clinical framing for the visit, and they don't share a shape. serviceType is an array of objects holding a bare text key, for example [{ "text": "Follow-up" }]. Free text, no coding underneath.

reasonCode matches that shape exactly: another array of { text: ... } entries.

appointmentType breaks the pattern. It's a single object holding a coding array with a system and a code: structured, and validated against a set rather than typed in freehand.

Two fields describing roughly the same thing in free text, one describing it as a code. That inconsistency belongs to the FHIR spec, not to Medplum, but it's easy to get backwards if you haven't written it down somewhere in your own integration.

Change one field, not the whole resource

A PUT to /fhir/R4/Appointment/{id} replaces the entire resource. Leave a field out and it's gone from the record, not left alone, and on this resource that means the participant array and every status it's carrying.

A single status change is safer as a PATCH with a JSON Patch body:

[ { "op": "replace", "path": "/status", "value": "arrived" } ]

One field named, one value changed, everything else untouched. That's the transition a status-aware integration lives on, because the whole premise of two independent layers falls apart the moment a full-resource write collapses them back together on every touch.

Reading both layers as one answer

Put the two fields together and the logic stops being a single condition, which is exactly why a single condition usually gets it wrong. A visit is genuinely confirmed only when the appointment is booked and the patient participant has accepted. Four combinations come up in practice, and each one calls for something different.

  • booked + accepted. Confirmed. Nothing left to do.
  • booked + needs-action. The slot is held and the patient hasn't answered. This is the reminder population, and it's exactly the group a single-field check erases.
  • booked + declined. The patient said no and the slot is still committed on paper. This needs a person, since it's the state that quietly turns into an empty chair.
  • pending + anything. The booking itself isn't settled yet. A reminder here is premature no matter what the participant field says.

Only the second row should get an automated nudge. The third needs a human, not a message. The other two need nothing. Collapsing this into one check is the most common source of reminder bugs on Medplum, and the failure is asymmetric: over-messaging is annoying, but treating declined as confirmed loses the visit and nobody notices until the appointment time has passed.

What you need on your side

  • A Medplum project and credentials. The hosted service or a self-run deployment; the API surface is the same either way.
  • An agreed reading of participant status. Which participants must be accepted before you call a visit confirmed, and what your code does when it hits tentative.
  • A filter for actor type. So a location entry stuck at needs-action never gets counted as a person who hasn't responded.
  • Update logic that defaults to PATCH. Reserving PUT for the rarer case where several fields genuinely need to change together.

Honest limits

  • No timestamps on either layer. Neither the appointment status nor a participant status records when it last changed. Knowing how long a patient sat at needs-action means capturing that yourself.
  • Tentative means whatever you decide it means. FHIR allows tentative as a value; it doesn't define what a practice should do with it. Leave that undefined and different parts of your system will treat it differently.
  • No attribution on who set a status. A participant status set through a patient portal and one set by front-desk staff look identical on the resource.
  • The location entry is permanent noise. Every aggregation over participant status has to account for it, forever, or it will misreport a fully confirmed appointment.

Sources

  1. Medplum docs: Appointment resource

About the author

Federico Ruiz Cassarino is the CEO and founder of Puppeteer AI, the company behind Recupra. He works with clinic operators on the scheduling and outreach problems that decide how much of a practice's booked capacity turns into revenue. Connect on LinkedIn.

FAQ

Frequently asked questions

What's the difference between appointment status and participant status on Medplum?

Appointment status describes the booking itself: booked, cancelled, fulfilled, and so on. Participant status describes whether one specific person on that appointment has agreed to it: accepted, declined, tentative, or needs-action. The two move independently.

Why does my appointment never show as fully confirmed?

Most likely the location participant, which Medplum sets to needs-action and never updates. Filter to human participants before checking whether everyone involved has accepted.

Is serviceType a coded field or free text?

Free text: an array of objects with only a text key. appointmentType is the coded one, holding a system and a code. They cover similar ground and behave differently, which is easy to get backwards.

Should a status change use PUT or PATCH?

PATCH, with a JSON Patch replace on the single field that changed. A PUT sends the whole resource and drops anything you leave out, including the participant array and its statuses.

Send reminders to the patients who actually haven't confirmed.

Connect Medplum and Recupra's Reminder Agent reads the participant status, not just the appointment status, so a booked slot with an unanswered patient gets a call and a confirmed one doesn't.