Skip to main content
Max AI pushes events to your app over webhooks, signed with Standard Webhooks. This page is the whole contract: what you can subscribe to, what is and is not guaranteed about delivery, how to verify a request really came from us, and what to do when one goes missing.

Registering an endpoint

Endpoints are managed from the Webhooks tab on your app’s page in the developer dashboard. There you add endpoint URLs, choose which event types each one receives, reveal or rotate the signing secret, inspect every delivery attempt, and resend events.
  • Admins only for changes. Adding, editing, disabling or deleting an endpoint, revealing or rotating its signing secret, resending events, and viewing request or response bodies all require the admin role in your developer organization. Other members can see endpoints and delivery status.
  • URL requirements. Endpoints must use https:// on port 443 with a public hostname. IP-address URLs, private hosts and other ports are rejected.
  • Send a test. After adding an endpoint, send it a test.event from the tab to check that your server answers with a 2xx.
A webhook URL cannot currently be declared in max-ai.app.toml. Endpoints are registered in the Webhooks tab only.

Event catalogue

Lifecycle events

These concern your app’s own installation at an organization. They are not scope-gated: your app always hears about its own installations, whatever permissions that installation carries.

Data events

These concern a clinic’s data changing. They exist so you do not have to rediscover changes by re-reading the API on a timer.

Booking, scheduling and clinical events

A first sighting is not necessarily a new booking. When a connector is first installed, the sync writes the whole −1/+6-week window at once, and any appointment already sitting in a terminal status reports appointment.cancelled or appointment.completed — not appointment.created. previousStatus is null on those, because no transition was observed; that null is the flag for “this may be historical”. An app that treats every appointment.cancelled as a just-now cancellation will contact every already-cancelled patient in that window on connect day. Gate outreach on previousStatus !== null, or on scheduledStartDate being in the future.
appointment.cancelled covers both cancelled and no_show, because the two are one signal for reactivation outreach: the patient did not get seen. Read status on the payload to tell them apart. Delivery is unordered, so order on the payload’s updatedAt rather than on arrival — a stale status applied late would otherwise resurrect a cancelled appointment as booked.
Facility events carry a compact payload. On facility.updated, refetch GET /facilities/{id} for the complete current address, specialty and other details. Appointment metadata changes likewise invalidate the appointment; refetch it when your cache needs fields absent from the event. Initial population of previously unknown appointment metadata can also emit an update. Data events are scope-gated: you receive them only for organizations whose installation grants your app the relevant scope. They are emitted from the sync that writes the change, and only when something actually changed — a steady-state re-sync that rewrites nothing is silent, so you will not see an event per poll cycle per record.
There is no patient.updated event, and no consent or deceased data anywhere in the platform. Max AI does not store do-not-contact flags or vital status: no such column exists on the patient record, no EHR integration captures them, and the API exposes none. If your app suppresses outreach, you cannot source that suppression from Max AI — neither by webhook nor by polling. Treat the EHR, or your own consent store, as the system of record for it.

Appointment payload

The payload carries identifiers and a few scheduling fields, not the full record. Re-fetch from GET /v{N}/appointments when you need everything.

Patient document payload

id is the same document id GET /v{N}/patients/{id}/documents returns. There is deliberately no download URL on the event — a signed URL expires long before a webhook retry chain does. Re-read the documents endpoint, where the URL is minted fresh. The rule behind every silence below: this event says a document was created, so it is only sent when “we noticed it” and “it happened” are the same moment. A document we merely caught up on is not announced — you should never have to wonder whether a created event is reporting something from months ago.
  • The first time a chart is listed, nothing is emitted. Everything on a chart we have never listed is new to us and old to the clinic, so a first listing would otherwise fire one event per historical document. You will receive events for that chart from its second listing onward.
  • A chart nobody has listed in over 30 days re-baselines silently. Past that gap the documents we find are a backlog rather than news, so the listing updates the chart without announcing anything, and events resume on the next one.
  • A document the EHR dates before our previous listing is skipped. We simply missed it earlier; it is not a creation.
  • A document the EHR removes and later restores is not re-announced. It was announced the first time we saw it, and it was not created twice. A document that is genuinely re-scanned is a new attachment with its own EHR-native id, so it is announced like any other.
  • A large burst is capped (a chart that gains hundreds of documents between two listings is a backfill, not hundreds of clinical events). The most recently created documents are the ones announced.
In every one of these cases the documents are still readable from GET /patients/{id}/documents — only the announcement is withheld. That is why updatedSince on the documents endpoint is the backstop, below. The event fires from whichever listing found the document first — the nightly active-patient sweep, your own POST /v{N}/patients/{id}/documents/refresh, or a biller refreshing the chart from the dashboard. Only the FIRST listing to see a document announces it; later ones find it already known and correctly say nothing. Treat the webhook as the fast path and updatedSince on the documents endpoint as the backstop.

Fax status payload

