> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.voicebip.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.voicebip.com/_mcp/server.

# Webhooks

> Receive real-time events for calls, messages, and deliveries with HMAC-SHA256 signature verification.

Voicebip delivers real-time events to your webhook URL via HTTP POST with HMAC-SHA256 signatures.

## Event Types

### Voice

| Event                   | Description                                                                    |
| ----------------------- | ------------------------------------------------------------------------------ |
| `call.initiated`        | Call started — direction, numbers, agent                                       |
| `call.transcription`    | Real-time speech transcript from caller or agent                               |
| `call.agent_response`   | Text response generated by the AI before TTS synthesis                         |
| `call.barge_in`         | Caller spoke while the agent was talking — agent interrupted                   |
| `call.idle_silence`     | Caller was silent beyond the configured idle timeout                           |
| `call.quality`          | Per-call MOS score and RTP stats at call end                                   |
| `call.quality_degraded` | Real-time quality degradation detected (packet loss, jitter spike)             |
| `call.completed`        | Call ended with final duration, transcript, and disposition                    |
| `tool.invocation`       | AI agent called a custom tool — your webhook must respond with the tool result |

### SMS & WhatsApp

| Event                 | Description                                                                                                                                                           |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message.received`    | Inbound message received from a caller                                                                                                                                |
| `message.sent`        | Outbound message accepted for delivery                                                                                                                                |
| `message.delivered`   | MNO or WhatsApp confirmed message delivery to the handset                                                                                                             |
| `message.read`        | WhatsApp recipient opened the conversation and read the message (read receipt)                                                                                        |
| `message.dlr`         | SMS delivery receipt from the MNO with status code                                                                                                                    |
| `message.failed`      | Provider rejected an outbound message after Voicebip accepted it (e.g. invalid number, blocked content). Payload includes provider error code.                        |
| `message.send_failed` | Voicebip itself failed to dispatch an outbound message (e.g. quota exhausted, SMPP session down). Distinct from `message.failed`, which is a provider-side rejection. |
| `message.opt_out`     | Recipient sent STOP/UNSUBSCRIBE — they are automatically added to the workspace opt-out list                                                                          |
| `window.closing`      | WhatsApp 24-hour conversation window is closing in 60 minutes                                                                                                         |

> **Note**
>
> `call.barge_in`, `call.idle_silence`, `call.quality`, and `call.quality_degraded` can be high-volume on busy agents. If you don't process them, register a webhook endpoint that filters them out at the edge — every delivered event still counts against your endpoint's processing capacity.

### Internal NATS Subjects (Not Delivered to Webhooks)

Voicebip's internal pipelines publish additional subjects that are **not** forwarded to your webhook URL today. They are documented here so you can recognise them in support tickets and dashboard activity feeds, and so the boundary between "webhook events" and "internal pipeline events" is explicit.

| Subject                     | Purpose                                                                                                            |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `billing.low_balance`       | Workspace balance dropped below the configured warning threshold. Triggers an email to the workspace owner.        |
| `billing.blocked`           | Workspace balance reached zero. Voice + messaging refuse usage until top-up.                                       |
| `billing.unblocked`         | Paystack or Stripe top-up cleared zero balance. Usage resumes.                                                     |
| `billing.pricing.updated`   | Admin pricing change. Replicas flush their pricing cache.                                                          |
| `usage.recorded`            | Per-event usage line item (one per billable unit).                                                                 |
| `usage.voice_minutes`       | Aggregated voice minute roll-up.                                                                                   |
| `conversation.mode_changed` | A human agent took over (or released) a messaging conversation; surfaced in the dashboard.                         |
| `agent.config.invalidated`  | An agent's configuration changed; downstream caches invalidate.                                                    |
| `audit.request`             | API request audit trail for compliance.                                                                            |
| `delivery.dlq`              | Dead-letter for webhook deliveries that exhausted all 7 retry attempts. Replay via `POST /v1/webhooks/dlq/replay`. |

If you have a use case for receiving any of these as webhooks, [open an issue](https://github.com/voicebip/voicebip/issues) — most can be surfaced behind a feature flag.

## Payload Format

Every webhook delivery is a JSON object with a common outer envelope and an
event-type-specific `payload` field.

### Envelope

```json
{
  "event_id": "evt_abc123",
  "event_type": "call.completed",
  "channel": "voice",
  "agent_id": "agt_PAEZ_njcfm2kycpjs",
  "number": "+2349012345678",
  "from": "+2348031234567",
  "timestamp": "2026-05-23T12:34:56Z",
  "payload": { ... }
}
```

| Field        | Description                                                           |
| ------------ | --------------------------------------------------------------------- |
| `event_id`   | Stable ID — identical across all retry attempts. Use for idempotency. |
| `event_type` | One of the event types in the table above.                            |
| `channel`    | `voice`, `sms`, or `whatsapp`                                         |
| `agent_id`   | The agent that handled the call or message.                           |
| `number`     | Your Voicebip DID (E.164).                                            |
| `from`       | Remote party number (E.164).                                          |
| `timestamp`  | UTC ISO 8601 timestamp.                                               |
| `payload`    | Event-specific data — see below.                                      |

### `call.initiated`

Published when a call connects and media is flowing. Phone numbers are
always E.164; Nigerian DIDs use the +234 country code.

```json
{
  "event_id": "evt_abc123",
  "event_type": "call.initiated",
  "channel": "voice",
  "agent_id": "agt_PAEZ_njcfm2kycpjs",
  "number": "+2349012345678",
  "from": "+2348031234567",
  "timestamp": "2026-05-23T12:34:00Z",
  "payload": {
    "call_id": "call_01HXYZ...",
    "workspace_id": "ws_xyz123",
    "direction": "inbound",
    "from_number": "+2348031234567",
    "to_number": "+2349012345678",
    "mno": "mtn"
  }
}
```

| Payload field | Description                                                            |
| ------------- | ---------------------------------------------------------------------- |
| `call_id`     | Stable call ID — use this to correlate all subsequent events.          |
| `direction`   | `inbound` (caller rang your number) or `outbound` (your agent called). |
| `from_number` | Caller E.164.                                                          |
| `to_number`   | Called DID (E.164).                                                    |
| `mno`         | MNO trunk used: `mtn`, `glo`, `airtel`, or `9mobile`.                  |

### `call.transcription`

Published on every final STT transcript — once per caller utterance. The
`turn_id` matches the `turn_id` field in the BYOM webhook request for the
same utterance.

```json
{
  "event_id": "evt_def456",
  "event_type": "call.transcription",
  "channel": "voice",
  "agent_id": "agt_PAEZ_njcfm2kycpjs",
  "timestamp": "2026-05-23T12:34:07Z",
  "payload": {
    "call_id": "call_01HXYZ...",
    "turn_id": "turn_xk9j...",
    "text": "I'd like to check my outstanding balance.",
    "is_final": true,
    "confidence": 0.95,
    "conversation_history": [
      { "turn_id": "turn_ab12", "role": "agent",  "text": "Hi, this is Kemi. How can I help?", "timestamp": "2026-05-23T12:34:02Z" },
      { "turn_id": "turn_xk9j", "role": "caller", "text": "I'd like to check my outstanding balance.", "timestamp": "2026-05-23T12:34:07Z" }
    ]
  }
}
```

| Payload field          | Description                                                             |
| ---------------------- | ----------------------------------------------------------------------- |
| `turn_id`              | Stable ID for this turn. Matches the BYOM request `messages[].turn_id`. |
| `text`                 | Transcribed caller speech.                                              |
| `is_final`             | Always `true` for webhook-delivered events.                             |
| `confidence`           | STT confidence score (0.0–1.0).                                         |
| `conversation_history` | Rolling history at this point (last 20 turns).                          |

### `call.completed`

Published when the call ends. Contains the full transcript and
AI-mode metadata. Check `ai_mode` to confirm whether the call was handled
by your BYOM webhook (`"byom"`) or a hosted provider.

```json
{
  "event_id": "evt_ghi789",
  "event_type": "call.completed",
  "channel": "voice",
  "agent_id": "agt_PAEZ_njcfm2kycpjs",
  "timestamp": "2026-05-23T12:35:02Z",
  "payload": {
    "call_id": "call_01HXYZ...",
    "workspace_id": "ws_xyz123",
    "status": "completed",
    "duration_seconds": 62,
    "direction": "inbound",
    "mno_used": "mtn",
    "ai_mode": "byom",
    "recording_url": "/v1/calls/call_01HXYZ.../recording",
    "quality_score": 4.1,
    "transcript": [
      { "turn_id": "turn_ab12", "role": "agent",  "text": "Hi, this is Kemi. How can I help?",            "timestamp": "2026-05-23T12:34:02Z" },
      { "turn_id": "turn_xk9j", "role": "caller", "text": "I'd like to check my outstanding balance.",    "timestamp": "2026-05-23T12:34:07Z" },
      { "turn_id": "turn_mn34", "role": "agent",  "text": "Your balance is ₦45,000, due April 20th.",     "timestamp": "2026-05-23T12:34:09Z" }
    ]
  }
}
```

| Payload field      | Description                                                                                     |
| ------------------ | ----------------------------------------------------------------------------------------------- |
| `status`           | Always `completed`.                                                                             |
| `duration_seconds` | Total call length in seconds.                                                                   |
| `ai_mode`          | `byom` or `hosted` (hosted live-audio calls report a distinct live-audio mode).                 |
| `recording_url`    | API path to the recording; empty string if recording was off.                                   |
| `quality_score`    | MOS score (1.0–5.0) sampled at hangup; `0` when unavailable.                                    |
| `transcript`       | Full turn-by-turn history (last 20 turns, objects with `turn_id`, `role`, `text`, `timestamp`). |

> **Note**
>
> BYOM agents: lifecycle events (`call.initiated`, `call.transcription`,
> `call.completed`) are delivered to the **same `webhook_url`** as the per-turn
> BYOM requests — they are not separate URLs. Check for an `event_type` field
> in the request body to tell them apart: lifecycle events carry `event_type`
> and expect `{"received": true}`; BYOM turns do not carry `event_type` and
> expect a `BYOMVoiceWebhookResponse`. See the [BYOM guide](/guides/byom) for
> the turn request/response schema and HMAC verification.

## Signature Verification

Every webhook delivery includes the following headers:

| Header                 | Type     | Description                                                                                                                         |
| ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `X-Voicebip-Signature` | `string` | `sha256={hex_digest}` — HMAC-SHA256 over `"{timestamp}.{body}"` using your signing secret                                           |
| `X-Voicebip-Timestamp` | `string` | Unix timestamp (seconds) used in the signature. Read this to reconstruct the signed payload and reject replays older than 5 minutes |
| `X-Voicebip-Event-ID`  | `string` | Stable event ID — identical across all retry attempts. Use for idempotency                                                          |

The signed payload is **`"{timestamp}.{body}"`** — the Unix timestamp string, a literal `.`, then the raw request body. This lets you reject replayed deliveries by checking the timestamp age.

```python
import hmac
import hashlib
import time

