Webhooks
Hear about new recordings, notes, to-dos and events the moment they happen.
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
In Settings › Developers › Webhooks, or with the API (a key with the webhooks scope):
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.
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
{
"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
Every delivery is signed with Standard Webhooks, 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.
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
- Answer
2xxwithin 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
4xxanswers aren't retried. - Deliveries can arrive twice or out of order: skip an
idyou'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
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=….