Gareth Wilson Gareth Wilson

Guide to Bland AI Webhooks: Features and Best Practices

Published


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

FeatureDetails
Configurationwebhook on each call, the Advanced section of an inbound number or the Send Call page, and per SMS number, request or batch
URL requirementhttps:// (the Send Call API rejects other schemes)
Subscription modelThe post-call webhook always fires; webhook_events opts a call into nine extra streams
Signature headerX-Webhook-Signature
SchemeHMAC-SHA256, lowercase hex, no prefix
Signed contentThe request body
SecretOne per account, created with "Replace Secret" under Account Settings > Keys, shown once
Replay windowNone. No timestamp is sent or signed
RetriesAutomatic on 4xx, 429 and 5xx; no schedule, count or timeout published
Testing"Test Webhook" button on an inbound number
Source IPsNot 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:

OptionWhat arrives
queueLive messages with category: "queue", such as "Call enqueued"
callLive messages with category: "call", such as "Call connected" and agent and user speech
latencyLive timing messages with category: "latency", such as "TTS: 218ms"
webhookLive messages about the pathway's webhook calls, arriving with category: "call"
toolLive messages about custom tools, arriving with category: "call"
dynamic_dataLive messages about stored dynamic data, arriving with category: "call"
citationsA separate post-processing webhook with extracted citation variables
evalsEval scores from an attached evals workbench, sent as a separate webhook when the run completes
post_transfer_transcriptThe 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_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.
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-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 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 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:

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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.