def verify_webhook(body: bytes, headers: dict, secret: str, max_age_seconds: int = 300) -> bool:
    signature = headers.get("X-Voicebip-Signature", "")
    timestamp = headers.get("X-Voicebip-Timestamp", "")
    if not timestamp:
        return False
    # Reject replays older than max_age_seconds (default 5 minutes)
    if abs(time.time() - int(timestamp)) > max_age_seconds:
        return False
    signed_payload = f"{timestamp}.{body.decode('utf-8')}".encode()
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"sha256={expected}", signature)
```

```go
import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "math"
    "net/http"
    "strconv"
    "time"
)

func verifyWebhook(r *http.Request, body []byte, secret string) bool {
    signature := r.Header.Get("X-Voicebip-Signature")
    tsStr := r.Header.Get("X-Voicebip-Timestamp")
    if tsStr == "" {
        return false
    }
    ts, err := strconv.ParseInt(tsStr, 10, 64)
    if err != nil {
        return false
    }
    // Reject replays older than 5 minutes
    skew := time.Since(time.Unix(ts, 0))
    if skew < 0 {
        skew = -skew
    }
    if skew > 5*time.Minute {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(tsStr))
    mac.Write([]byte{'.'})
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return subtle.ConstantTimeCompare([]byte(expected), []byte(signature)) == 1
}
```

## Retry Policy

Failed webhook deliveries are retried with exponential backoff:

| Attempt | Delay      |
| ------- | ---------- |
| 1       | Immediate  |
| 2       | 1 minute   |
| 3       | 5 minutes  |
| 4       | 15 minutes |
| 5       | 1 hour     |
| 6       | 4 hours    |
| 7       | 24 hours   |

A delivery is considered failed if your endpoint returns a non-2xx status code or times out after 10 seconds.

## Testing Webhooks

`POST /v1/webhooks/test` delivers a realistic mock payload to any URL with a
valid HMAC signature — no real call or message is needed.

```bash
# Simulate a call.completed event
curl -X POST "https://api.voicebip.com/v1/webhooks/test" \
  -H "Authorization: Bearer pk_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{"webhook_url": "https://your-server.com/webhook", "event_type": "call.completed"}'