patientId is null for a fax sent without a patient. pageCount is the pages we handed the EHR, counted at send time without the cover page (null when a document’s pages were unknown); pagesSent is the EHR’s own count once the fax settles. POST /v{N}/faxes/send returns as soon as the EHR accepts the fax — before it has dialed — so this event is how you learn the result without polling. Only terminal outcomes are announced: the in-flight sent state is never sent as an event. status is delivered or failed. On a failure, errorKind tells you whether retrying could ever help:
  • invalid_request — the number or the attachment was rejected before dialing. Retrying the same payload will fail the same way; fix it first.
  • transmission_failed — the line was busy, nobody answered, or the far end was not a fax machine. A retry is reasonable.
  • not_found_in_outbox — the EHR never reported the transmission at all, and we stopped waiting. The outcome is unknown: the fax may still have been delivered (the practice can delete an outbox row), so do not blind-retry — confirm with the practice first.
  • ambiguous_outbox_match — more than one fax in the EHR’s outbox matched this send (you faxed the same documents to the same number twice within a couple of minutes), so we could not tell them apart. It may have been delivered — do not blind-retry.
  • patient_unresolvable — the patient no longer resolves to an EHR chart, so the outcome can no longer be read. Same caution as above.
  • still_processing — the last thing we saw was the EHR not having finished (most often the practice ran out of fax credit), or the fax dropping out of the outbox listing while we were tracking it. Either way it may still transmit, on its own, without any action from you. Do not re-send; tell the practice.
  • send_interrupted — our own send never completed (a process died mid-call). Whether anything transmitted is unknown.
  • outbox_unreadable — the practice’s EHR connection was down for as long as we tracked this fax, so we never learned its outcome. Usually means the clinic’s EHR connector needs attention.
  • unknown_status — the EHR reported a status code we do not recognise. It may even be a delivered variant we have not seen; treat it as unknown.
A synchronous refusal by the EHR (ehr_send_failed) is returned in the POST /v{N}/faxes/send response itself and never arrives as an event. Every terminal outcome is announced, including the ones we could not determine — not_found_in_outbox, ambiguous_outbox_match, patient_unresolvable, still_processing, send_interrupted and outbox_unreadable are all terminal failed states and are emitted like any other. What is not announced is the in-flight sent state, and a synchronous refusal, which the send call returns to you directly. Delivery is best-effort and at-most-once: a settled fax is written to the database first and announced afterwards, and nothing re-emits an announcement that failed to dispatch. So do not treat the webhook as the only way you will hear about a fax — reconcile anything you have not heard about within an hour or so with GET /v{N}/faxes/{id}, which is always authoritative. delivered means the receiving fax machine accepted the pages. It does not mean anyone at the far end has read or worked the fax — no fax transport can tell you that. occurredAt is when the transition happened, not when we dispatched the event — order on that field rather than on delivery order.

Payload shape

Every event shares one envelope:

app.installed

organizationId is your routing key. grantedPermissions lists the optional scopes the clinic agreed to — your version’s required scopes are always present and are not repeated here. settings holds the clinic’s answers to your version’s settings_schema.
installedBy is currently always null. The column exists and the field is in the contract, but nothing on the platform writes it yet — not even a clinic install performed by a named user. Do not use it to attribute an install; it will start carrying a user id without a breaking change once the install path records one.

app.uninstalled

Stop all background processing for that organization when you receive this. Your API calls for it will already be failing — see After an uninstall — but you should not be relying on failed reads as your stop signal.

app.suspended and app.reactivated

A suspension has the same immediate effect as an uninstall — your API calls stop working — but it is reversible. Stop processing; keep the tenant.
Neither of these two is emitted yet. The platform can now perform and record a suspension, and the events are registered so you can subscribe to them, but no operator-facing path triggers one — so an endpoint you wire up today will not receive them until that ships. The payload above is the contract they will arrive with; it is documented now so a handler written against it keeps working.app.installed, app.uninstalled and installation.permissions_updated are live. Until suspension is reachable, treat GET /v{N}/marketplace/installations as the way to notice a non-active status — it reports the current status of every installation whether an event fired or not.

installation.permissions_updated

Both the new and previous sets are included so you can act on the delta. In the example above the clinic revoked read:phone_numbers; an SMS channel gated on that scope should be disabled rather than left to fail on the next send.

member.added, member.removed and member.role_changed

The three share one shape; only member.role_changed carries previousRole, and it is sent only when the previous role is known — a role change we cannot compare to one is not announced. Read current roles from the roster endpoint. A redelivery of the same change from the identity provider keeps the same webhook-id, so the usual dedupe applies. The fields mean what they mean on GET /v{N}/marketplace/members: userId is the App Bridge token’s sub (a raw uuid on every version) or null when the person’s Max AI user record does not exist yet, and role is Clerk’s spelling, open vocabulary. organizationId is a raw uuid, like every other webhook payload. timestamp is when the change happened in the identity provider, not when it was forwarded — order on it. A member.removed for someone who was re-added a minute later can arrive after the member.added; its older timestamp is how you know to keep the grant. member.removed is the one to act on: key the grants you hold on userId (or clerkUserId when userId was null) and retire them. The roster endpoint stays the source of truth — reconcile against it on a schedule, as with installations.

