> For the complete documentation index, see [llms.txt](https://shoppad.gitbook.io/yedric/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://shoppad.gitbook.io/yedric/developers/webhooks.md).

# Webhooks

Webhooks push events from Yedric to a URL you control, as they happen. Every request is signed so you can verify it came from us, failed deliveries retry automatically, and a delivery log shows what was sent and what came back.

Webhooks can be configured on **Organization > Integrations > Webhooks**. See [Integrations](/yedric/going-further/integrations.md) for that walkthrough. This page is the technical reference for the endpoint receiving them.

***

## Event catalog

Eight events are subscribable, in two groups.

| Event                     | Fires when                                                                                                                                            |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chat.started`            | A visitor requested a live chat and joined the queue.                                                                                                 |
| `chat.assigned`           | An agent took the chat, either by claiming it or via auto-assign.                                                                                     |
| `chat.transferred`        | An agent handed an in-progress chat to a different agent.                                                                                             |
| `chat.sentiment_set`      | The visitor rated the chat good or bad.                                                                                                               |
| `chat.ended`              | The chat closed. Says whether the visitor, an agent, a timeout, or a handoff back to the assistant ended it, and carries the agent notes on the chat. |
| `conversation.created`    | A visitor's first message opened a new AI conversation.                                                                                               |
| `conversation.escalated`  | The assistant handed the visitor off to a human.                                                                                                      |
| `conversation.summarized` | An idle conversation was analyzed. Includes its summary, sentiment and topics.                                                                        |

The `chat.*` events describe [live chat](/yedric/going-further/live-chat.md), so they can only occur for organizations that have it enabled. Nothing in the webhook layer checks for it: those chats simply cannot be created without it. The `conversation.*` events fire for everyone.

There is a ninth event name, `webhook.test`, which is **not subscribable**. It is only produced by the **Send test event** action, and it appears in the delivery log's event filter so you can find those rows.

The catalog is also readable at `GET /api/webhooks/events` if you would rather not hard-code it.

***

## The request

Yedric sends a `POST` with a JSON body and these headers:

| Header                | Value                                                                    |
| --------------------- | ------------------------------------------------------------------------ |
| `Content-Type`        | `application/json`                                                       |
| `User-Agent`          | `Yedric-Webhooks/1.0`                                                    |
| `X-Yedric-Event`      | The event name, for example `chat.ended`.                                |
| `X-Yedric-Delivery`   | The delivery id. Stable across retries, so use it as an idempotency key. |
| `X-Yedric-Webhook-Id` | Which webhook this is, so you can pick the right signing secret.         |
| `X-Yedric-Timestamp`  | Unix seconds at the moment of sending, and part of the signed string.    |
| `X-Yedric-Signature`  | `v1=` followed by the hex HMAC.                                          |

Respond with any `2xx`. Anything else counts as a failure.

The webhook id appears **only** in the header, never in the body. You need it to choose a signing secret, and that decision has to happen before the body can be trusted, so the header is the only place it can actually be used.

Note that `X-Yedric-Timestamp` is the send time, not the envelope's `createdAt`. The two differ on a retry, and it is the header that is signed.

***

## Verifying the signature

The signature covers the timestamp and the raw body together:

```
signature = "v1=" + HMAC_SHA256(secret, timestamp + "." + rawBody)
```

```js
const crypto = require('crypto');

function verify(req, secret) {
  const raw = req.rawBody;                       // exact bytes, not a re-serialization
  const ts = req.get('X-Yedric-Timestamp');
  const sig = req.get('X-Yedric-Signature');

  // Reject stale requests. This check is only meaningful because the timestamp is signed.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const expected =
    'v1=' + crypto.createHmac('sha256', secret).update(`${ts}.${raw}`).digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(sig || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Three things matter here:

1. **The timestamp is inside the MAC.** Signing the body alone would leave the timestamp forgeable, which makes rejecting replays pointless. Because it is signed, your freshness window is real.
2. **Verify against the raw request bytes.** Parsing the JSON and re-serializing it is not guaranteed to reproduce the same bytes, and both sides have to hash the same ones. In Express, capture `rawBody` via the `verify` option on `express.json()`.
3. **The `v1=` prefix is a version scheme.** It leaves room for comma-separated values during a secret rotation, so match on the value you expect rather than assuming a single one forever.

The signature legitimately differs between attempts of the same delivery, because the timestamp is fresh and the `attempt` counter in the body increments.

***

## The payload

```jsonc
{
  "id": "b3f1…",                        // delivery id, stable across retries
  "apiVersion": "2026-08-06",
  "event": "chat.ended",
  "createdAt": "2026-08-19T16:04:11.000Z",
  "orgId": "org_…",
  "agentId": "agt_…",                   // key omitted when the event has no assistant context
  "attempt": 1,                         // 1-based, increments per retry
  "data": {                             // shape depends on the event
    "assistantName": "Yedric",          // omitted when the assistant has no name
    // ...
  },
  "truncated": true                     // present only when data was dropped for size
}
```

`agentId` is **omitted** rather than sent as `null` when an event has no assistant context, so type it as optional. The same is true of `truncated`, which is only ever present when it is `true`.

Every event carries `assistantName` in `data`, beside whatever else the event includes, so you can name the assistant a chat or conversation came from without calling back to the API. `agentId` in the envelope identifies the same assistant; the two go together. Like `agentId`, the key is **omitted** rather than sent as `null` when the assistant has no name or no longer exists, so type it as optional too.

The serialized envelope is capped at 256 KB. Over that, `data` is emptied entirely and `truncated` is set to `true`. It is not trimmed to fit. When you see `truncated`, fetch the record from the API instead of working with what arrived.

That cap is measured once, when the delivery is queued. Retries re-serialize the stored envelope with a new `attempt` number and are not re-measured, so a payload sitting right on the boundary can arrive a few bytes over it. Do not treat 256 KB as a hard guarantee when sizing your parser.

***

## Retries and delivery guarantees

A failed delivery is tried **up to six times in total**, one first attempt plus five retries. The delay before each retry grows, and carries jitter of plus or minus 20 percent:

| Attempt | Delay before it | Approximate time after the first try |
| ------- | --------------- | ------------------------------------ |
| 1       | none            | immediately                          |
| 2       | 10s             | 10s                                  |
| 3       | 30s             | 40s                                  |
| 4       | 2m              | 2m 40s                               |
| 5       | 10m             | 12m 40s                              |
| 6       | 1h              | 1h 12m                               |

Each request times out after 10 seconds. The first 2 KB of your response body is stored and shown in the delivery log, which makes debugging much easier if you return a useful error string.

**`404` and `410` are terminal.** The route is gone, and that does not improve by waiting, so no retries are attempted. Three other conditions also end a delivery immediately rather than retrying: the webhook was deleted while the delivery was queued, its signing secret could not be decrypted, and the callback URL failed revalidation (see [Callback URL requirements](#callback-url-requirements)).

**Delivery is at least once.** If a worker dies after sending but before recording the outcome, the delivery is reclaimed and legitimately sent again. Deduplicate on `X-Yedric-Delivery`.

**Repeated failure disables the webhook.** After 20 consecutive deliveries have exhausted all their retries, the webhook is switched off and the dashboard shows "Auto-disabled after repeated failures." Note that the count is of fully dead deliveries, not individual attempts, so this takes sustained failure. A single success resets the streak. Test events never count toward it, and re-enabling the webhook resets the count.

***

## Notes, attachments and redelivery

`chat.ended` is the one event that includes the agent notes written during the chat, as a `notes` array in `data`. Each note has an `id`, its `text`, a `kind` of `note` or `closing` (the closing note is the write-up an agent leaves when ending the chat), `createdAt`, and the author's display name as `authorName`, which is omitted when it is not known. Notes never identify the author by id.

Agent notes on `chat.ended` can carry attachments. Each one includes `key`, `name`, `contentType`, `size`, and a presigned `url` with a `urlExpiresAt`. The last two are omitted, not null, if presigning fails.

**That URL is valid for 24 hours.** It has to outlive the retry schedule, and it is an unauthenticated link to a customer's file, so it is deliberately short-lived.

The consequence to plan for: **Redeliver reuses the stored payload.** Redelivering a chat that is more than a day old hands your receiver an expired link. Download attachments promptly, and treat `key` as the durable handle rather than the URL.

***

## Callback URL requirements

Enforced when the URL is saved and again on every delivery, including after each redirect.

* Scheme must be `http` or `https`. **In production, `https` is required.**
* The port must be the protocol default, or one of `80`, `443`, `8080`, `8443`. Anything else is rejected when you save, with a message naming the allowed ports.
* The hostname must resolve to a public IP address. Private, loopback, link-local, and reserved ranges are refused.
* Redirects are followed up to five hops, and every hop is re-validated. Only `accept`, `accept-language`, `content-type` and `user-agent` survive a cross-origin redirect, so every `X-Yedric-*` header including your signature is dropped. Terminate the request at the URL you configured rather than bouncing it.

### Your receiver has to be publicly reachable

Because loopback and private ranges are refused, there is no way to point a webhook at a service running on your own machine, and `localhost` and `127.0.0.1` are both rejected. While you are building, put your receiver behind a tunnel (from a service like Ngrok) that gives you a public `https` URL and configure that. **Send test event** is the fastest way to confirm the tunnel works before you subscribe to anything real.