```

The mock payload always includes:

* Valid Nigerian E.164 phone numbers (+2348031234567, +2349012345678)
* Realistic conversation history
* A valid `X-Voicebip-Signature` HMAC header so your verification code runs

Supported `event_type` values: `call.initiated`, `call.transcription`,
`call.completed`, `call.barge_in`, `call.idle_silence`, `call.quality`,
`call.quality_degraded`, `message.received`, `message.sent`,
`message.delivered`, `message.failed`.

### Testing BYOM Routing End-to-End

The fastest way to verify your BYOM webhook before a live call:

1. Expose a local endpoint with ngrok: `ngrok http 8080`
2. Update your agent's `webhook_url` to the ngrok URL
3. Use a `pk_test_` API key — sandbox mode synthesizes the full BYOM turn
   without a real SIP call or billing charge
4. Fire a `call.transcription` test event to your events endpoint to see
   what the event stream looks like alongside the BYOM turn requests:

```bash
curl -X POST "https://api.voicebip.com/v1/webhooks/test" \
  -H "Authorization: Bearer pk_test_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://your-ngrok.ngrok-free.app/events",
    "event_type": "call.completed"
  }'
```

5. Make a sandbox test call (or use the Dashboard's "Test call" button) and
   watch both channels: BYOM turn POSTs arrive at `webhook_url`, lifecycle
   events arrive at the registered events endpoint.

### Signing Secret Rotation

When you rotate your workspace's signing secret, Voicebip sends both the new and old signatures during a 24-hour grace period:

* `X-Voicebip-Signature` — signature using the new secret
* `X-Voicebip-Signature-Previous` — signature using the old secret (present during 24h grace window only)

Your verification code should accept either signature during rotation to avoid rejecting valid deliveries.

## Multiple Webhook Endpoints (Fan-Out)

Each agent supports multiple webhook URLs. When a matching event fires, the platform delivers the same payload to every active endpoint independently — each has its own attempt counter, retry queue, and delivery status. One slow or failing endpoint does not affect delivery to the others.

### Register an Endpoint

```bash
curl -X POST "https://api.voicebip.com/v1/agents/agt_PAEZ_njcfm2kycpjs/webhooks/endpoints" \
  -H "Authorization: Bearer pk_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://primary.example.com/webhook",
    "description": "Primary handler"
  }'