Delivery guarantees

At-least-once, with retries. A failing delivery is retried 7 times, after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours — about 27½ hours in total — before it is marked failed. Each attempt waits 15 seconds for your response. Return a 2xx quickly — do the work asynchronously — or you will be retried. Endpoints that keep failing are disabled. An endpoint that has failed continuously for 5 days is disabled automatically. Events that happen while an endpoint is disabled are not delivered later; after you fix and re-enable it, you can resend the events that failed before it was disabled, and reconcile the rest (see Reconciliation). History is kept for 14 days. Delivery records, payloads and attempt responses are retained for 14 days, so resends and inspection in the Webhooks tab reach back 14 days. Requests come from fixed IP addresses. If your firewall allowlists senders, allow the addresses listed in the Webhooks tab (they are also in the Details panel of each endpoint). Deduplicate on svix-id. That header is stable across every retry of the same event. Treat it as the idempotency key.
One-time exception during our infrastructure move. Events that were first sent before our move to new delivery infrastructure, and retried after it, carry a new svix-id. If you deduplicate on svix-id alone, also compare the payload’s type, timestamp and resource id within the 48 hours around the move date shown in the Webhooks tab.
Ordering is NOT guaranteed. Two events for the same installation can arrive out of order. Every payload carries its own timestamp; order on that, never on arrival order. That timestamp is when the change was recorded, not when the message was sent — which is what makes it safe to order on. Two events for one installation are comparable even if the earlier one was delivered second.
An app.uninstalled can arrive before the app.installed it follows. If your handler upserts a tenant on install, that sequence will resurrect a tenant you just tore down. Compare the payload timestamp against what you last applied and ignore anything older.
Delivery can still be lost. Endpoints go down for longer than the retry window. The at-least-once guarantee also begins the moment our delivery service accepts a message: an event is emitted right after the change that produced it is committed, so a process that dies in between loses that event with nothing to retry — and because the change itself is already recorded, no later sync re-detects it. Rare, and unrecoverable when it happens. See Reconciliation.

Verifying signatures

Requests are signed with Standard Webhooks. Your signing secrets and these headers are unchanged by our infrastructure move. Three headers are sent:
The headers arrive with the svix- prefix. The Standard Webhooks spec names them webhook-id / webhook-timestamp / webhook-signature, and Svix’s own libraries accept either spelling — but only white-labelled Svix accounts actually send the webhook- form, so do not read those names off the request. Pass the whole header collection to the library and let it work out which it has, as below.
The signed content is {id}.{timestamp}.{raw body}, HMAC-SHA256 with your endpoint’s signing secret, base64-encoded. Verify against the raw request body, before any JSON parsing — re-serializing changes the bytes and the signature will not match. Reject a request whose timestamp is more than five minutes from now, which is what stops an intercepted request being replayed later. More than one signature can appear in the signature header; that is how secret rotation works. Accept the request if any of them matches. Use Svix’s library rather than hand-rolling this:

Reconciliation

Webhooks give you latency. This endpoint gives you correctness:
It returns every organization that has installed your app, in every status, including uninstalled:
Unlike every other data endpoint, this one takes no X-Organization-Id header — it is scoped to the app your API key belongs to, and answering “which organizations installed me?” cannot require knowing one up front.
  • organizationId is org_ plus the uuid’s 32 hex digits on v3 and later (a raw dashed uuid on v1–v2). Webhook payloads and the App Bridge token’s org claim carry the same organization as a raw dashed uuid. On v3+, drop the dashes and prepend org_ to compare; on v1–v2 the values already match. X-Organization-Id accepts either form.
  • grantedPermissions is not your scope set. It lists only the optional scopes the clinic opted into; your version’s required scopes are not added here, so [] is normal. To check whether you may call an endpoint, read scopes from GET /v4/marketplace/me with that organization’s X-Organization-Id: it is the live version’s required scopes plus the clinic’s opt-ins, limited to your key’s scopes.
Poll it on a schedule (nightly is enough for most apps) and treat it as the source of truth. Uninstalled installations are deliberately included: without them, a revoked installation would be indistinguishable from one that never existed, and an app that missed the webhook would never learn to stop.

Reconciling data events

Data events need the same treatment, against their own endpoints. For documents that is GET /v{N}/patients/{id}/documents?updatedSince=..., which returns what the chart holds now regardless of what was announced — so it recovers both a delivery that was lost and a document that was deliberately not announced (see the silences under Patient document payload). Poll it for the patients you are actively working, and treat patient_document.created as the thing that tells you to look now rather than the thing that tells you everything.

After an uninstall

Once an installation leaves active, API requests for that organization are rejected:
This is checked against the installation’s current status on every request, not cached at key issue time, so it applies immediately on uninstall and on suspension. A cached API key does not retain access to an organization that revoked your app. Treat that as a backstop, not a design. A stream of 401s is something you have to infer an uninstall from; the app.uninstalled event and the reconciliation endpoint are how you find out properly.