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:
{
"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:
{
"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:
{
"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_. 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:
- Outbound calls from the API: pass
webhook(and optionallywebhook_events) in the Send Call request. - Outbound calls from the dashboard: open the Send Call page, expand Advanced and fill in the webhook field.
- Inbound calls: open Inbound Phone Numbers, select the number, expand Advanced and fill in the webhook field. "Test Webhook" sends a test payload.
- 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
globalobject.
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.
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:
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 verifies Bland's
X-Webhook-Signatureheader 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, thencategory, then shape (conversation_idmeans SMS,call_lengthandcompletedmean post-call). - Send live streams to a separate endpoint from post-call data, using the per-call
webhookURL.
How Hookdeck Can Help: Hookdeck 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
2xxquickly 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 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_messageandstatusbefore treating a call as successful. - Route calls with a non-null
error_messageto 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_idplus the kind of webhook (andconversation_idwithstatusfor SMS). See our guide to webhook idempotency.
How Hookdeck Can Help: Hookdeck's 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.
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 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 for free and handle Bland AI webhooks reliably in minutes.