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

# Send events to a webhook

A webhook pushes your app's events to an endpoint you own, as they happen — a visitor starting a chat conversation, each message in that conversation, a server going live. Instead of polling the API, your system is told.

Every app can have up to **10 webhooks**, each with its own destination, its own list of events, and its own signing secret. Webhooks are available on every plan; open an app and click **Webhooks** in the sidebar.

Because a webhook sends your app's activity to an endpoint of your choosing, **only workspace owners can add, edit, or remove webhooks**. Members can view the configuration and the delivery log, but not change it.

## Adding a webhook

1. Open your app and click **Webhooks** in the sidebar.
2. Click **Add webhook** and fill in:
   * **Name** — a label so you can tell webhooks apart (e.g. "CRM sync").
   * **Destination URL** — the `https://` endpoint that receives the POSTs. It must be publicly reachable; private and internal addresses are rejected.
   * **Events** — which events to send (at least one).
   * **Include message text** — off by default. When off, message events carry metadata only. See [What gets sent](#what-gets-sent).
3. Leave **Enabled** on, then click **Create webhook**.
4. **Copy the signing secret.** It is shown once and cannot be read again. If you lose it, use **Rotate secret** to mint a new one.

Then click **Test** on the webhook to send a `webhook.test` ping and confirm your endpoint answers.

## Events

| Event                  | Fires when                                                                        |
| ---------------------- | --------------------------------------------------------------------------------- |
| `conversation.created` | A visitor starts a new chat-widget conversation.                                  |
| `message.created`      | A message is added to a conversation — one per user turn and per assistant reply. |
| `server.published`     | An MCP server is published (or redeployed) to production.                         |

`webhook.test` is also delivered when you click **Test**. It is not subscribable: it goes only to the webhook you tested.

Conversation events come from the [chat widget](/guides/embed-ai-chat-widget.md). They do not depend on transcript retention — a widget that stores nothing still emits them.

## What a delivery looks like

Each delivery is a `POST` with a JSON body:

```json
{
  "id": "evt_9f2c…",
  "type": "message.created",
  "createdAt": "2026-09-20T10:31:02.481Z",
  "workspaceId": "ws_…",
  "appId": "app_…",
  "data": {
    "conversation": {
      "id": "conv_…",
      "key": "6b1f…",
      "embedId": "emb_…",
      "embedName": "Support widget",
      "serverId": "srv_…",
      "origin": "https://app.example.com",
      "context": { "page": "/orders/1042" }
    },
    "message": {
      "role": "user",
      "sequence": 3,
      "contentLength": 42,
      "attachments": 1
    }
  }
}
```

and these headers:

| Header                 | Value                                                  |
| ---------------------- | ------------------------------------------------------ |
| `X-FlowCP-Signature`   | `t=<unix seconds>,v1=<hex HMAC-SHA256>` — see below.   |
| `X-FlowCP-Event`       | The event type, e.g. `message.created`.                |
| `X-FlowCP-Event-Id`    | The event id. Stable across webhooks and redeliveries. |
| `X-FlowCP-Delivery-Id` | This attempt's delivery id.                            |
| `X-FlowCP-Webhook-Id`  | Which webhook this was sent to.                        |

Answer with any `2xx` status. Anything else counts as a failure and is retried.

`conversation.key` is a one-way hash of the visitor's conversation handle — it correlates every event of one thread without exposing the handle itself.

## What gets sent

Payloads carry ids, roles, counts, names and timestamps. They never carry end-user access tokens, the visitor's conversation handle, tool arguments, or upstream API responses.

**Message text is withheld by default.** A `message.created` event normally reports the role, its position in the thread, how long the text was, how many attachments it had, and which tools an assistant turn called — but not the words. Turn on **Include message text** for a webhook to add `data.message.content` (capped at 20,000 characters) to its deliveries. Only do this for endpoints you are comfortable sending customer conversations to.

## Verifying the signature

Every request is signed with the webhook's secret, so you can prove it came from FlowCP and is not a replay. The signature is an HMAC-SHA256 over `<timestamp>.<raw request body>`, hex-encoded:

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

function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
  const timestamp = Number(parts['t']);
  // Reject anything older than five minutes (replay protection).
  if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const received = Buffer.from(parts['v1'] ?? '', 'hex');
  const digest = Buffer.from(expected, 'hex');
  return received.length === digest.length && timingSafeEqual(received, digest);
}
```

Verify against the **raw** body bytes, before any JSON parsing and re-serialization — a re-serialized object will not match.

## Delivery, retries, and the delivery log

A delivery is attempted up to **5 times** with exponential backoff, spreading retries over roughly ten minutes. Click **Deliveries** on a webhook to see recent attempts with their status, HTTP response code, attempt count, and error message, and to **Redeliver** any of them. A redelivery replays the stored event with the same event id — de-duplicate on `X-FlowCP-Event-Id` if that matters to you.

Delivery history is kept for **14 days**.

If a webhook's deliveries keep failing, it shows a **Delivery error** badge with the latest message. After **20 consecutive** failed deliveries it is disabled automatically and says so; fix the endpoint and re-enable it to resume.

Events are queued before they are sent, so a delivery survives a restart on our side. Ordering is not guaranteed — use `createdAt` and `message.sequence` if you need to order events yourself.

## Rotating or removing a webhook

* **Rotate secret** mints a new signing secret and shows it once. Deliveries signed with the old secret stop verifying immediately, so update your endpoint promptly.
* **Edit** changes the destination, the events, or the content setting.
* **Delete** removes the webhook and its delivery history; no further events are sent to its endpoint.

## Related

* [Embed an AI chat widget](/guides/embed-ai-chat-widget.md) — the source of conversation and message events.
* [Read chat conversations](/guides/read-chat-conversations.md) — retained transcripts in the dashboard.
* [Stream logs to external sinks](/guides/stream-logs.md) — for per-call execution logs rather than product events.
