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

# Webhooks

> Get an HTTPS call from Aniva when an appointment, a home kit order, its results, a location, a profile or a shipment changes

A webhook is an HTTPS `POST` that Aniva sends to your server when something changes. You do not
need to poll.

Events are thin. They carry the object id, the new status, a revision and the `url` to read. They
never carry lab values or personal data. Read the `url` with your API key to get the data.

## Quick setup

1. Build an HTTPS endpoint that accepts `POST` and answers `2xx` within 15 seconds.
2. Register it with [Create Webhook](/api/webhooks/create-webhook).
   Store the `secret` from the response. Aniva shows it only once.
3. [Verify the signature](#verify-the-signature) of every request.
4. Send a [test event](/api/webhooks/send-test-event). Check the result in
   [List Deliveries](/api/webhooks/list-deliveries).

Your API key needs the `webhooks_manage` scope. Ask Aniva to add it. A webhook belongs to the
API key that created it, and a key can own at most 5 webhooks.

## Events

You receive an event only for objects your API key can read with the matching `GET` endpoint.
Choose the types with `enabled_events`. Send `["*"]` to receive every type, also types added later.

| Type | When it fires |
| - | - |
| `appointment.created` | The appointment becomes visible to you. Sent once per appointment. |
| `appointment.pending` | The appointment moves into `pending`. |
| `appointment.confirmed` | The appointment moves into `confirmed`. |
| `appointment.blood_drawn` | The appointment moves into `blood_drawn`. |
| `appointment.cancelled` | The appointment moves into `cancelled`. |
| `appointment.rescheduled` | `scheduled_at` or the location changes. Not sent when the appointment is `cancelled`. |
| `appointment.results.updated` | The results change. |
| `appointment.results.completed` | The results `status` moves into `completed`. |
| `appointment.results.fault` | The results `status` moves into `fault`. |
| `home_kit_order.created` | The order is created. Sent once per order. |
| `home_kit_order.pending` | The order moves into `pending`. |
| `home_kit_order.dispatched` | The order moves into `dispatched`. |
| `home_kit_order.activated` | The order moves into `activated`. |
| `home_kit_order.cancelled` | The order moves into `cancelled`. |
| `home_kit_order.results.updated` | The results change. |
| `home_kit_order.results.completed` | The results `status` moves into `completed`. |
| `home_kit_order.results.fault` | The results `status` moves into `fault`. |
| `location.created` | The location becomes visible to you: new, activated, or added to a group you read. |
| `location.updated` | Any field of the location changes: name, address, website, opening hours, image. |
| `location.deactivated` | You can no longer read the location. |
| `location.retail_prices.updated` | A published panel or biomarker price at the location changes. Needs the `retail_prices_view` scope. |
| `profile.created` | The profile becomes visible to you: new, or added to a profile group you read. |
| `profile.updated` | Any field of [Get Profile](/api/profiles/get-profile) changes. |
| `profile.deleted` | The profile is deleted, or you can no longer read it. |
| `shipment.created` | The shipment becomes visible to you. |
| `shipment.updated` | Any field of the shipment changes: tracking, scans, pickup, notes, links. |
| `shipment.pending` | The shipment moves into `pending`. |
| `shipment.in_transit` | The shipment moves into `in_transit`. |
| `shipment.delivered` | The shipment moves into `delivered`. |
| `shipment.fault` | The shipment moves into `fault`. |
| `shipment.cancelled` | The shipment moves into `cancelled`. |
| `ping` | You sent a test event. Always sent, not part of `enabled_events`. |

* A status event fires on every move into that status, also a move back. For example, a kit that
  goes from `dispatched` back to `pending` sends `home_kit_order.pending`.
* One change can send two events with the same `status_revision`, for example
  `appointment.confirmed` and `appointment.rescheduled`.
* `results.updated` fires on every change of the results: a new value, a correction, a fault, a new
  or replaced document, or a sample that leaves the object. It arrives within a few minutes of the
  last change.
* `results.completed` and `results.fault` always come with a `results.updated` of the same
  `results_revision`. Results can reopen and complete again, so you can get a second
  `results.completed`.

Subscribe to `results.updated` to show partial results, or to `results.completed` to act only on
final results.

`results.completed` follows the result values, not the PDF. The report PDF can arrive before or
after the values. Some labs first send a preliminary PDF and later replace it with the final one
under the same document `id`. The replacement sends `results.updated`. Read
`documents[].original_file_name` to tell the two apart: Labor Quade ends a preliminary report
(Vorbefund) in `_V` and the final report (Endbefund) in `_E`, for example
`7642100312_B0012_FA_E.pdf`.

Location, profile and shipment events arrive within a few minutes. Aniva waits until the object has
not changed for 2 minutes, so a burst of edits sends one event. These rules also apply:

* Anonymous profiles send no events.
* A shipment sends no event when you lose access to it.
* A shipment status change sends `shipment.updated` and the `shipment.<status>` event with the same
  `revision`.

## Payload

| Field | Description |
| - | - |
| `id` | Event id (UUID). Equal to the `webhook-id` header. Stays the same on retry and resend. |
| `type` | Event type, for example `appointment.confirmed`. |
| `timestamp` | When the change happened, as an ISO 8601 UTC instant. Not the time of the send. |
| `data` | The changed object. The shape depends on the event type. |

### Status event data

| Field | Description |
| - | - |
| `object` | `appointment` or `home_kit_order`. |
| `id` | The appointment or home kit order id (UUID). |
| `url` | `/api/v1/appointments/{id}` or `/api/v1/home-kit-orders/{id}`. |
| `status` | The status after the change. |
| `previous_status` | The status before the change. `null` on `created`. |
| `status_revision` | Counter per object. It goes up with each status change and can skip numbers. |

```json appointment.confirmed theme={null}
{
  "id": "0b8e6f3a-2c4d-4e7f-9a1b-3c5d7e9f1a2b",
  "type": "appointment.confirmed",
  "timestamp": "2026-10-15T09:12:44.512Z",
  "data": {
    "object": "appointment",
    "id": "c9f3e2a1-7d6b-4c5e-b3a2-1f0e9d8c7b6a",
    "url": "https://anivahealth.com/api/v1/appointments/c9f3e2a1-7d6b-4c5e-b3a2-1f0e9d8c7b6a",
    "status": "confirmed",
    "previous_status": "pending",
    "status_revision": 2
  }
}
```

### Results event data

| Field | Description |
| - | - |
| `object` | `appointment` or `home_kit_order`. |
| `id` | The appointment or home kit order id (UUID). |
| `url` | `/api/v1/appointments/{id}/results` or `/api/v1/home-kit-orders/{id}/results`. |
| `results_status` | `in_progress`, `completed` or `fault`. |
| `previous_results_status` | The `results_status` of the previous results event. `null` on the first one. |
| `results_revision` | Counter per object. It goes up with each results change and can skip numbers. |

```json home_kit_order.results.completed theme={null}
{
  "id": "6d2a9c4e-8f1b-4a3d-b5e7-0c2f4a6b8d1e",
  "type": "home_kit_order.results.completed",
  "timestamp": "2026-10-15T14:03:00.000Z",
  "data": {
    "object": "home_kit_order",
    "id": "d1e2f3a4-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
    "url": "https://anivahealth.com/api/v1/home-kit-orders/d1e2f3a4-5b6c-4d7e-8f9a-0b1c2d3e4f5a/results",
    "results_status": "completed",
    "previous_results_status": "in_progress",
    "results_revision": 4
  }
}
```

### Location, profile and shipment event data

| Field | Description |
| - | - |
| `object` | `location`, `profile` or `shipment`. |
| `id` | The location or profile id (UUID), or the shipment reference. |
| `url` | `/api/v1/locations/{id}`, `/api/v1/locations/{id}/retail-prices`, `/api/v1/profiles/{id}` or `/api/v1/shipments/{id}`. Absent on `.deactivated` and `.deleted`. |
| `status` | Shipments only. The status after the change. |
| `previous_status` | Shipments only. The status before the change. `null` on `shipment.created`. |
| `revision` | Counter per object across all its events. It can skip numbers. Retail price events have their own counter. |

```json shipment.in_transit theme={null}
{
  "id": "3f7c1a2b-9d4e-4b6a-8c5f-2e1d0a9b8c7d",
  "type": "shipment.in_transit",
  "timestamp": "2026-10-16T08:41:12.000Z",
  "data": {
    "object": "shipment",
    "id": "P2610164821",
    "url": "https://anivahealth.com/api/v1/shipments/P2610164821",
    "status": "in_transit",
    "previous_status": "pending",
    "revision": 3
  }
}
```

A `ping` carries `data.object` `webhook` and `data.id`, the webhook id.

## Verify the signature

Events carry no data, but you should still verify each one. A valid signature proves that the
event came from Aniva. Each request carries the headers `webhook-id`, `webhook-timestamp` (Unix
seconds) and `webhook-signature`.

1. Take the raw request body. Verify it before you parse the JSON.
2. Build the signed content `{webhook-id}.{webhook-timestamp}.{raw body}`.
3. Base64-decode the part of your secret after `whsec_`. Use it as the key.
4. Compute HMAC-SHA256 of the signed content and encode it as base64.
5. Compare it in constant time with each `v1,<signature>` entry in `webhook-signature`. The entries
   are separated by spaces. One match is enough.
6. Reject the request when `webhook-timestamp` is more than 5 minutes from your clock.

```typescript Node.js theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyAnivaWebhook(rawBody: string, headers: Headers, secret: string): boolean {
  const id = headers.get('webhook-id') ?? '';
  const timestamp = headers.get('webhook-timestamp') ?? '';
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();

  return (headers.get('webhook-signature') ?? '').split(' ').some((entry) => {
    const [version, signature = ''] = entry.split(',');
    const received = Buffer.from(signature, 'base64');
    return (
      version === 'v1' && received.length === expected.length && timingSafeEqual(received, expected)
    );
  });
}
```

The scheme is [Standard Webhooks](https://www.standardwebhooks.com). Standard Webhooks libraries
exist for most languages and do this in one call.

### Rotate the secret

Call [Rotate Secret](/api/webhooks/rotate-secret) to get a new secret. For 24 hours, Aniva
signs with the old and the new secret, so deploy the new one within that time. After a leak, send
`revoke_current_secret_now: true` to stop the old secret at once.

## Delivery

* Answer `2xx` within 15 seconds. Store or queue the event and do the work later.
* Use an `https` URL on port 443 with a public host. Aniva does not follow redirects: a `3xx`
  counts as a failure.
* Delivery is at least once and unordered. Drop duplicates by `webhook-id`. Use the revisions to
  ignore an older event.
* Answer `410 Gone` to stop the events. Aniva disables the webhook at once.
* Aniva keeps deliveries for 30 days. After that, you cannot list or resend them.

### Retries

A non-`2xx` answer, a timeout or a network error starts a retry. Each event gets up to 10 attempts
over about three days:

| Attempt | Time after the first attempt |
| - | - |
| 2 | about 5 seconds |
| 3 | about 5 minutes |
| 4 | about 35 minutes |
| 5 | about 2.5 hours |
| 6 | about 7.5 hours |
| 7 | about 17.5 hours |
| 8 | about 27.5 hours |
| 9 | about 51.5 hours |
| 10 | about 75.5 hours |

After attempt 10, the delivery is `failed`. Send it again with
[Resend Delivery](/api/webhooks/resend-delivery). It keeps the same `webhook-id`.

### Failure notices

Aniva emails the webhook's `alert_emails` when no delivery succeeded for 24 hours, when an event is
`failed`, and when the webhook is disabled. After 5 days without a successful delivery, Aniva
disables the webhook. A disabled webhook gets no new events, and its waiting deliveries are
cancelled.

| `disabled_reason` | Cause |
| - | - |
| `Disabled by the partner` | You sent `status: "disabled"`. |
| `Disabled by Aniva` | Aniva disabled the webhook. Only Aniva can enable it again. |
| `The endpoint answered 410 Gone` | Your endpoint answered `410 Gone`. |
| `No successful delivery for 5 days` | No delivery succeeded for 5 days. |

To start again, fix your endpoint and send `status: "enabled"` with
[Update Webhook](/api/webhooks/update-webhook). Missed events are not sent
again by themselves. Resend them, or read the current state with the `GET` endpoints.

## Best practices

* **Drop duplicates.** Skip an event whose `webhook-id` you already processed.
* **Use the revisions.** Store the highest `status_revision`, `results_revision` and `revision`
  per object. Ignore an event with a lower one.
* **Fetch the `url`.** The event says that something changed. The `GET` endpoint says what.
* **Answer fast.** Queue the event and answer `2xx` before you process it.
* **Reconcile.** Run a regular job that reads open objects with the `GET` endpoints. Polling stays a
  valid fallback.
* **Test after each deploy.** Use [Send Test Event](/api/webhooks/send-test-event).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.