# Guide to Bland AI Webhooks: Features and Best Practices

Bland AI runs AI phone agents that make and answer calls, and send and receive SMS. When a call ends, Bland sends the call record to your webhook: who was called, how long it lasted, the transcript, a summary and any variables the agent collected. That webhook is how call outcomes reach your own systems without polling Bland's API.

This guide covers the webhooks Bland delivers to you: the post-call webhook, the optional live and post-processing streams, SMS conversation webhooks, how they are signed, and the best practices for running a Bland receiver in production. It does not cover the Webhook node in Bland's conversational pathways, which is a request Bland makes to your API during a call.

## What are Bland AI webhooks?

A Bland webhook is a URL attached to a call, an inbound number or an SMS number. When a call completes, Bland sends an HTTP `POST` with a JSON body describing it. If you list extra streams in the call's `webhook_events` parameter, the same URL also receives progress messages during the call and post-processing results after it.

Once you have created a signing secret in your account, every request carries an `X-Webhook-Signature` header holding the hex HMAC-SHA256 of the body, keyed with that secret.

The main thing to plan for is that Bland's webhooks do not share one event field. The post-call webhook has no type at all, the live stream uses `category`, post-processing webhooks use `event_type`, and SMS webhooks are recognised by their fields.

## Bland AI webhook features

| Feature | Details |
| --- | --- |
| Configuration | `webhook` on each call, the Advanced section of an inbound number or the Send Call page, and per SMS number, request or batch |
| URL requirement | `https://` (the Send Call API rejects other schemes) |
| Subscription model | The post-call webhook always fires; `webhook_events` opts a call into nine extra streams |
| Signature header | `X-Webhook-Signature` |
| Scheme | HMAC-SHA256, lowercase hex, no prefix |
| Signed content | The request body |
| Secret | One per account, created with "Replace Secret" under Account Settings > Keys, shown once |
| Replay window | None. No timestamp is sent or signed |
| Retries | Automatic on `4xx`, `429` and `5xx`; no schedule, count or timeout published |
| Testing | "Test Webhook" button on an inbound number |
| Source IPs | Not published |

## Common events

### The post-call webhook

Every call with a `webhook` URL produces a post-call webhook as soon as the call completes. It is the call record, with no field naming it. Here is Bland's example, trimmed:

```json
{
  "call_id": "12345678-1234-1234-1234-123456789012",
  "c_id": "12345678-1234-1234-1234-123456789012",
  "call_length": 0.183333333333333,
  "to": "+15551234567",
  "from": "+15559876543",
  "completed": true,
  "inbound": false,
  "queue_status": "complete",
  "status": "completed",
  "answered_by": "human",
  "summary": "The call was a brief test call from Bland...",
  "concatenated_transcript": "user: Hello?  \n assistant: This is a test call from Bland. \n ...",
  "transcripts": [
    { "id": 123456789, "user": "user", "text": "Hello? ", "created_at": "2025-09-12T20:39:13.173+00:00" }
  ],
  "variables": { "phone_number": "+15551234567", "country": "US" },
  "metadata": { "campaign_type": "test_calls" },
  "call_ended_by": "ASSISTANT",
  "disposition_tag": "COMPLETED_ACTION"
}

```

A failed call still produces a delivered webhook. Bland reports call failures, such as a busy line or a blocked number, in `error_message` inside a normal post-call payload, not through the delivery status.

### `webhook_events` streams

The call's `webhook_events` array accepts nine options:

| Option | What arrives |
| --- | --- |
| `queue` | Live messages with `category: "queue"`, such as "Call enqueued" |
| `call` | Live messages with `category: "call"`, such as "Call connected" and agent and user speech |
| `latency` | Live timing messages with `category: "latency"`, such as "TTS: 218ms" |
| `webhook` | Live messages about the pathway's webhook calls, arriving with `category: "call"` |
| `tool` | Live messages about custom tools, arriving with `category: "call"` |
| `dynamic_data` | Live messages about stored dynamic data, arriving with `category: "call"` |
| `citations` | A separate post-processing webhook with extracted citation variables |
| `evals` | Eval scores from an attached evals workbench, sent as a separate webhook when the run completes |
| `post_transfer_transcript` | The conversation after a transfer to a human, added to the post-processing webhook (limited rollout, requires recording) |

