# Webhooks

> Signed, retried, thin events for resource writes, consent changes and finalized documents, with signature verification in TypeScript, Dart and Python.

Source: https://developers.anpheros.com/guides/webhooks

Anpheros Platform can call your server when something happens in your project: a resource
was written, a person granted or revoked access, a document was finalized. Events are
**thin**: ids, type, version — never clinical content. Read the resource through the API
if you need it. Every delivery is signed; verify the signature before trusting it.

## Register an endpoint

```bash
curl -X POST $BASE/v1/webhooks -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://your.app/anpheros/hook", "events": ["resource.created", "consent.revoked"], "description": "prod"}'
```

The response contains `secret` (`whsec_…`) **once**. Store it; you need it to verify signatures.
`events` accepts `["*"]` or any of:

| type | when | `data` |
|---|---|---|
| `resource.created` / `resource.updated` / `resource.deleted` | your project wrote a FHIR resource (REST v1, FHIR API or a bundle) — **or another project wrote into a record you have access to** (you created the patient, or you hold an active grant); then `data.external` is `true` and `data.source` says who wrote it: `author_type` (patient / practitioner / device / import / ai), `organization` (the display name the writer acted on behalf of), `practitioner` (name from the directory, when known), `source_system` | `resource_type`, `id`, `patient` (the id you see), `version`, `last_updated`, and for external writes `external`, `source` |
| `consent.granted` | a person granted your app access from the consent page (or a trusted app linked its record) | `grant_id`, `patient`, `application_id`, `scopes`, `purpose`, `expires_at` |
| `consent.revoked` | the person (or the platform) ended a grant | same as above plus `revoked_at`, `reason` |
| `document.finalized` | a document upload was finalized and checksummed | `id`, `patient`, `content_type`, `size`, `sha256` |
| `ping` | you asked for a test delivery | `endpoint` |

Endpoints are per project **and environment**: a `sk_test_` key manages sandbox endpoints,
a `sk_live_` key production ones. URLs must be `https://` (in sandbox, `http://localhost` is fine).
Up to 10 endpoints per project and environment.

## The delivery

```
POST https://your.app/anpheros/hook
Content-Type: application/json
User-Agent: Anpheros-Webhooks/1.0
Anpheros-Event: resource.created
Anpheros-Event-Id: evt_…
Anpheros-Delivery: whd_…
Anpheros-Signature: t=1789800000,v1=5f3a…

{"id":"evt_…","type":"resource.created","created_at":"2026-09-19T12:00:00.000Z","environment":"production",
 "project":"proj_…","data":{"resource_type":"Observation","id":"…","patient":"…","version":1,"last_updated":"…"}}
```

Respond with any `2xx` within 10 seconds. Anything else (or a timeout) is a failure and the
delivery is retried with backoff for up to 24 hours (10 attempts), then marked `exhausted`.
Deliveries are at-least-once and may arrive out of order: use `Anpheros-Event-Id` to
de-duplicate and `data.version` to ignore stale updates.

## Verify the signature

`v1` is HMAC-SHA256 with your secret over `<t>.<raw body>`; reject if `t` is older than 5 minutes.

```ts
import { verifyWebhookSignature } from '@anpheros/sdk';
app.post('/anpheros/hook', express.raw({ type: 'application/json' }), async (req, res) => {
  const ok = await verifyWebhookSignature({ secret: process.env.ANPHEROS_WEBHOOK_SECRET!, header: req.header('anpheros-signature'), body: req.body });
  if (!ok) return res.status(400).end();
  const event = JSON.parse(req.body.toString());
  // … enqueue and return quickly
  res.status(204).end();
});
```

```dart
import 'package:anpheros_sdk/anpheros_sdk.dart';
final ok = verifyWebhookSignature(secret: secret, header: request.headers['anpheros-signature'], body: rawBodyBytes);
```

```python
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

## Operate

- `GET /v1/webhooks/{id}/deliveries?status=failed` — the log, newest first, with status code and error.
- `POST /v1/webhooks/{id}/deliveries/{delivery_id}/redeliver` — send one again now.
- `POST /v1/webhooks/{id}/ping` — a test event.
- `POST /v1/webhooks/{id}/rotate` — new secret (the old one stops immediately; update your server first if you can't afford a gap: register a second endpoint, then delete the old one).
- `PATCH /v1/webhooks/{id}` with `{"active": false}` pauses deliveries; pending ones are dropped when you delete the endpoint.

Both SDKs expose this under `webhooks` (`create`, `list`, `get`, `update`, `delete`, `rotateSecret`, `ping`, `deliveries`, `redeliver`).
