Agent skill
Bland AI Webhooks Skill
Receive and verify Bland AI webhooks (AI phone calls). Use when setting up a Bland post-call webhook endpoint, creating the webhook signing secret, debugging X-Webhook-Signature (HMAC-SHA256) verification failures, or handling post-call payloads with call_id, status, transcripts, citations and post_transfer_transcript.
Install this skill
npx skills add hookdeck/webhook-skills --skill bland-ai-webhooks
Bland runs AI phone agents for inbound and outbound calls. When a call ends, Bland POSTs the call record (call_id, status, transcripts, summary, variables, ...) to the webhook URL you set on the call or on an inbound number. If you have created an account signing secret, each delivery carries an X-Webhook-Signature header so you can verify that Bland sent it.
When to Use This Skill
- How do I receive Bland AI post-call webhooks?
- How do I verify a Bland webhook signature (
X-Webhook-Signature)? - Where do I get the Bland webhook signing secret? (Dev Portal → Account Settings → Keys → Replace Secret; shown once.)
- How do I handle the post-call payload, citations, or the post-transfer transcript?
- Why is my Bland webhook signature verification failing?
Verification (core)
| Header | Value |
|---|---|
X-Webhook-Signature | Hex-encoded HMAC-SHA256 of the request body, keyed with your account webhook signing secret |
There is no timestamp and no message id in the signature. Bland's docs sign JSON.stringify(req.body); use the raw body bytes instead. They are identical for the compact JSON Bland sends, and raw bytes don't depend on re-serialisation.
const crypto = require('crypto');
function verifyBlandSignature(rawBody, signature, secret) { // rawBody: exact bytes, NOT re-serialised JSON
if (!signature || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(signature.trim().toLowerCase()), Buffer.from(expected));
} catch {
return false; // different lengths = invalid
}
}
// Express: mount the route with express.raw({ type: '*/*' }) so req.body is the raw bytes.
Python: hmac.new(secret.encode(), raw_ compared with hmac.compare_digest. Bland has no webhook-verification SDK, so all three examples verify manually.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Enterprise accounts can switch to JWT-signed webhooks (RS256, verified against Bland's JWKS). That is a different scheme and is not covered by this skill.
Payload Shape
Bland's standard post-call webhook has no event-type field. It is the call record, sent once when the call ends:
{
"call_id": "12345678-1234-1234-1234-123456789012",
"status": "completed",
"completed": true,
"call_length": 0.18,
"to": "+15551234567",
"from": "+15559876543",
"error_message": null,
"summary": "...",
"concatenated_transcript": "user: Hello? \n assistant: ...",
"transcripts": [{ "id": 1, "user": "user", "text": "Hello?", "created_at": "..." }],
"variables": {},
"metadata": {}
}
Failed calls (busy, number not found, ...) still arrive as a 200 delivery with error_message set.
Common Payload Types
| Payload | How to recognise it | Sent when |
|---|---|---|
| Post-call | call_id + status (e.g. completed), no event_type | The call ends (the webhook set on the call or inbound number) |
citations | event_type: "citations" | Separate webhook after the call, if citations is in webhook_events. With the dashboard "Delay post call webhook" toggle, citations and corrected_transcript are merged into the post-call payload instead |
post_transfer_transcript | event_ | Call was transferred to a human and post_transfer_transcript is in webhook_events (limited rollout) |
evals | event_type: "evals" | Eval scores from an attached workbench; separate webhook when the run completes |
multiple | event_type: "multiple" + contents array | One payload carries more than one enrichment |
| SMS | conversation_id instead of call_id | Each SMS/RCS/WhatsApp message and conversation status change |
See references/overview.md for the details.
Delivery Semantics
- Return any
2xxto acknowledge.4xx,429and5xxcount as delivery failures and Bland retries automatically (the retry schedule isn't documented). - A call's
call_idcan appear in several webhooks (post-call, citations, evals), so dedupe oncall_idplusevent_type, notcall_idalone. - No timestamp is signed, so a captured delivery can be replayed; idempotent handling is your protection.
Environment Variables
BLAND_WEBHOOK_SECRET=... # required; the account-wide signing secret. Handlers fail closed without it
Local Development
# Express / Next.js (use 8000 for FastAPI). No install or account required.
npx hookdeck-cli listen 3000 bland-ai --path /webhooks/bland-ai
Use the printed https:// URL as the webhook parameter when sending a call, then place a call and let it end.
Reference Materials
- references/overview.md - Payloads,
webhook_events, delivery behaviour - references/setup.md - Creating the signing secret, setting webhook URLs (inbound, outbound, dashboard)
- references/verification.md - Verification details, gotchas, debugging