```

Response:

```json
{ "id": "whep_abc123def456gh78" }
```

### List Endpoints

```bash
curl "https://api.voicebip.com/v1/agents/agt_PAEZ_njcfm2kycpjs/webhooks/endpoints" \
  -H "Authorization: Bearer pk_live_your_key"
```

```json
{
  "endpoints": [
    {
      "id": "whep_abc123def456gh78",
      "agent_id": "agt_PAEZ_njcfm2kycpjs",
      "url": "https://primary.example.com/webhook",
      "active": true,
      "description": "Primary handler"
    },
    {
      "id": "whep_zyx987wvu654ts32",
      "agent_id": "agt_PAEZ_njcfm2kycpjs",
      "url": "https://backup.example.com/webhook",
      "active": true,
      "description": "Backup handler"
    }
  ]
}
```

### Pause / Resume an Endpoint

Toggle the `active` flag without deleting the endpoint:

```bash
curl -X PATCH "https://api.voicebip.com/v1/agents/agt_PAEZ_njcfm2kycpjs/webhooks/endpoints/whep_abc123def456gh78" \
  -H "Authorization: Bearer pk_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "active": false }'
```

### Delete an Endpoint

```bash
curl -X DELETE "https://api.voicebip.com/v1/agents/agt_PAEZ_njcfm2kycpjs/webhooks/endpoints/whep_abc123def456gh78" \
  -H "Authorization: Bearer pk_live_your_key"
```

> **Note**
>
> The legacy `webhook_url` field on the agent object is the backward-compatible single-endpoint configuration. It remains the fallback destination when no rows exist in `webhook_endpoints`. Both mechanisms can coexist — if an agent has a `webhook_url` **and** registered endpoints, the consumer uses the registered endpoints and treats `webhook_url` as a belt-and-braces fallback only when no active endpoint rows are found.

## Webhook Delivery Log

Monitor recent webhook deliveries via the API or the [Voicebip Dashboard](https://app.voicebip.com/webhooks).

### List Deliveries

```bash
curl "https://api.voicebip.com/v1/webhooks/deliveries?page_size=20" \
  -H "Authorization: Bearer pk_live_your_key"
```

Each delivery entry includes the event type and payload, HTTP status code from your endpoint, retry attempt count, and next retry timestamp if applicable.

### Get a Single Delivery

```bash
curl "https://api.voicebip.com/v1/webhooks/deliveries/del_abc123" \
  -H "Authorization: Bearer pk_live_your_key"
```

### Replay Failed Deliveries (DLQ)

If your endpoint was down during the retry window, you can replay failed deliveries from the dead-letter queue:

```bash
curl -X POST "https://api.voicebip.com/v1/webhooks/dlq/replay" \
  -H "Authorization: Bearer pk_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{"event_ids": ["evt_abc123", "evt_def456"]}'
```

Replayed deliveries restart the full 7-attempt retry sequence from attempt 1.