> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.rydhm.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.rydhm.ai/_mcp/server.

# Webhooks

Webhooks tell your systems about events as they happen: a meeting ended, a summary is ready, a document finished processing. Rydhm sends each event as a JSON `POST` to the endpoints you choose.

Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) format, so any Standard Webhooks or Svix library can verify them.

## Add an endpoint

![The Add endpoint panel with an endpoint URL, events and experts](/_fern-files/_img/467054978c0395938a1ef9124c07b1c9fe060d77d17fcbb1141b12ed34812911.webp)![The Add endpoint panel with an endpoint URL, events and experts](/_fern-files/_img/b21e4c7cc0ec080b44a8042d91541dd9ebc078e5be570848a19508ea321aef79.webp)

Only workspace admins manage webhooks, in the app. API keys can't create or change endpoints.

#### Open Webhooks

Go to **Developers → Webhooks** and choose **Add endpoint**.

#### Enter the URL

Under **Endpoint URL**, enter a full `https://` address that the public internet can reach. Addresses with a username or password, and addresses that resolve to private or internal networks, are refused. A **Description** is optional.

#### Choose events

Under **Events**, pick the events you want, or **Select all**. Subscribing to all events also gets you event types added later.

#### Choose experts

Under **Experts**, keep **All experts** or pick **Only these**. An endpoint limited to some experts gets only the events about them.

#### Save the signing secret

Choose **Add endpoint**. Copy the **Signing secret** (`whsec_…`) and store it with your server's other secrets. You need it to [verify deliveries](#verify-the-signature).

A workspace can have up to 10 endpoints. To check an endpoint, open it and choose **Send test event**. Rydhm sends a `webhook.test` event to that endpoint only, even if it doesn't subscribe to it.

## Events

| Event                       | Group         | When it is sent                                                                                                                                               |
| --------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `conversation.started`      | Conversations | A conversation with an expert began: a chat in the app or on a public page, or a browser voice call.                                                          |
| `conversation.ended`        | Conversations | A voice conversation ended. Sent once, when the call's session report arrives. Text chats have no end and don't send this.                                    |
| `call.ended`                | Conversations | A voice call with an expert finished, with its billable minutes. Sent once per call.                                                                          |
| `meeting.scheduled`         | Meetings      | An expert was sent to (or booked into) a video meeting.                                                                                                       |
| `meeting.joining`           | Meetings      | The meeting bot is on its way in (dispatched, or waiting in the lobby).                                                                                       |
| `meeting.joined`            | Meetings      | The expert is in the call: the meeting is live.                                                                                                               |
| `meeting.join_failed`       | Meetings      | The expert couldn't get into the meeting: the bot couldn't be dispatched, was refused, or failed before the call went live.                                   |
| `meeting.ended`             | Meetings      | A meeting reached its final state: the call ended (completed), it was cancelled, or the bot failed after the call had gone live. `meeting.status` says which. |
| `meeting.summary.ready`     | Meetings      | The post-meeting summary is ready. Carries a short snippet and the counts; fetch the meeting for the full summary.                                            |
| `meeting.summary.failed`    | Meetings      | The post-meeting summary couldn't be generated. It can be regenerated from the meeting.                                                                       |
| `knowledge.document.ready`  | Knowledge     | A knowledge document finished processing and the expert can now answer from it.                                                                               |
| `knowledge.document.failed` | Knowledge     | A knowledge document couldn't be processed. Nothing from it is searchable.                                                                                    |
| `tool.call.failed`          | Tools         | An expert called a tool and it failed (a custom webhook tool, a built-in action or an MCP tool). Arguments are never included.                                |
| `expert.created`            | Experts       | A new expert was created in the workspace.                                                                                                                    |
| `expert.updated`            | Experts       | An expert's settings changed. Lists which fields changed, never their values.                                                                                 |
| `expert.deleted`            | Experts       | An expert was deleted.                                                                                                                                        |
| `webhook.test`              | Webhooks      | A test event, sent when you choose **Send test event**. Only ever goes to the endpoint you tested.                                                            |

Every meeting that reaches a final state sends exactly one of `meeting.ended` or `meeting.join_failed`.

## The payload

Every delivery has the same envelope:

