# Guide to Recall.ai Webhooks: Features and Best Practices

Recall.ai webhooks tell your app what your meeting bots and recordings are doing: a bot is joining a call, waiting to be let in, recording, or done; a recording or transcript has finished processing; a user's calendar has changed; or a desktop recording has uploaded. During a call, real-time endpoints can also stream participant events and transcript data to you. If you build note-takers, sales tools or meeting analytics on Recall.ai, webhooks are how your app follows each bot from start to finish.

This guide covers the two webhook systems Recall.ai uses, how requests are signed and how that depends on when your account was created, the main events, the retry and timeout rules, and the best practices for running a Recall.ai receiver in production.

## What are Recall.ai webhooks?

Recall.ai sends webhooks in two ways.

**Svix webhooks** carry status changes: bots, recordings, transcripts and other media artifacts, Calendar V2 and Desktop SDK uploads. You add endpoints and subscribe to event types in the webhooks dashboard for your region. Recall.ai only sends the event types you subscribe to.

**Real-time endpoints** carry in-call data such as participant joins, chat messages and transcript segments. You set them on each bot when you create it. They don't appear in the webhooks dashboard and follow different retry rules.

Status webhooks share one shape: an `event` name and a `data` object holding the status (`data.data.code`, `sub_code` and `updated_at`) and the object it applies to, such as `data.bot`.

Signing depends on your account. Once you create a workspace verification secret, every request carries `webhook-id`, `webhook-timestamp` and `webhook-signature`. Until you do, those headers are not sent. Accounts created before 15 December 2025 sign Svix webhooks with each endpoint's own Svix secret and the `svix-*` headers instead.

## Recall.ai webhook features

| Feature | Details |
| --- | --- |
| Status webhooks | Svix, configured in the regional webhooks dashboard |
| Real-time data | Real-time endpoints, set per bot |
| Regions | us-east-1, us-west-2, eu-central-1, ap-northeast-1, each with its own dashboard |
| Event selection | Per endpoint. Unsubscribed events are not sent |
| Signature headers | `webhook-id`, `webhook-timestamp`, `webhook-signature` (or `svix-*` on legacy accounts) |
| Scheme | HMAC-SHA256, base64, over `{id}.{timestamp}.{raw body}` |
| Secret | Workspace verification secret, `whsec_` prefixed, opt-in. Legacy: per-endpoint Svix secret |
| Rotation | The old secret stays valid for 24 hours. At most 9 active secrets |
| Event name | Top-level `event` field |
| Timeout | 15 seconds. It can't be increased |
| Svix retries | Immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours |
| Svix recovery | **Recover failed messages** for a time window in the dashboard |
| Auto-disable | A Svix endpoint failing for 5 days is disabled. Email alerts are available |
| Real-time retries | 60 attempts at 1-second intervals, then the endpoint is marked failed. No replay |
| Source IPs | Svix webhooks come from static IPs. Real-time endpoints don't |
| Query parameters | Passed through on both kinds of URL |

## Common events

### Bot status events

| Event | Meaning |
| --- | --- |
| `bot.joining_call` | The bot is connecting. Can be sent more than once if a join is retried |
| `bot.in_waiting_room` | The bot is in the meeting's waiting room |
| `bot.in_call_not_recording` | The bot has joined but isn't recording |
| `bot.recording_permission_allowed` / `bot.recording_permission_denied` | The host allowed or denied recording. See `sub_code` for the reason |
| `bot.in_call_recording` | The bot is recording |
| `bot.call_ended` | The bot left the call. Media may not be ready yet |
| `bot.done` | The bot has shut down, and any recording is uploaded |
| `bot.fatal` | An error shut the bot down, usually when it couldn't join. See `sub_code` |

There are also `bot.breakout_room_*` events for breakout rooms. A bot status webhook looks like this:

```json
{
  "event": "bot.done",
  "data": {
    "data": {
      "code": "done",
      "sub_code": null,
      "updated_at": "2026-10-21T09:41:12.000000Z"
    },
    "bot": {
      "id": "6c8b2f7a-1d2e-4f3a-9b8c-7d6e5f4a3b2c",
      "metadata": {}
    }
  }
}

```

### Recording, transcript and media events

Recordings and each media artifact (`transcript`, `participant_events`, `video_mixed`, `video_separate`, `audio_mixed`, `audio_separate` and `meeting_metadata`) have `processing`, `done`, `failed` and `deleted` events, such as `recording.done` and `transcript.failed`.

