> 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.

# BYOM (Bring Your Own Model)

> Plug your own LLM or agent logic into Voicebip calls via a single webhook endpoint.

BYOM lets you drive an agent's conversation with **any LLM or agent framework you
choose** — OpenAI, Anthropic Claude, Llama on Ollama, a LangGraph pipeline,
or a deterministic state machine. Voicebip handles telephony, speech-to-text,
text-to-speech, MNO failover, call control, billing, and HMAC-signed events; your
webhook only has to return the next thing the agent should say.

If you want Voicebip to host the model for you, see [Agents](/guides/agents) and
set `ai_provider` to a hosted provider slug (list them with `GET /v1/ai/models`) instead.

## Hosted AI vs BYOM

|                    | Hosted AI                                        | BYOM                                       |
| ------------------ | ------------------------------------------------ | ------------------------------------------ |
| Model choice       | Voicebip-hosted models (see `GET /v1/ai/models`) | Anything you can host                      |
| System prompt      | Managed by Voicebip                              | Managed by you                             |
| Conversation state | Managed by Voicebip                              | Passed to you every turn                   |
| Turn latency (P95) | \< 500ms end-to-end                              | \< 500ms + your webhook round-trip         |
| When to use        | Fastest path, standard use cases                 | Custom logic, proprietary models, tool use |

Both modes use the same voice pipeline, the same phone numbers, and the same
event stream. You can switch an agent between modes with a single `PATCH` —
nothing else in your integration changes.

## Configure an Agent for BYOM

Set `ai_provider` to `byom` and point `webhook_url` at the endpoint that will
handle conversation turns.

```bash
curl -X POST "https://api.voicebip.com/v1/agents" \
  -H "Authorization: Bearer pk_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Loan Recovery Agent",
    "language": "en",
    "ai_provider": "byom",
    "webhook_url": "https://your-server.com/voicebip/byom"
  }'
```

> **Note**
>
> Your `webhook_url` receives **everything** for that agent — both BYOM turn
> requests and lifecycle events (`call.initiated`, `call.transcription`,
> `call.completed`). Distinguish them by the presence of an `event_type` field
> in the JSON body: if `event_type` is present it is a lifecycle event and you
> should return `{"received": true}`; if absent it is a BYOM turn request and
> you must return a `BYOMVoiceWebhookResponse` with a `text` field. See
> [Webhooks](/guides/webhooks) for the lifecycle event payload shapes.

## The Turn Contract

On every caller turn, Voicebip POSTs a JSON body to your `webhook_url` and
expects a JSON response. This is a single request/response — BYOM does not
stream tokens.

### Request

```json
{
  "event": "turn",
  "agent_id": "agt_PAEZ_njcfm2kycpjs",
  "call_id": "call_01HXYZ...",
  "from_number": "+2348031234567",
  "to_number": "+2341000000000",
  "transcription": "I'd like to check my outstanding balance.",
  "messages": [
    { "role": "agent",  "text": "Hi, this is Kemi from FairMoney. How can I help?", "timestamp": "2026-04-14T09:00:02Z" },
    { "role": "caller", "text": "I'd like to check my outstanding balance.",         "timestamp": "2026-04-14T09:00:07Z" }
  ]
}
```

