202 Accepted with a queued sync request. The re-sync runs
asynchronously — logging into an EHR and re-reading a record is a multi-second
operation on a good day — so you then either poll GET /sync-requests/{id} or
subscribe to the sync_request.completed webhook, and
re-read the resource once the request is terminal.
Supported resource types
resourceId is the id the resource’s own endpoint returns — prefixed
(clm_…, bil_…, pat_…, nte_…, apt_…) from v3, raw UUID earlier. The
prefix must match resourceType; for patient_appointments it is the patient’s
pat_… id.
No new scopes: a re-sync only refills data your app can already read, so it
requires the resource’s existing read scope.
Appointments are coarser. There is no single-appointment sync; an
appointment request re-sweeps the connector’s schedule window (roughly one
week back to thirteen weeks ahead). Because of that it has a longer, per-connector
cooldown — and since the schedule already re-syncs itself every few minutes,
you rarely need it. An appointment outside the window that sweep covers is
rejected with a 400 rather than accepted into a re-sync that could not reach
it. To refresh what you know about one patient, use patient_appointments
instead.One patient’s appointments
patient_appointments answers “does this patient have a visit booked?” without
waiting for the next schedule pass (up to about five minutes behind the EHR):
GET /appointments?patientId=, and any that actually changed fires the usual
appointment.created, appointment.updated, appointment.cancelled or
appointment.completed webhook, exactly as if the schedule sync had seen it.
An appointment the EHR has deleted outright is not removed:
GET /appointments keeps listing it.
- Cooldown is per patient (5 minutes), separate from the
appointmentkind’s 30-minute per-connector cooldown. resourceUpdatedAtis alwaysnull: a patient’s appointment list has no single change stamp. Wait forcompleted, then re-readGET /appointments?patientId=.- ModMed EMA reads the patient’s appointments directly, but only at the
facilities of the one ModMed connection the patient’s record is linked to,
which is not necessarily where their visits happen. If that connection is
not live, the request is refused with a
400. If your practice has several ModMed connections, this request does not re-read a visit at another connection’s facility; that connection’s own schedule sync keeps it current within the window it sweeps (one week back to thirteen weeks ahead). - EZDerm reads the patient’s chart in one patient-scoped call, the same read
as
POST /v4/patients/history/refresh, so like that refresh it leaves telehealth (virtual) visits out. It does not matter which connection, if any, the patient’s record is linked to. What matters is which EHR login reads the chart, in order: your organization’s office (system) EZDerm connection on the patient’s practice; if it has none live, the personal EZDerm connection of the logged-in user who made the request, when you send their App BridgesessionToken(with no live office connection, a token that does not verify for your app and organization is a401for that request, or for each batch item that needed it; with an office connection the token is only the read’s fallback, should the office connection drop before the read starts, and never refuses it); otherwise none. Another staff member’s personal connection, and the practice’s autosubmission connection, are never used. With no such connection the request is a400before anything is recorded, and the message says which of the two is missing. A read through the user’s own connection re-reads and writes what that login can see (for example only their facilities), so it never marks appointments it did not return as removed, and still completes. A chart the office connection cannot return completely reportsfailed. - Both stay scoped to the one patient; neither is turned into a whole-schedule sweep.
- A patient with no EHR id to search by (or, on ModMed, a non-numeric one) is
refused with a
400before anything is recorded.
Request lifecycle
status moves queued → completed | failed. Treat the first terminal status
you see as the outcome. Until the platform has finished recording it, that
status is derived live from the underlying sync runs, so if those runs
briefly become unreadable a later poll can report queued again — even after an
earlier poll returned a terminal status and a completedAt. Polling again
resolves it; the request itself never moves backwards.
completed— every sync run the request fanned out finished. Re-read the resource’s own endpoint for the refreshed data.failed—errorKindsays why, as a closed marker:child_run_failed— a sync run failed, or was interrupted mid-flight. The data may be partially refreshed; retry after the cooldown.cancelled_before_start— the sync runs were discarded while still queued, so nothing this request dispatched ever ran. Usually an equivalent sync already held the lane (in which case that run is doing the same work), but a worker restart looks identical, so treat the resource as possibly still stale. It holds the cooldown (5 minutes, or 30 per connector for an appointment). Re-read the resource, and retry after the cooldown if it is still stale.stale— no terminal outcome inside the watch window (30 minutes). The re-sync may still land late; treat the request as spent and re-read the resource before retrying.
errorKind: it is refused
synchronously with a 4xx or a 502, and no request id is issued. Two kinds,
which differ in what they cost you:
- Refused before the request is recorded — an unknown resource, a paused
connector, an appointment outside the sweep window. Nothing is stored, so
there is no cooldown; retrying returns the same
4xxuntil the underlying problem is fixed. - Refused while dispatching, once the request exists — the resource has no
EHR identity, or the dispatch itself failed. These hold the cooldown, so an
immediate retry returns
409.
resourceUpdatedAt on the 202 is the resource’s own change stamp before
your request, so you can tell whether the re-sync changed anything: re-read the
resource and compare. It is the same field that resource’s endpoint returns —
updatedAt for claim, bill, note and patient; lastSyncedAt for
appointment, which exposes that instead and has no updatedAt; always null
for patient_appointments.
It is read before anything is dispatched, so a re-sync that finishes faster than
the 202 returns cannot slip past it. Two caveats: it is a property of the
resource at request time rather than of the request, so it is returned only on
the 202 (always null on GET); and it moves only when the data actually
changed, so it is a change detector, not a completion signal — use the request’s
status or the webhook for that.
A sync request belongs to the app that created it: GET /sync-requests/{id}
and the completion webhook are both scoped to that app, and another app in the
same organization gets a 404.
Completion webhook
If your app has webhooks configured, a terminal request also emitssync_request.completed, delivered only to the app that made the request:
GET /v{N}/sync-requests/{id}, which accepts either form.
Many resources at once
POST /v4/sync-requests/batch queues up to 25 re-syncs in one round trip.
Kinds may be mixed. Use it when a sweep of your own has found several resources
worth re-checking; it saves round trips, nothing else.
- Results come back in the order you sent them, with
resourceIdechoed back verbatim in the form you sent it, so you can match them to your input. error.statusCodeis the status that item would have received on its own —400,403,404,409,429,502— with the same message. One status is reported per item rather than for the request: a500item is the same unhandled failure the single-resource endpoint would answer with, scoped to that item so the rest of the batch still runs. Retry that item; the others are unaffected.- A
503item means the batch ran out of its time budget before that item was started. An item is not one EHR call — aclaimfans out to as many as four — so a large batch of the heavier kinds can outrun the budget. Nothing ran for a503item: it holds no cooldown, so send it again straight away, in a smaller batch. The items that did queue are still in the response with their ids, which is the point of stopping rather than timing out. - Queued items are ordinary sync requests: poll each one’s
GET /sync-requests/{id}, andsync_request.completedfires once per item, never once per batch. - More than 25 items is a
400naming the limit — never a silent truncation. Send the rest in another batch. - The response is held until every item has been dispatched, so a full batch can take several seconds.
- Sending the same resource twice in one batch is allowed; the second occurrence is refused by its own cooldown, like any other repeat.
Guardrails
Re-syncs spend a real clinic’s EHR rate budget, so they are deliberately bounded:- Cooldown — one re-sync per resource per 5 minutes (
409), and a request still running also409s until it finishes or goes stale. Appointments cool down per connector per 30 minutes, since one request sweeps that connector’s whole schedule.patient_appointmentscools down per patient, on the ordinary 5 minutes. - Refusals — a request rejected before it is even recorded (an unknown or
un-syncable resource, a paused connector) holds no cooldown. Once a request
exists, the cooldown is held either way, so retrying straight away returns
409. - EHR rate share — the EHR calls a re-sync makes draw from your app’s share of the practice’s EHR login rate (see rate limits). There is no daily cap.
- The global rate limit applies on top.
409 tells you a refresh is already underway or just happened — poll the
resource instead of retrying the POST.
When not to use this
- Routine change detection. Poll with
updatedSincedelta queries or subscribe to webhooks; background syncs keep the cache fresh without you spending re-syncs. - Bulk refreshes. The batch endpoint exists for a short list you have already narrowed down; every item spends EHR calls from your app’s rate share, so it is not a way to refresh everything. If you believe a whole organization’s data is stale, contact us instead of scripting a loop over your cooldown budget.
