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.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.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.
Appointment payload
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.
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.
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
app.suspended and app.reactivated
installation.permissions_updated
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 a2xx
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.
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.{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:uninstalled:
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 isGET /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 leavesactive, API requests for that organization are
rejected:
app.uninstalled event and the reconciliation
endpoint are how you find out properly.