# Webhooks (/api/webhooks)

A webhook endpoint is an address on your server. When something changes in Taad, we send it an HTTP `POST` with the event, signed so you know it came from us.

## Add an endpoint [#add-an-endpoint]

In **Settings › Developers › Webhooks**, or with the API (a key with the `webhooks` scope):

```bash
curl https://api.taad.app/v1/webhook_endpoints \
  -H "Authorization: Bearer $TAAD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/taad-webhooks", "events": ["recording.ready", "todo.created"] }'
```

The answer carries the endpoint's **signing secret** (`whsec_…`), shown this once. Leave `events` out to get every event. The address must be `https` and reachable from the internet.

An endpoint added with a personal key hears about your Personal workspace; one added with an organization's key hears about that organization; one added with a test key hears about your [sandbox](/api/sandbox).

## Events [#events]

| Event | When |
| --- | --- |
| `recording.created` | a recording you sent through the API was received |
| `recording.ready` | a recording's transcript and notes are ready |
| `recording.failed` | a recording couldn't be transcribed |
| `recording.updated` | its title, transcript, notes, speakers or workspace changed (`data.changed` says which) |
| `recording.removed` | it was deleted, merged into another, or moved out of reach (`data.reason`) |
| `todo.created`, `todo.updated`, `todo.completed`, `todo.deleted` | a to-do changed |
| `calendar_event.created`, `calendar_event.updated`, `calendar_event.deleted` | a calendar event changed |
| `usage.balance_low`, `usage.exhausted` | your API wallet is under $1, or empty |
| `endpoint.test` | you pressed Send test (sent only to that endpoint, never listed) |

## What arrives [#what-arrives]

```json
{
  "id": "evt_06F8Q3W1ZJ4K2M9N7P5R3T1V0X",
  "object": "event",
  "type": "recording.ready",
  "created_at": "2026-11-03T10:03:40.000Z",
  "livemode": true,
  "data": {
    "object": { "id": "rec_06F8Q3W1ZJ4K2M9N7P5R3T1V0X", "object": "recording", "title": "Pricing call with Northwind", "status": "ready" },
    "transcript": [{ "speaker": { "id": "sp_1", "name": "Sara", "is_you": true }, "start_ms": 1200, "end_ms": 5400, "text": "Shall we start with the numbers?" }],
    "notes": { "summary_markdown": "…", "taad_notes_markdown": "…" }
  }
}
```

Everything is in the event, the transcript and notes included, so most flows need no second call. When an event and a `GET` disagree (events can arrive late or twice), the `GET` is right.

## Check the signature [#check-the-signature]

Every delivery is signed with [Standard Webhooks](https://www.standardwebhooks.com), so any of its libraries can check it. The headers:

| Header | What |
| --- | --- |
| `webhook-id` | the event's id: the same on every retry |
| `webhook-timestamp` | when this attempt was sent, in seconds |
| `webhook-signature` | `v1,` and a base64 HMAC-SHA256 |

The signature is over `{webhook-id}.{webhook-timestamp}.{the raw body}`, keyed with your secret's bytes (base64 after `whsec_`). Check it against the raw body, before parsing the JSON, and refuse timestamps more than five minutes old.

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

export function isFromTaad(headers: Headers, rawBody: string, secret: string): boolean {
  const id = headers.get("webhook-id") ?? "";
  const ts = headers.get("webhook-timestamp") ?? "";
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = Buffer.from(createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64"));
  return (headers.get("webhook-signature") ?? "").split(" ").some((part) => {
    const [version, sig = ""] = part.split(",");
    const given = Buffer.from(sig);
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

While you **rotate** a secret, deliveries are signed with both the old and the new one for 24 hours, so you can switch without missing any.

## Answering, retries and replays [#answering-retries-and-replays]

- **Answer `2xx` within 10 seconds**, then do the work. Anything else counts as a failure.
- **408, 429, 5xx, timeouts and network errors are retried**: after 30 seconds, 2, 5, 15 and 30 minutes, then 1, 2, 6, 12 and 24 hours. Redirects aren't followed; other `4xx` answers aren't retried.
- **Deliveries can arrive twice or out of order**: skip an `id` you've already handled.
- **After four days without a success** the endpoint is turned off and we email you. When you turn it back on (Settings, or `POST /v1/webhook_endpoints/{id}/enable`), **everything it missed is sent again**.

## Catching up [#catching-up]

Every event is kept for 30 days, delivered or not, and any key can list them, though a key only sees the kinds of events its scopes cover (recordings need `recordings:read`, to-dos `todos:read`, calendar events `calendar:read`; a key with `webhooks` sees them all, as its endpoints receive them). `GET /v1/events?after=<cursor>` lists what happened since your last look, so a system that was down can catch up without webhooks at all. A cursor older than 30 days answers `410 cursor_expired`: start again from `GET /v1/recordings?updated_after=…`.