### Calendar and Desktop SDK events

Calendar V2 sends `calendar.update` when a calendar's data changes, such as its `status` becoming `disconnected`, and `calendar.sync_events` when events on a calendar are created, updated or deleted. The Desktop SDK sends `sdk_upload.recording_started`, `sdk_upload.recording_ended`, `sdk_upload.complete` and `sdk_upload.failed`:

```json
{
  "event": "sdk_upload.complete",
  "data": {
    "data": {
      "code": "complete",
      "sub_code": null,
      "updated_at": "2025-05-10T15:48:24.034072Z"
    },
    "recording": { "id": "0206f11b-68ab-42b1-8a30-b85f60076e83", "metadata": {} },
    "sdk_upload": { "id": "fa08e580-d7b6-4f88-9f71-5b718c5a5f77", "metadata": {} }
  }
}

```

### Real-time events

Real-time endpoints receive events such as `participant_events.join`, `participant_events.leave`, `participant_events.speech_on`, `participant_events.chat_message`, `transcript.data` and `transcript.partial_data`.

## Setting up Recall.ai webhooks

1. On the **API keys** page of your region's dashboard, create a workspace verification secret. It starts with `whsec_`. Store it server-side, for example as `RECALL_WEBHOOK_SECRET`. If your account predates 15 December 2025, copy each endpoint's Svix signing secret from the webhooks dashboard instead.
2. In the webhooks dashboard for your region, add an endpoint with your HTTPS URL.
3. Subscribe the endpoint to every event type you want. Events you don't subscribe to are not sent.
4. For real-time data, add a real-time endpoint when you create each bot.

You can add query parameters to a webhook URL, such as `?env=staging`, and Recall.ai passes them through, which helps when several environments share one receiver.

## Securing Recall.ai webhooks

Build the signed content as `{webhook-id}.{webhook-timestamp}.{raw body}`, compute an HMAC-SHA256 with the base64-decoded part of your secret after `whsec_`, base64-encode it, and compare it in constant time with each `v1,` entry in `webhook-signature`. Recall.ai's own verification function falls back to the `svix-*` headers, so one function covers both kinds of account.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyRecallSignature(rawBody, headers, secret) {
  const id = headers['webhook-id'] ?? headers['svix-id'];
  const timestamp = headers['webhook-timestamp'] ?? headers['svix-timestamp'];
  const signatureHeader = headers['webhook-signature'] ?? headers['svix-signature'];
  if (!id || !timestamp || !signatureHeader) return false;
  if (typeof secret !== 'string' || !secret.startsWith('whsec_')) return false;

  // The key is the base64-decoded part of the secret after "whsec_".
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  // During a rotation the header holds one "v1,<base64>" entry per active secret.
  return signatureHeader.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/recall', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.RECALL_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyRecallSignature(req.body, req.headers, secret)) {
    return res.sendStatus(401);
  }

  // Optional: if your own policy needs it, reject timestamps that are too old.
  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.event, then process async. Respond well inside 15 seconds.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_recall_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id") or headers.get("svix-id")
    timestamp = headers.get("webhook-timestamp") or headers.get("svix-timestamp")
    signature_header = headers.get("webhook-signature") or headers.get("svix-signature")
    if not (msg_id and timestamp and signature_header):
        return False
    if not isinstance(secret, str) or not secret.startswith("whsec_"):
        return False

    key = base64.b64decode(secret[len("whsec_"):])
    signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode(), expected):
            return True
    return False

```

To check your verifier, use the secret `whsec_aG9va2RlY2stZ3VpZGUtdGVzdC1rZXktMzJieXRlcyE=`, the headers `webhook-id: msg_2kRecallTest0001` and `webhook-timestamp: 1761040800`, and this exact body:

```
{"event":"bot.done","data":{"bot":{"id":"6c8b2f7a-1d2e-4f3a-9b8c-7d6e5f4a3b2c","metadata":{}}}}

```

The `webhook-signature` header should be:

```
v1,2iHMjJ7i8bqfujiWPY8x+VV2JMmCoxLDU9JDZqj+dUw=

