Skip to main content
Every read endpoint serves a cache of the clinic’s EHR, refreshed continuously by background syncs. Most of the time that cache is minutes fresh and you never think about it. When you know something changed in the EHR — a biller tells you a claim was corrected, your user is looking at a stale ledger — you can ask for one targeted re-sync instead of waiting for the next background pass:
The response is 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):
It re-reads only that patient’s appointments from the EHR, over at least the window the schedule sync covers (one week back to thirteen weeks ahead), and writes them into the same mirror. When it completes, every appointment the EHR lists for the patient in that window (on ModMed, at their linked connection’s facilities; on EZDerm, other than telehealth visits, and through a personal login only what that login can see; see below) has been re-read into 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 appointment kind’s 30-minute per-connector cooldown.
  • resourceUpdatedAt is always null: a patient’s appointment list has no single change stamp. Wait for completed, then re-read GET /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 Bridge sessionToken (with no live office connection, a token that does not verify for your app and organization is a 401 for 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 a 400 before 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 reports failed.
  • 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 400 before 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 — errorKind says 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.
A request that cannot be started at all never reaches 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 4xx until 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 emits sync_request.completed, delivered only to the app that made the request:
The webhook is best-effort (like every data event); polling is the correctness path. Ids in webhook payloads are raw UUIDs — pass them straight to 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.
Every item is processed independently, under exactly the same guardrails as the single-resource endpoint: its own scope check and its own cooldown. Batching buys round trips, not extra EHR capacity: every item’s EHR calls draw from your app’s share of the practice’s EHR rate.
Partial success is the normal case. The 202 says the batch was processed, not that every item queued. Each result carries either request or error, never both and never neither — check every one. An item refused by its cooldown does not affect the others.
  • Results come back in the order you sent them, with resourceId echoed back verbatim in the form you sent it, so you can match them to your input.
  • error.statusCode is 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: a 500 item 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 503 item means the batch ran out of its time budget before that item was started. An item is not one EHR call — a claim fans out to as many as four — so a large batch of the heavier kinds can outrun the budget. Nothing ran for a 503 item: 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}, and sync_request.completed fires once per item, never once per batch.
  • More than 25 items is a 400 naming 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 also 409s until it finishes or goes stale. Appointments cool down per connector per 30 minutes, since one request sweeps that connector’s whole schedule. patient_appointments cools 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.
A 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 updatedSince delta 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.