> ## Documentation Index
> Fetch the complete documentation index at: https://docs.maxcare.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> The event catalogue, delivery and ordering guarantees, signature verification, and how to reconcile when a delivery is lost

Max AI pushes events to your app over webhooks, delivered through
[Svix](https://www.svix.com). 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.

<Note>
  A webhook URL cannot currently be declared in `max-ai.app.toml`. Endpoints are
  registered in the portal only.
</Note>

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

| Event                              | Fired when                                                                               |
| ---------------------------------- | ---------------------------------------------------------------------------------------- |
| `app.installed`                    | An organization installs your app                                                        |
| `app.uninstalled`                  | An organization uninstalls your app                                                      |
| `app.suspended`                    | An installation is suspended (billing or admin action). **Not emitted yet — see below.** |
| `app.reactivated`                  | A suspended or inactive installation returns to active. **Not emitted yet — see below.** |
| `installation.permissions_updated` | An organization changes which optional scopes you are granted                            |

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

| Event                      | Fired when                                                                                                                                                                                                                                                                                                                                                                                                         | Required scope                      |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------- |
| `appointment.created`      | An appointment is seen in a sync for the first time AND is not already in a terminal status. A first sighting that is already `cancelled`/`no_show`/`completed` reports that event instead — see the note below                                                                                                                                                                                                    | `read:appointments`                 |
| `appointment.updated`      | Any tracked field on a synced appointment changes — status, scheduling, type, facility, reason for visit, or the patient/provider it is booked with (excluding cancel and complete, which get their own events)                                                                                                                                                                                                    | `read:appointments`                 |
| `appointment.cancelled`    | An appointment transitions to `cancelled` or `no_show` — or is first seen already in one of them, in which case `previousStatus` is `null`                                                                                                                                                                                                                                                                         | `read:appointments`                 |
| `appointment.completed`    | An appointment transitions to `completed` — or is first seen already completed, in which case `previousStatus` is `null`                                                                                                                                                                                                                                                                                           | `read:appointments`                 |
| `patient_document.created` | A chart refresh finds a document that was not on the chart last time we listed it                                                                                                                                                                                                                                                                                                                                  | `read:patient_documents`            |
| `facility.created`         | A facility is seen in a sync for the first time                                                                                                                                                                                                                                                                                                                                                                    | `read:facilities`                   |
| `facility.updated`         | A synced facility's details change, including its address                                                                                                                                                                                                                                                                                                                                                          | `read:facilities`                   |
| `provider.created`         | A provider is first written by a USER sync. A provider first observed on an appointment is stubbed into the mirror silently — no `provider.created` fires for it, and the next user sync reports it as `provider.updated` (on EZDerm, where the stub already carries the name and active flag, possibly no event at all). Reconcile against `GET /providers` rather than treating this as a complete creation feed | `read:providers`                    |
| `provider.updated`         | A synced provider's name or active status changes — and, on ModMed, a role change, which is where its `title` comes from. EZDerm's `title` is read live from the user record and is NOT compared, so a title-only edit there does not fire                                                                                                                                                                         | `read:providers`                    |
| `fax.status_changed`       | An outbound fax you sent reaches a terminal outcome — delivered, or failed                                                                                                                                                                                                                                                                                                                                         | `read:faxes`                        |
| `sync_request.completed`   | An on-demand re-sync you requested (`POST /v{N}/sync-requests`) reaches a terminal outcome — see [On-demand sync](/guides/on-demand-sync)                                                                                                                                                                                                                                                                          | the re-synced resource's read scope |
| `test.event`               | You press "Send test event" in the portal                                                                                                                                                                                                                                                                                                                                                                          | —                                   |

<Warning>
  **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.
</Warning>

<Note>
  `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.
</Note>

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.

<Warning>
  **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.
</Warning>

### Appointment payload

```json theme={null}
{
  "type": "appointment.completed",
  "data": {
    "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
    "organizationId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "integrationAppointmentId": "APT00001234",
    "patientId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
    "providerUsersEhrId": "d3e4f5a6-b7c8-4d9e-0f1a-2b3c4d5e6f70",
    "facilityId": "e4f5a6b7-c8d9-4e0f-1a2b-3c4d5e6f7081",
    "status": "completed",
    "integrationStatus": "CHECKED_OUT",
    "previousStatus": "arrived",
    "cancelReasonName": null,
    "scheduledStartDate": "2026-08-14T15:00:00.000Z",
    "scheduledEndDate": "2026-08-14T15:30:00.000Z",
    "updatedAt": "2026-08-02T14:03:10.880Z"
  },
  "timestamp": "2026-08-02T14:03:11.000Z"
}
```

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

```json theme={null}
{
  "type": "patient_document.created",
  "data": {
    "id": "9c1f0f7a-2f4b-4f2e-8a1e-6b0f2d3c4a5b",
    "organizationId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "patientId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
    "kind": "insurance_card",
    "sourceCategory": "Insurance Card Front",
    "title": "Insurance Card Front",
    "filename": "card_front.jpg",
    "mimeType": "image/jpeg",
    "bytes": 184320,
    "createdAt": "2026-08-15T20:15:00.000Z"
  },
  "timestamp": "2026-08-15T20:15:04.000Z"
}
```

`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

```json theme={null}
{
  "type": "fax.status_changed",
  "data": {
    "faxId": "a7c3f1e2-8b4d-4a9e-b5c6-d7e8f9012345",
    "organizationId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "patientId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
    "toNumber": "7347148907",
    "status": "delivered",
    "errorKind": null,
    "statusDetail": "successfully delivered",
    "pagesSent": 15,
    "sentAt": "2026-08-26T03:20:20.000Z",
    "occurredAt": "2026-08-26T03:25:00.000Z"
  },
  "timestamp": "2026-08-26T03:26:11.000Z"
}
```

`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:

```json theme={null}
{
  "type": "app.installed",
  "data": { },
  "timestamp": "2026-08-02T14:03:11.000Z"
}
```

### `app.installed`

```json theme={null}
{
  "type": "app.installed",
  "data": {
    "installationId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
    "appId": "d0e1f2a3-b4c5-4d6e-7f8a-9b0c1d2e3f4a",
    "organizationId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "organizationName": "Dermatology & Cosmetic Center",
    "versionId": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
    "grantedPermissions": ["read:phone_numbers"],
    "settings": { "reminderWindowDays": 30 },
    "installedBy": "f2a3b4c5-d6e7-4f8a-9b0c-1d2e3f4a5b6c",
    "installedAt": "2026-08-02T14:03:11.000Z"
  },
  "timestamp": "2026-08-02T14:03:11.000Z"
}
```

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

<Note>
  **`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.
</Note>

### `app.uninstalled`

```json theme={null}
{
  "type": "app.uninstalled",
  "data": {
    "installationId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
    "appId": "d0e1f2a3-b4c5-4d6e-7f8a-9b0c1d2e3f4a",
    "organizationId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "organizationName": "Dermatology & Cosmetic Center",
    "uninstalledAt": "2026-08-02T14:09:44.000Z"
  },
  "timestamp": "2026-08-02T14:09:44.000Z"
}
```

<Warning>
  Stop all background processing for that organization when you receive this. Your
  API calls for it will already be failing — see [After an
  uninstall](#after-an-uninstall) — but you should not be relying on failed reads
  as your stop signal.
</Warning>

### `app.suspended` and `app.reactivated`

```json theme={null}
{
  "type": "app.suspended",
  "data": {
    "installationId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
    "appId": "d0e1f2a3-b4c5-4d6e-7f8a-9b0c1d2e3f4a",
    "organizationId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "organizationName": "Dermatology & Cosmetic Center",
    "status": "suspended",
    "reason": "billing failure",
    "changedAt": "2026-08-02T14:20:00.000Z"
  },
  "timestamp": "2026-08-02T14:20:00.000Z"
}
```

A suspension has the same immediate effect as an uninstall — your API calls stop
working — but it is reversible. Stop processing; keep the tenant.

<Warning>
  **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.
</Warning>

### `installation.permissions_updated`

```json theme={null}
{
  "type": "installation.permissions_updated",
  "data": {
    "installationId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
    "appId": "d0e1f2a3-b4c5-4d6e-7f8a-9b0c1d2e3f4a",
    "organizationId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "organizationName": "Dermatology & Cosmetic Center",
    "grantedPermissions": [],
    "previousGrantedPermissions": ["read:phone_numbers"],
    "updatedAt": "2026-08-02T14:25:00.000Z"
  },
  "timestamp": "2026-08-02T14:25:00.000Z"
}
```

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.

<Warning>
  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.
</Warning>

**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](#reconciliation).

## Verifying signatures

Requests are signed with [Standard Webhooks](https://www.standardwebhooks.com),
delivered through Svix. Three headers are sent:

| Header           | Meaning                                                |
| ---------------- | ------------------------------------------------------ |
| `svix-id`        | Unique message id — also your idempotency key          |
| `svix-timestamp` | Unix seconds when the message was sent                 |
| `svix-signature` | Space-separated list of `v1,<base64 signature>` values |

<Note>
  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.
</Note>

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:

```javascript theme={null}
import { Webhook } from "svix";

const wh = new Webhook(process.env.MAXAI_WEBHOOK_SECRET);

// Two things matter here:
//  - `payload` is the RAW body string, not a parsed object.
//  - pass the headers wholesale. The library lowercases them and accepts either
//    the `svix-` or the `webhook-` spelling; hand-picking one prefix is how you
//    end up verifying empty strings and rejecting every delivery.
const event = wh.verify(payload, req.headers);
```

## Reconciliation

Webhooks give you latency. This endpoint gives you correctness:

```http theme={null}
GET /v4/marketplace/installations
Authorization: Bearer <your app API key>
```

It returns every organization that has installed your app, **in every status,
including `uninstalled`**:

```json theme={null}
{
  "data": {
    "installations": [
      {
        "installationId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
        "organizationId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
        "organizationName": "Dermatology & Cosmetic Center",
        "status": "active",
        "versionId": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
        "grantedPermissions": ["read:phone_numbers"],
        "settings": { "reminderWindowDays": 30 },
        "installedAt": "2026-08-02T14:03:11.000Z",
        "uninstalledAt": null
      }
    ]
  }
}
```

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](#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:

```
401 Application is not installed for this organization
```

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.