```

If you haven't created a workspace secret, new accounts receive no signature headers, so there is nothing to verify. Create one before you go to production. In Hookdeck, leave verification off if you didn't set a secret. Real-time webhooks from the Desktop SDK can't be verified at all. For real-time endpoints, Recall.ai also supports a `?token=` query parameter, which needs a trailing `/` before the query string or requests fail with `400`.

> **Make Recall.ai webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `webhook-signature` (or `svix-signature`) on every Recall.ai delivery, responds well inside the 15-second timeout, and queues bot and recording events durably before your service sees them.

## Recall.ai webhook limitations and pain points

### Signing is opt-in

**The Problem:** Your endpoint accepts forged bot events, because nothing on the request proves it came from Recall.ai.

**Why It Happens:** On accounts created from 15 December 2025, the `webhook-*` headers are only sent after you create a workspace verification secret.

**Workarounds:**

* Create a workspace secret before you build your receiver, and reject requests without signature headers.
* If you use the `?token=` approach on real-time endpoints, keep the token long and random.

**How Hookdeck Can Help:** Hookdeck's Recall.ai source verifies the signature with your workspace secret and accepts both the `webhook-*` and `svix-*` header sets, so legacy endpoints work too.

### A 15-second timeout you can't change

**The Problem:** A handler that downloads media or calls an LLM before responding times out, and Recall.ai retries an event you already processed.

**Why It Happens:** Status webhooks time out after 15 seconds, and Recall.ai says the timeout can't be increased. A timed-out request counts as failed "even if the work has completed".

**Workarounds:**

* Verify, store the event, and return `200` straight away. Do the work in a background job.
* Make processing idempotent so a retry after a slow success does nothing.

**How Hookdeck Can Help:** Hookdeck acknowledges each webhook as soon as it arrives, then delivers it to your service with the timeout and [retry](/docs/retries) policy you choose.

### Real-time endpoints fail fast and can't be replayed

**The Problem:** A short outage during a meeting loses live transcript data, and the endpoint stops receiving anything for the rest of the call.

**Why It Happens:** Real-time endpoints retry 60 times at 1-second intervals. After that, the endpoint is marked `failed` and no more messages are delivered. There is no manual retry in the dashboard or API.

**Workarounds:**

* Keep the real-time receiver as small as possible and push work to a queue.
* After the meeting, fetch the transcript and participant data asynchronously while the recording media is still available.

**How Hookdeck Can Help:** Hookdeck accepts each real-time request immediately, so a brief outage on your side doesn't use up Recall.ai's 60 retries. Events wait in Hookdeck until your service is back.

### Media isn't ready at `bot.call_ended`

**The Problem:** Your app tries to download a recording and gets nothing.

**Why It Happens:** `bot.call_ended` means the bot left the call. Recall.ai notes recordings "may not be available at this point".

**Workarounds:**

* Wait for `bot.done` before downloading media, or for the artifact's own `*.done` event, such as `transcript.done`.
* Make sure the endpoint is subscribed to those events, because unsubscribed events aren't sent.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route `bot.done` and `*.done` events to the service that downloads media, and keep status updates elsewhere.

## Best practices

### Rotate secrets without downtime

After you rotate the workspace secret, the old one stays valid for 24 hours and each request carries a signature for every active secret. Accept any matching entry, update your stored secret, and treat multiple signatures as a sign that a rotation is in progress.

### Subscribe deliberately

Only subscribed event types are sent. Review your subscriptions when you start using a new feature, such as Calendar V2 or the Desktop SDK.

### Expect repeats

`bot.joining_call` can be sent more than once for the same bot, and Svix retries resend a message with the same ID. Deduplicate on the `webhook-id` (or `svix-id`) header, and make state transitions idempotent.

### Use query parameters to tell environments apart

Add a parameter such as `?env=staging` to each workspace's webhook URL. It comes back on every request.

### Acknowledge fast and process asynchronously

Return `200` well inside the 15-second limit and queue the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make handlers idempotent

Processing the same event twice should have the same result as processing it once. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Recall.ai sends status webhooks through Svix and in-call data through real-time endpoints, both signed with an HMAC-SHA256 over the ID, timestamp and raw body once you create a workspace verification secret. Create that secret, accept both `webhook-*` and `svix-*` headers if you have older endpoints, respond within 15 seconds, and wait for `bot.done` before you fetch media.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every Recall.ai signature, answers inside the timeout, and holds each event until your service has processed it, so an outage on your side doesn't cost you a meeting's worth of data.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Recall.ai webhooks reliably in minutes.