| Field           | Type   | Description                                                                                                                                               |
| --------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event`         | string | `"turn"` for a normal turn, `"call.screening"` for the pre-greeting screening call (see [Call Screening](#call-screening-allow--reject-inbound-callers)). |
| `agent_id`      | string | The agent receiving the call.                                                                                                                             |
| `call_id`       | string | Stable ID for this call. Use it as the session key on your side.                                                                                          |
| `from_number`   | string | Caller's number in E.164 (inbound calls). Useful for CRM lookups and screening.                                                                           |
| `to_number`     | string | The dialed Voicebip number in E.164.                                                                                                                      |
| `transcription` | string | The most recent caller utterance — the turn you need to respond to.                                                                                       |
| `messages`      | array  | Rolling conversation history (see [Conversation History](#conversation-history)).                                                                         |

Each entry in `messages` has:

| Field       | Type              | Description                                        |
| ----------- | ----------------- | -------------------------------------------------- |
| `turn_id`   | string            | Stable ID for this turn. Useful for deduplication. |
| `role`      | string            | `"caller"` or `"agent"`                            |
| `text`      | string            | STT transcript (caller) or TTS text (agent)        |
| `timestamp` | string (ISO 8601) | UTC time when the turn was recorded                |

You can pass the array straight into an LLM's `messages` parameter after mapping roles.

### Response

Return a JSON body with the agent's next action. Only `text` is required.

```json
{
  "text": "Your outstanding balance is ₦45,000, due on April 20th. Would you like to pay now?",
  "end_call": false,
  "transfer_to": null,
  "dtmf": null
}
```

| Field         | Type    | Description                                                                          |
| ------------- | ------- | ------------------------------------------------------------------------------------ |
| `text`        | string  | What the agent should say next. Rendered via TTS and played to the caller. Required. |
| `end_call`    | boolean | If `true`, Voicebip speaks `text`, then hangs up cleanly.                            |
| `transfer_to` | string  | E.164 number to transfer the caller to after speaking `text`.                        |
| `dtmf`        | string  | DTMF digits to play on the line (e.g. `"1"` to navigate an IVR).                     |

`end_call`, `transfer_to`, and `dtmf` are mutually exclusive with a normal
continue-the-conversation response — pick one per turn.

## Voice Pipeline

When a call comes in on a BYOM agent:

```
Caller speaks
  → the voice pipeline (RTP media)
  → STT transcription
  → POST to your webhook_url
  → { text, end_call?, transfer_to?, dtmf? }
  → TTS synthesis
  → Caller hears the response
```

Your webhook sits squarely on the latency critical path. Voicebip enforces a
**5-second hard timeout** per request; responses that arrive later are treated
as a webhook failure.

## Call Screening (allow / reject inbound callers)

BYOM agents can screen **inbound** calls against your own logic — a caller-ID
whitelist, a CRM lookup, business hours — *before* the call reaches the agent,
and optionally supply a per-call greeting. Enable it on the agent:

```bash
curl -X PATCH https://api.voicebip.com/v1/agents/agt_xxx \
  -H "Authorization: Bearer pk_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "screening_enabled": true, "screening_fail_closed": false }'
```

When enabled, Voicebip makes **one synchronous call to the same `webhook_url`**
just before the greeting, with `event: "call.screening"` so you can branch:

```json
{
  "event": "call.screening",
  "agent_id": "agt_xxx",
  "call_id": "call_xxx",
  "from_number": "+2348031234567",
  "to_number": "+2341000000000",
  "messages": []
}
```

Respond using the **same `BYOMVoiceWebhookResponse`** you already return for turns:

| Response                                                       | Effect                                                                                   |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `{ "end_call": false }`                                        | **Allow** — the call proceeds and the agent plays its configured `greeting_text`.        |
| `{ "end_call": false, "text": "Welcome back, Ada!" }`          | **Allow** with a **dynamic greeting** — `text` is spoken instead of the static greeting. |
| `{ "end_call": true }`                                         | **Reject** — the call is hung up silently.                                               |
| `{ "end_call": true, "text": "Sorry, this line is private." }` | **Reject** — `text` is spoken, then the call hangs up.                                   |

The screening call carries the same HMAC headers as a turn (verify it the same
way). It runs on call setup, so keep it fast — the default timeout is **3
seconds** (override with `SCREENING_WEBHOOK_TIMEOUT_SECONDS` on self-hosted
deployments).

**Failure policy.** If your endpoint errors or times out, `screening_fail_closed`
decides: `false` (default) **allows** the call through (prioritises not dropping
legitimate callers during an outage); `true` **rejects** it (prioritises the
whitelist's security guarantee). Pick per agent.

> **Note**
>
> Screening is BYOM-only — it needs your server to make the allow/reject decision.
> Hosted-AI agents have no screening hook. Normal per-turn requests
> carry `event: "turn"`, so branch on `event` to keep the two flows separate.

## Latency Budget

The voice pipeline targets **\< 800ms P95 from your webhook response to TTS
playback start**. To stay inside a natural-feeling turn:

* **\< 300ms** is the target for your webhook round-trip end-to-end.
* **\< 500ms + webhook RTT** is the realistic total turn latency callers perceive.
* **5s** is the hard timeout. Beyond that, the turn is dropped.

Practical implications:

* Host your webhook in the **same region** as the call. EU/Africa regions are recommended for Nigerian traffic; US regions add \~200ms baseline.
* Use a **warm, persistent HTTP server**. Cold-started serverless functions blow
  the budget on the first turn of every call.
* **Stream from your LLM internally** and start responding as soon as you have
  the first complete sentence — don't wait for the full completion.
* For tool calls that can't finish in 300ms, return a filler like `"One moment
  while I check that..."` and resolve the real answer on the next turn.