```json
{
  "id": "evt_01JBX7Q8M4N2P6R9S3T5V7W9YA",
  "type": "meeting.summary.ready",
  "created_at": "2026-11-04T10:48:20.000Z",
  "workspace_id": "6f1c2a9e-3b7d-4c55-9a0e-1d2f3a4b5c6d",
  "data": {
    "meeting": {
      "id": "9d4e5f60-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
      "title": "Weekly pipeline review",
      "ended_at": "2026-11-04T10:47:40.000Z"
    },
    "expert_id": "2b8e4d10-7c3a-4f9b-8e21-5a6b7c8d9e0f",
    "expert": { "id": "2b8e4d10-7c3a-4f9b-8e21-5a6b7c8d9e0f", "name": "Ava" },
    "summary": {
      "snippet": "The team agreed to move the Acme renewal to Q1 and to send pricing by Friday.",
      "key_points": 6,
      "action_items": 3,
      "decisions": 2,
      "topics": ["renewals", "pricing"],
      "generated_at": "2026-11-04T10:48:20.000Z"
    },
    "links": {
      "app": "https://app.rydhm.ai/conversations?id=9d4e5f60-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
      "api": "https://api.rydhm.ai/v1/meetings/9d4e5f60-1a2b-4c3d-8e9f-0a1b2c3d4e5f"
    }
  }
}
```

| Field          | What it is                                                                   |
| -------------- | ---------------------------------------------------------------------------- |
| `id`           | The event id, `evt_…`. A retried delivery carries the same id. Dedupe on it. |
| `type`         | One of the [events](#events).                                                |
| `created_at`   | When the event happened (ISO 8601), not when this attempt was sent.          |
| `workspace_id` | The workspace the event belongs to.                                          |
| `data`         | The event's details. Fields are `snake_case`.                                |

### What `data` contains

Payloads carry ids, names, statuses, timestamps, counts and links. They never carry a transcript, a message, an expert's instructions, tool arguments or anything secret. To get the full record, call the API at `data.links.api` with a key that has the right scope. `data.links.app` opens the same record in the app.

Most events carry `expert_id` and `expert` (`id` and `name`).

| Events                                            | Main object in `data`                                                                                                                                                        |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `conversation.started`, `conversation.ended`      | `conversation`: `id`, `channel`, times, and on `ended` also `duration_seconds`, `message_count`, `end_reason`                                                                |
| `call.ended`                                      | `call`: `id`, `conversation_id`, times, `duration_seconds`, `minutes`, `disconnect_reason`                                                                                   |
| `meeting.*` (except summaries)                    | `meeting`: `id`, `title`, `status`, `previous_status`, `platform`, `mode`, times, `duration_seconds`; plus `reason` on failures and `transcript_segments` on `meeting.ended` |
| `meeting.summary.ready`, `meeting.summary.failed` | `meeting`, and `summary` (snippet and counts) or `reason`                                                                                                                    |
| `knowledge.document.*`                            | `document`: `id`, `filename`, `mime_type`, `status`, and `chunks` or `error`                                                                                                 |
| `tool.call.failed`                                | `tool` (`id`, `name`) and `call`: `modality`, `status`, `duration_ms`, `error`                                                                                               |
| `expert.*`                                        | `expert`, `actor`, and on `expert.updated` the `changed_fields`                                                                                                              |
| `webhook.test`                                    | `test: true`, `message`, `endpoint_id`                                                                                                                                       |

A payload larger than 256 KB is replaced by a stub with `truncated: true`, the `expert_id` and the `links`. Fetch the record through `links.api` instead.

## Headers

Every delivery is a `POST` with these headers:

| Header              | Value                                                                    |
| ------------------- | ------------------------------------------------------------------------ |
| `content-type`      | `application/json`                                                       |
| `user-agent`        | `Rydhm-Webhooks/1`                                                       |
| `webhook-id`        | The event id, the same as the body's `id`.                               |
| `webhook-timestamp` | When this attempt was signed, in Unix seconds.                           |
| `webhook-signature` | `v1,<signature>`. During a secret rotation, two space-separated entries. |

## Verify the signature

Check every delivery before you trust it. The signature is an HMAC-SHA256, computed like this:

1. Take the signing secret, remove the `whsec_` prefix and base64-decode the rest. That is the HMAC key.
2. Build the signed content: `{webhook-id}.{webhook-timestamp}.{raw body}`.
3. Compute HMAC-SHA256 of the content with the key, and base64-encode it.
4. Compare it with each `v1,` entry in `webhook-signature`. Any match is valid.
5. Reject the request if `webhook-timestamp` is more than 5 minutes (300 seconds) from your clock. This stops replays.

> **Warning**
>
> Verify the **raw** request body, exactly the bytes you received. If your framework parses the JSON first and you serialize it again, the bytes can change and every check fails.

#### TypeScript

**`verify.ts`**

```typescript title="verify.ts"
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verifyRydhmWebhook(
  secret: string, // "whsec_…"
  headers: Record<string, string | string[] | undefined>,
  rawBody: string | Buffer, // the exact bytes received
): boolean {
  const id = String(headers["webhook-id"] ?? "");
  const timestamp = String(headers["webhook-timestamp"] ?? "");
  const signatures = String(headers["webhook-signature"] ?? "");
  if (!id || !timestamp || !signatures) return false;

  const sentAt = Number(timestamp);
  if (!Number.isFinite(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) {
    return false;
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  // Two signatures arrive during a secret rotation: any match is enough.
  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const given = Buffer.from(signature, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

With Express, read the body raw on this route:

**`server.ts`**

```typescript title="server.ts"
import express from "express";
import { verifyRydhmWebhook } from "./verify";

const app = express();

app.post("/webhooks/rydhm", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyRydhmWebhook(process.env.RYDHM_WEBHOOK_SECRET!, req.headers, req.body)) {
    return res.status(400).send("Invalid signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // Dedupe on event.id, queue the work, and answer quickly.
  res.sendStatus(204);
});
```

#### Python

**`verify.py`**

```python title="verify.py"
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_rydhm_webhook(secret: str, headers, raw_body: bytes) -> bool:
    """secret is "whsec_…"; raw_body is the exact bytes received."""
    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:
        return False
    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - sent_at) > 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()

    # Two signatures arrive during a secret rotation: any match is enough.
    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

