# Webhooks

> Signed callbacks when a meeting starts, finishes, or is ready to read.

A webhook is an HTTPS endpoint of yours that we POST to when something happens. It replaces polling: the notetaker can sit in an hour-long call, and you hear about it once, when the notes exist.

## Events

| Event | Fires when |
| --- | --- |
| `meeting.started` | The notetaker has joined the call |
| `meeting.ended` | The call finished; processing has begun |
| `meeting.ready` | Transcript and summary are available |
| `meeting.failed` | The notetaker could not join, or processing failed |
| `calendar.connected` | Someone finished connecting a calendar |
| `calendar.disconnected` | A calendar was disconnected or its access was revoked |

## The payload

Every delivery carries the event name, a timestamp, and the affected record.

```json Body
{
  "id": "b7d41e02-5c93-4f18-a6d2-0e8c3b71f944",
  "type": "meeting.ready",
  "createdAt": "2026-08-12T10:02:14.000Z",
  "data": {
    "runId": "9f8c2e10-4b71-4d2a-8e93-1c7f5a604b18",
    "reference": "NB-3F2A-9C1D-7E5B",
    "meetingId": "3f2a9c1d-7e5b-4a80-9c11-2d90b8e4c177",
    "title": "Weekly product sync",
    "durationSeconds": 2740,
    "language": "en"
  }
}
```
```json Response 200
{ "received": true }
```

Respond `2xx` within ten seconds. Anything else is a failure, and we retry with exponential backoff — six attempts over about a minute, waiting 2s, 4s, 8s, 16s and 32s between them. A receiver that is down longer than that will miss the delivery, so read `GET /webhooks/{id}/deliveries` to catch up rather than relying on the retry.

**Warning: An endpoint that keeps failing is switched off**

After ten consecutive failures we stop sending to it and set `status: "disabled"`, with `disabledReason` saying what the last attempt said. Nothing is delivered again until you re-enable it:

```bash
curl -X PATCH https://api-notetaker.nabrah.ai/ext/v1/webhooks/$ID   -H "Authorization: Bearer $NABRAH_API_KEY"   -H "Content-Type: application/json"   -d '{"status":"active"}'
```

Re-enabling clears the failure count too — without that, one more failure would disable it again immediately. Check `GET /webhooks/{id}/deliveries` first to see what was actually going wrong.

**Danger: Redirects are never followed**

A `3xx` is a delivery failure, not a hop. Following one would hand the destination to whoever controls your endpoint's response, which would defeat the address checks we make before every send. Give us the final URL.

## What we will and will not send to

The URL must be `https`, on port 443, with no credentials in it, and it must resolve to a public address. Private ranges, loopback, link-local and the cloud metadata addresses are all refused — including when they are reached indirectly, such as an IPv4-mapped or NAT64-wrapped address.

This is checked when you register the endpoint **and again on every delivery**, because a hostname that resolves publicly today can resolve privately tomorrow.

## Verifying a delivery

Each request is signed with your endpoint's secret over `timestamp.body`. Compare in constant time, and reject anything older than five minutes.

```ts Node
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(request: Request, body: string, secret: string) {
  const timestamp = request.headers.get("X-Nabrah-Timestamp") ?? "";
  const signature = request.headers.get("X-Nabrah-Signature") ?? "";

  const age = Date.now() / 1000 - Number(timestamp);
  if (!Number.isFinite(age) || Math.abs(age) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);

  return a.length === b.length && timingSafeEqual(a, b);
}
```
```python Python
import hmac, hashlib, time

def verify(headers, body: str, secret: str) -> bool:
    timestamp = headers.get("X-Nabrah-Timestamp", "")
    signature = headers.get("X-Nabrah-Signature", "")

    try:
        if abs(time.time() - float(timestamp)) > 300:
            return False
    except ValueError:
        return False

    expected = hmac.new(
        secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)
```

**Danger: Sign against the raw body**

Verify before parsing. Re-serialising the JSON changes key order and whitespace, and the signature will never match.

## Delivery guarantees

### Can the same event arrive twice?
Yes. Delivery is at-least-once, and a network timeout after your `200` looks identical to a failure. Treat `id` as an idempotency key and ignore one you have already processed.

### Do events arrive in order?
No. `meeting.ready` can land before `meeting.ended` under retry. Order by `createdAt` if sequence matters to you.

### What happens when you give up on a delivery?
After the sixth attempt the delivery is marked `failed` and we stop. Separately, ten consecutive failed deliveries disable the endpoint itself, with `disabledReason` saying what the last attempt said.

Nothing is emailed about either — the endpoint's `status` and `disabledReason`, and `GET /webhooks/{id}/deliveries`, are where it shows. Every attempt is recorded there with its response status, timing and error, so poll that if you need to know rather than waiting to be told.
