# Bland AI Webhooks

[Bland](https://www.bland.ai) 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.

```javascript
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_body, hashlib.sha256).hexdigest()` 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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/bland-ai-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/bland-ai-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/bland-ai-webhooks/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:

```json
{
  "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_type: "post_transfer_transcript"` | 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](https://github.com/hookdeck/webhook-skills/blob/main/skills/bland-ai-webhooks/references/overview.md) for the details.

## Delivery Semantics

* Return any `2xx` to acknowledge. `4xx`, `429` and `5xx` count as delivery failures and Bland retries automatically (the retry schedule isn't documented).
* A call's `call_id` can appear in several webhooks (post-call, citations, evals), so dedupe on `call_id` plus `event_type`, not `call_id` alone.
* No timestamp is signed, so a captured delivery can be replayed; idempotent handling is your protection.

## Environment Variables

```bash
BLAND_WEBHOOK_SECRET=...   # required; the account-wide signing secret. Handlers fail closed without it

```

## Local Development

```bash
# 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](https://github.com/hookdeck/webhook-skills/blob/main/skills/bland-ai-webhooks/references/overview.md) - Payloads, `webhook_events`, delivery behaviour
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/bland-ai-webhooks/references/setup.md) - Creating the signing secret, setting webhook URLs (inbound, outbound, dashboard)
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/bland-ai-webhooks/references/verification.md) - Verification details, gotchas, debugging