See [Best Practices](/reference/best-practices) for the full latency table.

## Conversation History

Voicebip automatically trims `messages` to the **last 20 turns** before sending.
This keeps payloads small and LLM context windows efficient. You do **not** need
to maintain your own session store — pass the array straight through to your LLM
as conversation context, keyed by `call_id` if you need to correlate with
anything else on your side.

If you need the full transcript, subscribe to the `call.completed` event — it
carries the complete turn-by-turn history when the call ends.

## Errors and Failure Modes

BYOM has **no hosted fallback**. An agent configured with `ai_provider: byom`
will only use your webhook — if it's unreachable or the configuration is
missing, the turn fails and the caller hears the configured failure prompt.

| Failure                      | Behavior                                                                 |
| ---------------------------- | ------------------------------------------------------------------------ |
| `webhook_url` missing        | Agent creation/update fails validation. No valid BYOM agent without one. |
| Webhook returns non-200      | Turn fails. Per-workspace circuit breaker records the failure.           |
| Webhook times out (> 5s)     | Same as non-200 — turn fails, breaker records the failure.               |
| Webhook returns invalid JSON | Turn fails.                                                              |
| Response body > 1 MB         | Truncated; turn fails. Keep responses small.                             |

### Circuit Breaker

Each workspace has a BYOM circuit breaker. After **5 consecutive failures** or
**> 50% error rate over 30 seconds**, the breaker trips to `OPEN` and new turns
fail fast for **30 seconds** before entering `HALF-OPEN` probe mode. This
protects the voice pipeline from a broken webhook dragging down every call on
the workspace.

Watch the `byom.webhook.errors` and `byom.breaker.state` metrics (or the
equivalent dashboard panels) if your agents go quiet.

## Security

### HMAC Signature Verification

Voicebip signs every BYOM request (voice and messaging) with two headers:

```
X-Voicebip-Signature: sha256=<hex_digest>
X-Voicebip-Timestamp: <unix_seconds>
```

The signature covers `"{timestamp}.{raw_body}"` — the timestamp string, a
literal dot, then the exact raw request body bytes:

```
HMAC-SHA256(workspace_signing_secret, "{X-Voicebip-Timestamp}.{raw_body}")
digest = hex.EncodeToString(hmac_bytes)
expected_header = "sha256=" + digest
```

Verification checklist:

1. Read `X-Voicebip-Timestamp` and reject if `|now - timestamp| > 300 s`
   (replay protection).
2. Compute the HMAC over the **raw** body bytes — before any JSON parsing.
3. Compare with `hmac.Equal` (constant-time) — never a plain string compare.
4. Reject requests where the signature is absent or wrong.

```python
# Python
import hmac, hashlib, time

def verify(secret: str, timestamp: str, raw_body: bytes, sig_header: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    message = f"{timestamp}.".encode() + raw_body
    digest = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    expected = "sha256=" + digest
    return hmac.compare_digest(expected, sig_header)
```

```go
// Go
func Verify(secret, timestamp string, body []byte, sigHeader string) bool {
    if ts, err := strconv.ParseInt(timestamp, 10, 64); err != nil || abs(time.Now().Unix()-ts) > 300 {
        return false
    }
    message := append([]byte(timestamp+"."), body...)
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(message)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(sigHeader))
}
```

