Skip to main content
Max AI pushes events to your app over webhooks, delivered through Svix. 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 app portal, reachable from your app’s page in the developer dashboard. The portal is where you add endpoint URLs, choose which event types each one receives, inspect delivery attempts, and replay failed messages.
A webhook URL cannot currently be declared in max-ai.app.toml. Endpoints are registered in the portal 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.
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

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 determinenot_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.

Delivery guarantees

At-least-once, with retries. Svix retries a failing endpoint on an increasing backoff over roughly 24 hours before giving up. Return a 2xx quickly — do the work asynchronously — or you will be retried. Deduplicate on svix-id. That header is stable across every retry of the same event. Treat it as the idempotency key. 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 Svix 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, delivered through Svix. 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. 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.