The six live options share one small body:

```json
{
  "message": "Call connected",
  "call_id": "12345678-1234-1234-1234-123456789abc",
  "category": "call",
  "log_level": "info"
}

```

Only `queue`, `call` and `latency` appear as categories. The examples for `webhook`, `tool` and `dynamic_data` all arrive as `category: "call"`, so on the wire you can tell them apart only by the `message` text.

Post-processing webhooks carry `event_type`. When one enrichment is present it names it, as in this trimmed `citations` example:

```json
{
  "call_id": "12345678-1234-1234-1234-123456789abc",
  "user_id": "11111111-2222-3333-4444-555555555555",
  "event_type": "citations",
  "timestamp": "2025-07-03T16:41:15.231Z",
  "citations": [
    {
      "variable_name": "User height",
      "variable_type": "boolean",
      "value": true,
      "schema_id": "99999999-8888-7777-6666-555555555555"
    }
  ]
}

```

When more than one is present, `event_type` is `"multiple"` and a `contents` array lists them, drawn from `citations`, `corrected_transcript`, `dispositions`, `post_transfer_transcript` and `live_translation_transcript`. Bland does not show the body of the `evals` webhook.

With `citations` in `webhook_events` and the "Delay post call webhook" toggle on in the dashboard's Citations settings, Bland holds the post-call webhook for 30 to 60 seconds and sends one combined payload: the call record plus `citations` and `corrected_transcript`. Both settings are needed. Without the toggle, the post-call webhook fires immediately and citations follow separately.

### SMS conversation webhooks

SMS conversations use the same endpoint structure as calls. A message webhook fires for every message sent or received, with `conversation_id`, `phone_number`, `agent_number`, `channel` (`sms`, `rcs` or `whatsapp`), `message`, `sender` and the full `chat_history`. A status webhook fires when a conversation ends or changes state, adding `status` (`ended`, `timeout`, `blacklisted`, `inactive`, `human_handoff`, `human_handoff_ended`, `read` or `link_clicked`) and `message_count`.

## Setting up Bland AI webhooks

First, create the signing secret. In the Bland Dev Portal, open Account Settings, go to the Keys tab and click "Replace Secret". Bland shows the secret once, so store it straight away, for example as `BLAND_WEBHOOK_SECRET`. The secret covers the whole account, and Bland's docs say it must exist before webhooks can be verified.

Then attach your URL in whichever place matches the traffic:

1. **Outbound calls from the API:** pass `webhook` (and optionally `webhook_events`) in the Send Call request.
2. **Outbound calls from the dashboard:** open the Send Call page, expand Advanced and fill in the webhook field.
3. **Inbound calls:** open Inbound Phone Numbers, select the number, expand Advanced and fill in the webhook field. "Test Webhook" sends a test payload.
4. **SMS:** set the webhook on the SMS number in the Messaging dashboard or with the Update SMS Webhook endpoint, override it per Send SMS request, or set it per batch in the batch's `global` object.

```bash
curl -X POST "https://api.bland.ai/v1/calls" \
  -H "Authorization: Bearer $BLAND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "+15551234567",
    "task": "Confirm the appointment for tomorrow.",
    "webhook": "https://example.com/webhooks/bland",
    "webhook_events": ["citations"]
  }'

```

Because the post-call webhook is configured per call or per number, it is easy to miss a source of traffic. Check every inbound number and every code path that sends calls.

## Securing Bland AI webhooks

Read the `X-Webhook-Signature` header, compute an HMAC-SHA256 of the request body with your account secret, encode it as hex and compare in constant time.

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

function verifyBlandSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

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

  if (!verifyBlandSignature(req.body, req.get('x-webhook-signature'), secret)) {
    return res.sendStatus(401);
  }

  const body = JSON.parse(req.body.toString('utf8'));
  // Queue the body for processing; classify it with the rules in "Best practices".
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_bland_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_bland_signature(raw_body, request.headers.get("x-webhook-signature"),
#                        os.environ["BLAND_WEBHOOK_SECRET"])

```

Bland's documentation describes the signature as an HMAC "of the request body", but its own Node sample passes `JSON.stringify(req.body)`, a re-serialisation of the parsed JSON. The two match only when Bland sends compact JSON that `JSON.stringify` would reproduce byte for byte. The code above uses the raw bytes, as the prose describes. Check it against your first signed delivery before relying on it in production.

> **Make Bland AI webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Bland's `X-Webhook-Signature` header against the raw body, queues every post-call, stream and SMS webhook, and lets you route each kind to its own destination.

## Bland AI webhook limitations and pain points

### No single event field

**The Problem:** A router built around one field drops or misroutes most of the traffic.

**Why It Happens:** The post-call webhook and SMS webhooks carry no type. Live stream messages use `category`, and post-processing webhooks use `event_type`. Three of the live options also share `category: "call"`.

**Workarounds:**

* Classify in order: `event_type`, then `category`, then shape (`conversation_id` means SMS, `call_length` and `completed` mean post-call).
* Send live streams to a separate endpoint from post-call data, using the per-call `webhook` URL.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can match on the presence or value of any body field, so post-call records, live messages, citations and SMS traffic can each go to their own destination from one Bland URL.

### Unclear signed content

**The Problem:** Verification fails even though the secret is right.

**Why It Happens:** Bland's prose and its code sample disagree about what is signed. A framework that parses JSON before your handler runs also changes the bytes.

**Workarounds:**

* Mount a raw body parser on the webhook route and verify before parsing.
* Test with a real call before relying on verification in production.

**How Hookdeck Can Help:** Hookdeck keeps the original request, headers and body, so a failed verification can be compared byte for byte against what Bland sent.

### Undocumented retry schedule

**The Problem:** You cannot tell how long an outage can last before post-call data is lost.

**Why It Happens:** Bland says failures "trigger automatic retries based on the response code" for `4xx`, `429` and `5xx`, but publishes no count, interval or timeout.

**Workarounds:**

* Return `2xx` quickly and process asynchronously.
* After an outage, reconcile with Bland's call list API.

**How Hookdeck Can Help:** Hookdeck answers Bland straight away and stores each event, then [retries](/docs/retries) towards your service on a schedule you set. You can replay any event after a fix.

### Call failures look like successes

**The Problem:** A failed call is recorded as completed work.

**Why It Happens:** Bland delivers a failed call as a normal `200` post-call webhook, with the reason in `error_message`.

**Workarounds:**

* Check `error_message` and `status` before treating a call as successful.
* Route calls with a non-null `error_message` to a retry or review queue.

**How Hookdeck Can Help:** A Hookdeck filter on `error_message` can send failed calls to a separate destination.

### No replay protection or event ID

**The Problem:** A captured request still verifies if it is sent again, and retries can cause duplicates.

**Why It Happens:** Only the body is signed, with no timestamp, and there is no delivery ID header.

**Workarounds:**

* Key idempotency on `call_id` plus the kind of webhook (and `conversation_id` with `status` for SMS). See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeats based on the body fields you choose.

## Best practices

### Verify against the raw body

Verify before any JSON parsing. In Express, mount `express.raw()` on the webhook route. In Next.js, use `await request.text()`. In FastAPI, read `await request.body()`.

### Classify every request before handling it

Decide what kind of webhook you have from `event_type`, `category` and shape, and send unknown shapes to a log rather than dropping them.

### Subscribe only to the streams you use

The live `call` and `latency` streams send many small messages per call. If you only need outcomes, leave `webhook_events` empty or limit it to `citations`, `evals` or `post_transfer_transcript`.

### Acknowledge fast and process asynchronously

Write the payload to a queue and return `200`. Transcripts, summaries and citations can then be processed without holding Bland's request open. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Bland AI signs its webhooks with a hex HMAC-SHA256 in `X-Webhook-Signature`, keyed with one account secret you create under Account Settings > Keys. Verify against the raw body, classify each request before routing it, check `error_message` on post-call payloads, and acknowledge quickly, because Bland does not publish how long it keeps retrying.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Bland request, routes post-call, stream and SMS webhooks separately and holds each one until your service has processed it.

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