### Secret Rotation

When you rotate via `POST /v1/workspace/signing-secret/rotate`, Voicebip
moves the old secret to a 24-hour grace window. During that window, **two**
signature headers appear on every BYOM request:

* `X-Voicebip-Signature` — new secret
* `X-Voicebip-Signature-Previous` — old secret

Accept either as valid during your rollout, then stop accepting the old one
after 24 hours. This prevents dropped turns while you deploy the new secret.

### Other security practices

* **Idempotency**: Voicebip does not retry BYOM turns (unlike event webhooks),
  but you may receive duplicate `call_id + transcription` pairs if a caller
  repeats themselves. Don't assume uniqueness.
* **Never trust `transcription` as sanitized input.** Treat it as user content
  and pass it to your LLM through the same guards you'd use for any user text.
* **Log `call_id` and `agent_id`** on every turn so you can correlate with
  Voicebip's request IDs when debugging.

## Minimal Handler

A working Node.js/Express handler with HMAC verification lives in
[Code Examples](/reference/code-examples#expressjs-byom-webhook-handler). The
shape below is the bare minimum if you already have signature verification
wired up.

```javascript
app.post("/voicebip/byom", (req, res) => {
  const { call_id, transcription, messages } = req.body;

  const reply = await myLLM.complete({
    system: "You are a friendly loan recovery agent for a Nigerian fintech.",
    messages: messages.map(m => ({
      role: m.role === "caller" ? "user" : "assistant",
      content: m.text,
    })),
  });

  res.json({ text: reply, end_call: false });
});
```

Flask and Go versions are also in [Code Examples](/reference/code-examples).

## Testing Your Webhook

Before wiring a real number, test the turn contract end-to-end:

1. **Point the agent at an ngrok tunnel** — `webhook_url:
   "https://abc123.ngrok-free.app/voicebip/byom"`.
2. **Use the sandbox** — any API key prefixed with `pk_test_` routes to sandbox
   mode. Sandbox calls synthesize transcription events and exercise the full
   BYOM path without touching real SIP/SMPP or being billed.
3. **Fire a test lifecycle event** with `POST /v1/webhooks/test` to confirm
   your event handler works before a live call. See [Webhooks → Testing](/guides/webhooks#testing-webhooks) for the full list of supported
   `event_type` values and a BYOM-specific end-to-end testing checklist.
4. **Place a real call** to an agent's number (still in sandbox) and watch your
   handler logs plus the dashboard's live transcript view.

## Live Audio Mode

Some hosted providers offer a **live audio streaming mode** — a bidirectional
audio pipeline that bypasses the traditional STT → LLM → TTS steps entirely and
saves \~400–600ms per turn. Enable it by setting `ai_provider` to a live-audio
capable hosted provider (list available providers with `GET /v1/ai/models`).
Calls running in this mode report a live-audio `ai_mode` on the `call.completed`
event, distinct from the standard `hosted` value.

### The pipeline modes

| Mode                | How it works                                                                                  | Latency profile                                            |
| ------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `byom`              | Voicebip runs STT, posts transcript to your webhook, runs TTS on your reply                   | STT + your round-trip + TTS                                |
| Hosted (standard)   | Voicebip runs STT, calls the hosted model, runs TTS                                           | STT + LLM + TTS (\~500ms P95 target)                       |
| Hosted (live audio) | Raw audio streams bidirectionally to the hosted live-audio model; no separate STT or TTS step | The live model's own pipeline (saves \~400–600ms per turn) |

### Turn detection and barge-in

In live audio mode, the hosted model owns turn detection entirely — it decides
when the caller has finished speaking and when the agent should respond.

* **Turn sensitivity** is governed by the hosted live-audio session, not by Voicebip agent settings.
* **The idle-silence prompt** (played after a long caller silence) only applies to the classic STT path and does not run in live audio mode.
* **Barge-in** behavior is governed by the live model's real-time audio processing rather than the Voicebip barge coordinator used on classic STT calls.

### SSE transcript stream

In live audio mode, live transcript events may not appear in the SSE transcript
stream (`/v1/calls/{id}/transcript/stream`). For authoritative call transcripts,
use the `call.completed` webhook event — it carries the full turn-by-turn history
when the call ends.

## Messaging BYOM (SMS + WhatsApp)

The same `webhook_url` on an agent also handles **inbound SMS and WhatsApp
messages**. Each inbound message triggers a POST to your webhook with the
conversation history — route on `channel` to handle SMS and WhatsApp separately.

### Messaging Request

```json
{
  "agent_id": "agt_PAEZ_njcfm2kycpjs",
  "conversation_id": "conv_msg_01HXY...",
  "channel": "whatsapp",
  "from_number": "+2348012345678",
  "to_number": "+2349012345678",
  "inbound_message": {
    "message_id": "msg_01HXY...",
    "body": "Hello, I need help with my account.",
    "type": "text",
    "received_at": "2026-05-23T12:34:56Z"
  },
  "history": [
    { "direction": "inbound",  "body": "Hello, I need help with my account.", "created_at": "2026-05-23T12:34:56Z" },
    { "direction": "outbound", "body": "Hi! How can I help you today?",        "created_at": "2026-05-23T12:35:01Z" }
  ],
  "metadata": {
    "whatsapp": {
      "window_open": true,
      "window_expires_at": "2026-05-24T12:34:56Z"
    }
  }
}
```

| Field               | Type   | Description                                                   |
| ------------------- | ------ | ------------------------------------------------------------- |
| `agent_id`          | string | The agent this conversation belongs to.                       |
| `conversation_id`   | string | Stable ID for the conversation. Use as session key.           |
| `channel`           | string | `"whatsapp"` or `"sms"`                                       |
| `from_number`       | string | Sender E.164 number.                                          |
| `to_number`         | string | Your Voicebip number (E.164).                                 |
| `inbound_message`   | object | The message that triggered this turn (see below).             |
| `history`           | array  | Prior turns, oldest first, capped at 20. Empty on first turn. |
| `metadata.whatsapp` | object | Present on WhatsApp: `window_open`, `window_expires_at`.      |
| `metadata.sms`      | object | Present on SMS: `encoding` (`GSM7`/`UCS2`), `segments`.       |

### Messaging Response

```json
{
  "reply": "I can help you with that. Can I have your account number?"
}
```

| Field              | Type      | Description                                                        |
| ------------------ | --------- | ------------------------------------------------------------------ |
| `reply`            | string    | Free-form reply text. Max 160 chars (GSM-7 single segment) on SMS. |
| `end_conversation` | boolean   | Close the conversation.                                            |
| `escalate`         | string    | Reason for escalating to a human. Sets `ai_mode=human`.            |
| `template_name`    | string    | WhatsApp pre-approved template name (for closed 24h windows).      |
| `template_params`  | string\[] | Values for `{{1}}`, `{{2}}` placeholders in the template.          |

When multiple fields are set, precedence is: `end_conversation` > `escalate` >
`template_name` > `reply`. An empty `{}` body is valid — use it to silently
acknowledge and take action on your side without sending a reply.

> **Note**
>
> HMAC signing is identical to voice BYOM — same headers (`X-Voicebip-Signature`,
> `X-Voicebip-Timestamp`), same algorithm, same rotation grace-period behaviour.
> See [HMAC Signature Verification](#hmac-signature-verification) above.

## Switching Modes Mid-Lifetime

Switching an agent between `byom` and a hosted provider is a single `PATCH`
on `ai_provider`. New calls use the new mode immediately; in-flight calls keep
the mode they started with. This is the intended migration path if you prototype
on hosted AI and later move to BYOM (or vice versa).

```bash
curl -X PATCH "https://api.voicebip.com/v1/agents/{agent_id}" \
  -H "Authorization: Bearer pk_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{"ai_provider": "byom", "webhook_url": "https://your-server.com/voicebip/byom"}'
```