> ## Documentation Index
> Fetch the complete documentation index at: https://brosbots.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks: events to your system

> Signed messages after a call, an SMS, a campaign or on low balance, sent to your address.

A webhook is an address in your system (backend, CRM, n8n, Make, Zapier) that
Brosbots sends a JSON `POST` to when something happens: a call ends, an SMS
arrives, a campaign finishes its list. Every message is signed with your key,
so you can check it came from us.

## Setting up a webhook

**Integrations** → **Custom** tab → **Notify on an event** tile. The wizard
has four steps:

1. **Address** - name, URL and signing key. The key is generated for you
   (`whsec_…`). Copy it straight away: after saving it is only shown masked.
   **New key** generates another one; replace it on your side too.
2. **Events** - which events to receive. `call.completed` is always on.
3. **Agents** - all of them or selected ones only. Events tied to an agent
   (calls, SMS, campaigns) of other agents do not reach this address.
   `balance.low` always arrives.
4. **Test** - sends an example event, signed like a real one. You see your
   server's response code, the body sent and the signature header.

Edit the webhook later in the same wizard (**Your integrations** →
**Notifications**). The same webhook also shows in the catalog as
**Post-call Webhook**.

<Note>
  Webhooks are available from the **Start** plan.
</Note>

## Message format

Every event has the same envelope:

```json theme={null}
{
  "event": "call.completed",
  "sent_at": "2026-10-13T10:15:00+00:00",
  "data": { "...": "..." }
}
```

Headers:

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-Brosbots-Signature` | `sha256=<hex>`: HMAC-SHA256 of the raw request body, keyed with your signing key |

The wizard's example has `"test": true` in `data`, so it is easy to filter out.

## Events

| Event | When | Main `data` fields |
| - | - | - |
| `call.completed` | after every call (always on) | `workflow_run_id`, `workflow_id`, `workflow_name`, `duration_seconds`, `initial_context`, `gathered_context`, `recording_download_url`, `transcript_download_url` |
| `call.started` | a call starts | `workflow_run_id`, `workflow_id`, `direction`, `phone_number`, `channel`, `campaign_id`, `started_at` |
| `sms.received` | an incoming SMS | `sms_id`, `from_number`, `to_number`, `text`, `workflow_id`, `workflow_run_id`, `received_at` |
| `campaign.completed` | a campaign has called every contact | the campaign summary and `rows` (each contact's result) |
| `balance.low` | the balance reaches the warning threshold or drops below zero | `balance`, `in_debt`, `minutes_left` |
| `lead.dropped` | a lead the agent did not call: no credit for an hour, busy channels, a full queue, a lead limit, a dialling error ([The Leads list](/en/integrations/leads/lead-webhook#the-leads-list)) | `lead_id`, `workflow_id`, `workflow_name`, `source`, `phone_number`, `name`, `status`, `reason`, `attempts`, `created_at` |

Full examples and field descriptions: [Post-call webhook payload](/en/voice-agent/conversation-flow).

## Verifying the signature

Compute HMAC-SHA256 over the **raw** request body (the bytes as received,
before parsing the JSON) and compare it with the header in constant time.
Re-serialising the JSON can reorder keys or change whitespace, and then the
signature will not match.

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();
  const SECRET = process.env.BROSBOTS_WEBHOOK_SECRET;

  app.post("/webhooks/brosbots", express.raw({ type: "application/json" }), (req, res) => {
    const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
    const received = req.get("X-Brosbots-Signature") || "";
    const ok =
      received.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
    if (!ok) return res.status(401).end();

    const { event, data } = JSON.parse(req.body.toString("utf8"));
    // ... handle the event
    res.status(204).end();
  });
  ```

  ```python Python (FastAPI) theme={null}
  import hashlib
  import hmac
  import os

  from fastapi import FastAPI, Header, HTTPException, Request

  app = FastAPI()
  SECRET = os.environ["BROSBOTS_WEBHOOK_SECRET"].encode()


  @app.post("/webhooks/brosbots", status_code=204)
  async def brosbots(request: Request, x_brosbots_signature: str = Header("")):
      body = await request.body()
      expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(expected, x_brosbots_signature):
          raise HTTPException(status_code=401)
      payload = await request.json()
      # ... handle payload["event"], payload["data"]
  ```

  ```php PHP theme={null}
  <?php
  $secret = getenv('BROSBOTS_WEBHOOK_SECRET');
  $body = file_get_contents('php://input');
  $expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
  $received = $_SERVER['HTTP_X_BROSBOTS_SIGNATURE'] ?? '';

  if (!hash_equals($expected, $received)) {
      http_response_code(401);
      exit;
  }
  $payload = json_decode($body, true);
  // ... handle $payload['event'], $payload['data']
  http_response_code(204);
  ```
</CodeGroup>

Make, Zapier and n8n usually do not check the signature: the webhook address
is a secret in itself there. The key still helps if the address ever leaks.

## Delivery and retries

* Answer with a `2xx` within 15 seconds. The response body does not matter.
* On a connection error, a timeout, or `408`, `425`, `429`, `5xx`, we retry
  twice more (after 1 and after 5 seconds). Other `4xx` codes count as a
  rejection and are not retried.
* Every attempt is listed under **Recent deliveries** below the integrations
  catalog, with the response code and the error.
* In rare cases the same event can arrive twice. Use `event` with
  `data.workflow_run_id` (or `data.sms_id`, `data.campaign_id`) to spot a repeat.

## Security

* The address must be public: private-network addresses and `localhost` are
  blocked.
* The signing key is stored encrypted and is never returned in full by the API.
* The recording and transcript links in `call.completed` work without signing
  in. Treat them as personal data.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.