With Flask, `request.get_data()` gives the raw body:

**`app.py`**

```python title="app.py"
import json
import os

from flask import Flask, request

from verify import verify_rydhm_webhook

app = Flask(__name__)


@app.post("/webhooks/rydhm")
def rydhm_webhook():
    raw = request.get_data()
    if not verify_rydhm_webhook(os.environ["RYDHM_WEBHOOK_SECRET"], request.headers, raw):
        return "Invalid signature", 400
    event = json.loads(raw)
    # Dedupe on event["id"], queue the work, and answer quickly.
    return "", 204
```

#### Svix library

```typescript
// Any Standard Webhooks library works. For example, Svix's (npm install svix):
import { Webhook } from "svix";

const wh = new Webhook(process.env.RYDHM_WEBHOOK_SECRET!); // "whsec_…"
const event = wh.verify(rawBody, headers); // throws if the signature is wrong
```

The same scheme signs the requests your experts send to your custom tools, with the workspace's own secret under **Developers → Webhooks → Tool call signing**. The same code verifies them. See [Tools](/experts/tools).

## Respond quickly

Answer with any `2xx` status within 10 seconds. Do the real work in a background job.

* A `2xx` marks the delivery delivered.
* A redirect (`3xx`) is a failure: redirects are not followed.
* `410 Gone` tells Rydhm to stop. The endpoint is disabled at once.
* Anything else, a timeout, or a connection error is a failure and is retried.

## Retries

The first attempt is immediate. After a failure, Rydhm retries on this schedule:

| Attempt | Wait before it |
| ------- | -------------- |
| 2       | 30 seconds     |
| 3       | 2 minutes      |
| 4       | 10 minutes     |
| 5       | 1 hour         |
| 6       | 6 hours        |
| 7       | 12 hours       |
| 8       | 24 hours       |

That is 8 attempts over about two days. Each wait varies by up to 10% so retries don't arrive in bursts. After the last attempt, the delivery is marked dead.

Retries carry the same `webhook-id`, so dedupe on it. Deliveries can arrive out of order; use `created_at` to order events.

### Disabled endpoints

An endpoint that fails 15 or more times in a row, over at least 24 hours, is disabled automatically. Workspace admins get an email and a notification in the app, and an alert opens under **Developers → Alerts**.

Events that happen while an endpoint is disabled are not sent to it. Fix the endpoint, then open it in **Developers → Webhooks** and choose **Enable**.

## The delivery log

Open an endpoint in **Developers → Webhooks** to see its deliveries: status, attempt number, your endpoint's response status, and the time. Open a delivery to see the request body, the first 2 KB of your response, and any error.

From a delivery, **Resend** sends the same event again, with the same `webhook-id`.

Deliveries and events are kept for 30 days.

## Rotate the signing secret

Open the endpoint and, under **Signing secret**, choose **Rotate**. For 24 hours the old secret keeps signing too: each delivery carries two signatures, one per secret. Deploy the new secret within that window. The verification code above accepts either.

**Reveal** shows the current secret again if you need it.