# Receive your first webhook

> Step 4: register a webhook endpoint, verify the Anpheros-Signature HMAC in Node and Python, send a test ping, and handle retries, the delivery log and redelivery.

Source: https://developers.anpheros.com/guides/first-webhook

Step 4 of 5. Instead of asking the API every few minutes whether a record changed, register an
address and Anpheros sends you a short, signed event when something happens: a resource written, a
consent granted or revoked, a document finalized. Your server verifies the signature, then reads
the resource itself with its own credentials.

**In short:**

| | |
|---|---|
| Register | `POST /v1/webhooks` with `url` and `events` (key with `write` scope) |
| Secret | `whsec_…`, returned once, when you create the endpoint or rotate it |
| Signature | header `Anpheros-Signature: t=<unix time>,v1=<hex>`, HMAC-SHA256 of `"<t>." + raw body` |
| Accept within | 300 seconds of `t` |
| Test | `POST /v1/webhooks/{id}/ping` sends a `ping` event |
| Retries | up to 10 attempts with growing delays; the endpoint is disabled after 10 failures in a row |

<!-- visual:seq-webhook -->

## 1. Get a public HTTPS address

The address must be public and use `https`. While you develop, a tunnel works: run your server on
`localhost:3000` and expose it with a tunnelling tool of your choice, then use the `https://…`
address it gives you.

## 2. Register the endpoint

```bash
curl -X POST https://platform.anpheros.com/v1/webhooks \
     -H "Authorization: Bearer $ANPHEROS_KEY" -H 'content-type: application/json' -d '{
  "url": "https://your-tunnel.example/anpheros/webhook",
  "events": ["resource.created", "resource.updated", "consent.revoked"],
  "description": "dev laptop"
}'
```

The answer contains `id` and `secret` (`whsec_…`). **Save the secret now**: it is shown only once.
Leave `events` out to receive every event (`["*"]`). A project can have up to 10 endpoints per
environment. The list of event names: `GET /v1/webhooks/event-types`.

| Event | When |
|---|---|
| `resource.created`, `resource.updated`, `resource.deleted` | a FHIR resource in a record you can see was written |
| `consent.granted`, `consent.revoked` | a person approved or withdrew access for your app |
| `document.finalized` | an uploaded document is complete |
| `ping` | you asked for a test event |

## 3. Verify and handle the event

The event body is thin: it says what changed, not the medical data.

```json
{"id":"evt_…","type":"resource.created","created_at":"2026-11-08T10:15:02Z","environment":"sandbox","project":"proj_…","data":{"resource_type":"Observation","id":"…","patient":"…","version":1,"last_updated":"…"}}
```

Verify the signature on the **raw** body, before parsing it. Node (Express):

```js
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.ANPHEROS_WEBHOOK_SECRET;   // whsec_…
const app = express();

app.post('/anpheros/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const parts = Object.fromEntries(String(req.get('Anpheros-Signature') || '').split(',').map(p => p.split('=')));
  const t = Number(parts.t);
  const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.${req.body}`).digest('hex');
  const ok = parts.v1 && expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)) && Math.abs(Date.now() / 1000 - t) <= 300;
  if (!ok) return res.status(400).send('bad signature');

  const event = JSON.parse(req.body);
  res.sendStatus(204);                     // answer quickly, then work
  if (event.type === 'resource.created') {
    // read the resource with your own key: GET /fhir/R4/{resource_type}/{id}
  }
});

app.listen(3000);
```

Python (Flask):

```python
import hashlib, hmac, json, os, time
from flask import Flask, request

SECRET = os.environ["ANPHEROS_WEBHOOK_SECRET"].encode()
app = Flask(__name__)

@app.post("/anpheros/webhook")
def webhook():
    raw = request.get_data()                                    # bytes, exactly as sent
    parts = dict(p.split("=", 1) for p in request.headers.get("Anpheros-Signature", "").split(",") if "=" in p)
    expected = hmac.new(SECRET, parts.get("t", "").encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - int(parts.get("t", 0))) > 300:
        return "bad signature", 400
    event = json.loads(raw)
    print(event["type"], event["data"])
    return "", 204
```

The TypeScript and Dart SDKs have a ready verifier with the same rules: see [Webhooks](https://developers.anpheros.com/guides/webhooks).

Other headers on each delivery: `Anpheros-Event` (the type), `Anpheros-Event-Id` (the same as `id`;
store it and ignore repeats) and `Anpheros-Delivery`.

## 4. Send yourself a test event

```bash
curl -X POST https://platform.anpheros.com/v1/webhooks/$WEBHOOK_ID/ping -H "Authorization: Bearer $ANPHEROS_KEY"
```

Your server should log a `ping` event. Then write an observation (see [Your first write](https://developers.anpheros.com/guides/first-write))
and watch the `resource.created` event arrive.

## 5. When deliveries fail

Answer with a `2xx` within 10 seconds. Anything else, or no answer, is retried with growing delays,
up to 10 attempts; after 10 failed deliveries in a row the endpoint is disabled.

```bash
# what was delivered, and what failed
curl "https://platform.anpheros.com/v1/webhooks/$WEBHOOK_ID/deliveries?limit=50" -H "Authorization: Bearer $ANPHEROS_KEY"
# send the failed ones again, after you fixed your server
curl -X POST https://platform.anpheros.com/v1/webhooks/$WEBHOOK_ID/deliveries/redeliver-exhausted -H "Authorization: Bearer $ANPHEROS_KEY"
# turn it back on, or change the events
curl -X PATCH https://platform.anpheros.com/v1/webhooks/$WEBHOOK_ID -H "Authorization: Bearer $ANPHEROS_KEY" \
     -H 'content-type: application/json' -d '{"active": true}'
# new secret (the old one stops working)
curl -X POST https://platform.anpheros.com/v1/webhooks/$WEBHOOK_ID/rotate -H "Authorization: Bearer $ANPHEROS_KEY"
```

## Common errors

| Symptom | Fix |
|---|---|
| Every event fails the signature check | You verified the parsed and re-serialized JSON: verify the raw bytes, as above |
| `400` when registering | The `url` is not public `https`, or an event name is misspelled; see `GET /v1/webhooks/event-types` |
| Events stopped arriving | The endpoint was disabled after 10 failures: check the deliveries, fix, then `PATCH {"active": true}` |
| The same event twice | Deliveries can repeat: keep the `Anpheros-Event-Id` you processed and skip repeats |

## Next step

Step 5: [Go live](https://developers.anpheros.com/guides/go-live), from the sandbox to production with real patients.

## Frequently asked questions

### Why does the event not contain the data itself?
So that medical data never travels to an address unless your server asks for it with its own credentials, and so an old event never carries data the person has since withdrawn.

### Can I receive events for patients who use my app through OAuth?
Yes. Events cover the records your project can see, including those shared with your app by consent, and `consent.revoked` tells you when access ends.

### How do I test without a public server?
Use a tunnel to your laptop, as in step 1, and the `ping` endpoint to send test events whenever you want.
