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

# Receive webhooks

> Subscribe a URL to Brelio events, verify each delivery's signature in Node.js or Python, and handle retries.

Instead of polling, your server receives a signed `POST` when something happens in the clinic. Brelio signs deliveries with the [Standard Webhooks](https://www.standardwebhooks.com) scheme, so any Standard Webhooks library verifies them.

> [!NOTE]
>
> In this version the only event you can subscribe to is `webhookEndpoint.tested`, the test delivery. Appointment and patient events are next; `GET /v1/webhook-event-types` always lists what is available.

## 1. List the event types

```bash theme={null}
curl https://api.brelio.app/v1/webhook-event-types \
  -H "Authorization: Bearer $BRELIO_API_KEY"
```

```json Response theme={null}
{
  "data": [
    {
      "type": "webhookEndpoint.tested",
      "version": 1,
      "description": "A test delivery was requested for a webhook endpoint.",
      "module": "webhooks",
      "privacy": "operational"
    }
  ]
}
```

`privacy` is `operational` or `personal` (about patients). Clinical events are never delivered by webhook. `personal` events are delivered only while the clinic's `patients` [data area](https://docs.brelio.com/en/concepts/data-areas.md) is on; patient fields in other events are sent as `null` while it is off.

## 2. Create the webhook

Your key needs `integrations.keys.manage`. The URL must be `https` on a public host name (no IP addresses, no `localhost`).

```bash theme={null}
curl -X POST https://api.brelio.app/v1/webhooks \
  -H "Authorization: Bearer $BRELIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "url": "https://hooks.example.com/brelio",
    "description": "Clinic CRM",
    "eventTypes": ["webhookEndpoint.tested"]
  }'
```

```json 201 Created theme={null}
{
  "id": "e91750cd-0f3b-4acb-800a-1c7323e66bd7",
  "url": "https://hooks.example.com/brelio",
  "description": "Clinic CRM",
  "eventTypes": ["webhookEndpoint.tested"],
  "status": "enabled",
  "secretHint": "whsec_…6OQ=",
  "secretCreatedAt": "2026-11-09T18:01:30.790Z",
  "previousSecretExpiresAt": null,
  "failingSince": null,
  "disablesAt": null,
  "disabledAt": null,
  "createdBy": "api_key_85C593155A6442B58528D38FC59B0CE9",
  "version": 1,
  "createdAt": "2026-11-09T18:01:30.790Z",
  "updatedAt": "2026-11-09T18:01:30.790Z",
  "secret": "whsec_iSiK6+Zxx2+8QtctJLM+19HZZ+hc9hlmlXi67gh66OQ="
}
```

Store `secret` now, in your secret manager: it is shown only here and when you rotate it. Webhooks are created over REST only, never over MCP, so the secret never reaches an AI model.

## 3. Verify every delivery

Each delivery is a `POST` with a JSON body and three headers:

```http theme={null}
POST /brelio HTTP/1.1
Content-Type: application/json
User-Agent: Brelio-Webhooks/1
webhook-id: 5c2f8b7e-1d3a-4e6f-8a9b-0c1d2e3f4a5b
webhook-timestamp: 1794247290
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"id":"0d8b3c1e-…","type":"webhookEndpoint.tested","version":1,"timestamp":"2026-11-09T18:01:30.851Z","data":{"endpointId":"e91750cd-0f3b-4acb-800a-1c7323e66bd7"}}
```

The signature is `base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}"))`, where `key` is the base64-decoded part of your secret after `whsec_`. To verify:

1. Compute the signature over the **raw** body bytes, before any JSON parsing.
2. Compare it in constant time with each `v1,` entry of `webhook-signature` (space-separated: during a secret rotation there are two).
3. Reject timestamps more than 5 minutes from your clock, to stop replays.
4. Answer `2xx` quickly; do the work afterwards.

```javascript Node.js theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'

const SECRET = process.env.BRELIO_WEBHOOK_SECRET // whsec_…
const TOLERANCE_SECONDS = 5 * 60

/** Standard Webhooks: HMAC-SHA256 of `${id}.${timestamp}.${body}` with the base64 key after `whsec_`. */
export function verifyBrelioWebhook(secret, headers, rawBody) {
  const id = headers['webhook-id']
  const timestamp = headers['webhook-timestamp']
  const signatures = headers['webhook-signature']
  if (!id || !timestamp || !signatures) return false
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest()
  // During a secret rotation Brelio signs with both secrets: `v1,<sig> v1,<sig>`.
  return signatures.split(' ').some((candidate) => {
    const [version, signature] = candidate.split(',')
    if (version !== 'v1' || !signature) return false
    const received = Buffer.from(signature, 'base64')
    return received.length === expected.length && timingSafeEqual(received, expected)
  })
}

const seen = new Set() // use your database in production

createServer((req, res) => {
  let rawBody = ''
  req.on('data', (chunk) => (rawBody += chunk))
  req.on('end', () => {
    if (!verifyBrelioWebhook(SECRET, req.headers, rawBody)) {
      res.writeHead(400).end('invalid signature')
      return
    }
    const id = req.headers['webhook-id']
    if (!seen.has(id)) {
      seen.add(id)
      const event = JSON.parse(rawBody)
      console.log('received', event.type, event.id)
    }
    res.writeHead(204).end()
  })
}).listen(3000)
```

```python Python (Flask) theme={null}
import base64, hashlib, hmac, json, os, time
from flask import Flask, request

SECRET = os.environ["BRELIO_WEBHOOK_SECRET"]  # whsec_…
TOLERANCE_SECONDS = 5 * 60


def verify_brelio_webhook(secret: str, headers, raw_body: bytes) -> bool:
    """Standard Webhooks: HMAC-SHA256 of f"{id}.{timestamp}.{body}" with the base64 key after whsec_."""
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures or not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    # During a secret rotation Brelio signs with both secrets: "v1,<sig> v1,<sig>".
    return any(
        candidate.startswith("v1,") and hmac.compare_digest(candidate[3:], expected)
        for candidate in signatures.split(" ")
    )


app = Flask(__name__)
seen: set[str] = set()  # use your database in production


@app.post("/brelio")
def brelio_webhook():
    raw_body = request.get_data()  # the exact bytes, before any JSON parsing
    if not verify_brelio_webhook(SECRET, request.headers, raw_body):
        return "invalid signature", 400
    msg_id = request.headers["webhook-id"]
    if msg_id not in seen:
        seen.add(msg_id)
        event = json.loads(raw_body)
        print("received", event["type"], event["id"])
    return "", 204
```

Or use the official Standard Webhooks libraries, which do the same checks:

```javascript Node.js (standardwebhooks) theme={null}
import { createServer } from 'node:http'
import { Webhook } from 'standardwebhooks'

const webhook = new Webhook(process.env.BRELIO_WEBHOOK_SECRET.replace(/^whsec_/, ''))

createServer((req, res) => {
  let rawBody = ''
  req.on('data', (chunk) => (rawBody += chunk))
  req.on('end', () => {
    try {
      const event = webhook.verify(rawBody, req.headers)
      console.log('received', event.type, event.id)
      res.writeHead(204).end()
    } catch {
      res.writeHead(400).end('invalid signature')
    }
  })
}).listen(3000)
```

```python Python (standardwebhooks) theme={null}
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook

wh = Webhook(os.environ["BRELIO_WEBHOOK_SECRET"])
app = Flask(__name__)

@app.post("/brelio")
def brelio_webhook():
    try:
        event = wh.verify(request.get_data(), dict(request.headers))
    except Exception:
        return "invalid signature", 400
    print("pylib ok", event["type"])
    return "", 204
```

Install them with `npm install standardwebhooks` or `pip install standardwebhooks`.

## 4. Send a test

```bash theme={null}
curl -X POST https://api.brelio.app/v1/webhooks/$WEBHOOK_ID/test \
  -H "Authorization: Bearer $BRELIO_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

```json Response theme={null}
{ "id": "e91750cd-0f3b-4acb-800a-1c7323e66bd7", "eventType": "webhookEndpoint.tested" }
```

The test is delivered to this webhook whatever it subscribes to. At most 5 tests per minute per webhook (`429 WEBHOOK_TEST_RATE_LIMITED`).

## The delivery body

| Field | Meaning |
| - | - |
| `id` | The event's id. The same event delivered to two webhooks has the same `id`. |
| `type` | The event type, for example `webhookEndpoint.tested`. |
| `version` | The version of the event's `data` shape. A breaking change ships as a new version. |
| `timestamp` | When the event happened, ISO 8601. |
| `data` | The event's payload. |

## Retries and duplicates

* A delivery succeeds when your server answers `2xx` within 15 seconds. Redirects are not followed.
* A failed delivery is retried after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and then every 12 h, for 3 days.
* If nothing gets through for 3 days, the webhook is **disabled** and the clinic's owners are notified. `failingSince` and `disablesAt` on the webhook tell you it is coming.
* `webhook-id` is the delivery's id and stays the same on every retry and on a manual redelivery. Store the ids you processed and skip repeats.
* Deliveries can arrive out of order. Use `timestamp`, or read the record from the API, when order matters.

## Diagnose a webhook

```bash theme={null}
curl https://api.brelio.app/v1/webhooks/$WEBHOOK_ID \
  -H "Authorization: Bearer $BRELIO_API_KEY"
```

`recentDeliveries` shows each delivery's `status` (`pending`, `succeeded`, `failed`, `skipped`), `attempts`, `lastStatusCode`, `lastError` and `nextAttemptAt`. After fixing your server, send a delivery again now instead of waiting:

```bash theme={null}
curl -X POST https://api.brelio.app/v1/webhooks/$WEBHOOK_ID/redeliver \
  -H "Authorization: Bearer $BRELIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"deliveryId": "5c2f8b7e-1d3a-4e6f-8a9b-0c1d2e3f4a5b"}'
```

## Rotate the secret

```bash theme={null}
curl -X POST https://api.brelio.app/v1/webhooks/$WEBHOOK_ID/rotate-secret \
  -H "Authorization: Bearer $BRELIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"expectedVersion": 1}'
```

The answer carries the new `secret`. For 24 hours (`previousSecretExpiresAt`) deliveries are signed with both secrets, so you can deploy the new one without missing anything. The receivers above already accept either signature.

## Delete it

```bash theme={null}
curl -X POST https://api.brelio.app/v1/webhooks/$WEBHOOK_ID/delete \
  -H "Authorization: Bearer $BRELIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"expectedVersion": 2}'
```

```json Response theme={null}
{ "id": "e91750cd-0f3b-4acb-800a-1c7323e66bd7", "deleted": true }
```

Deleting removes the webhook and its delivery log; nothing more is sent.


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