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

# Bring Your Own Model (BYOM)

> Voicebip handles telephony, STT, TTS, and barge-in while your endpoint runs the LLM. Full control over the model and prompt, zero lock-in.

**What you build:** an agent whose "brain" is *your* server. Voicebip answers the call, transcribes the caller, and POSTs each turn to your signed `webhook_url`. You run whatever model you like (OpenAI, Claude, a fine-tune, a rules engine), return the reply text, and Voicebip speaks it. You own the prompt and the model; Voicebip owns the phone line.

```
Caller speaks ─▶ Voicebip STT ─▶ POST agent.webhook_url ──▶ your server
                                   BYOMVoiceWebhookRequest    │ your model + your prompt
                                                              ▼
Caller hears  ◀─ Voicebip TTS ◀─ { "text": "…" }  ◀─────────┘
```

**Who it's for:** teams with an existing LLM stack, strict data-residency needs, or a proprietary model they can't hand over. Also the escape hatch when hosted-AI features don't fit.

> **Note**
>
> **BYOM is a signed relay — no Voicebip model runs.** Because your endpoint *is* the model, the dashboard's `system_prompt` and `tool_definitions` fields do **not** apply to BYOM agents — your prompt and tools live in your own code. If you want Voicebip to run the model and call your functions instead, that's [Tool-calling](/use-cases/tool-calling), a different (hosted) path.

## Prerequisites

* An HTTPS `webhook_url` that responds within **5 seconds** (the hard timeout — slower responses are treated as failures and count against the circuit breaker).
* Your workspace signing secret (dashboard → **Workspace → Signing Secret**) — BYOM requests are HMAC-signed and **you must verify them**.

## Step 1 — Create a BYOM agent

Set `ai_provider: "byom"` and point `webhook_url` at your endpoint:

```bash
curl -X POST https://api.voicebip.com/v1/agents \
  -H "Authorization: Bearer pk_xxx" -H "Content-Type: application/json" \
  -d '{
    "display_name": "Custom-LLM Agent",
    "ai_provider": "byom",
    "webhook_url": "https://you.example.com/voice-turn",
    "tts_voice": "<voice_id>",
    "greeting_text": "Hi, you are through to Acme. How can I help?"
  }'
```

## Step 2 — Handle the turn request

Each caller turn arrives as a signed POST with a `BYOMVoiceWebhookRequest` body. Verify (`X-Voicebip-Signature` = HMAC-SHA256 of `"{timestamp}.{raw_body}"`, timestamp within ±300 s), run your model, return `{ "text": ... }`:

```js
// Express — BYOM voice turn handler
import express from "express";
import crypto from "crypto";
import OpenAI from "openai";

const app = express();
app.use(express.raw({ type: "application/json" }));
const openai = new OpenAI();

app.post("/voice-turn", async (req, res) => {
  // 1. verify — fail CLOSED if it doesn't match
  const sig = req.header("X-Voicebip-Signature");
  const ts  = req.header("X-Voicebip-Timestamp");
  const mac = crypto.createHmac("sha256", process.env.VOICEBIP_SIGNING_SECRET)
    .update(`${ts}.${req.body}`).digest("hex");
  if (sig !== `sha256=${mac}`) return res.sendStatus(401);

  const turn = JSON.parse(req.body);
  // turn.messages = rolling history (role/text/timestamp); turn.transcription = latest caller utterance

  // 2. run YOUR model with YOUR prompt (keep it under 5s)
  const completion = await openai.chat.completions.create({
    model: "gpt-4o",
    messages: [
      { role: "system", content: "You are Acme's support agent. Be concise." },
      ...turn.messages.map(t => ({
        role: t.role === "caller" ? "user" : "assistant", content: t.text
      })),
      { role: "user", content: turn.transcription },
    ],
  });

  // 3. return reply text — Voicebip speaks it
  res.json({ text: completion.choices[0].message.content });
});
app.listen(3000);
```

The inbound `BYOMVoiceWebhookRequest`:

```json
{
  "event": "turn",
  "agent_id": "agt_…",
  "call_id": "call_5xk…",
  "from_number": "+2348031234567",
  "to_number": "+2341000000000",
  "transcription": "How long do refunds take?",
  "messages": [
    { "role": "caller", "text": "Hi", "timestamp": "2026-07-02T09:13:00Z" },
    { "role": "agent",  "text": "Hi, you are through to Acme. How can I help?", "timestamp": "2026-07-02T09:13:02Z" }
  ]
}
```

Your response is a `BYOMVoiceWebhookResponse`. Only `text` is required; `end_call`, `transfer_to`, and `dtmf` are mutually exclusive with a normal reply:

```json
{ "text": "Refunds take 5 to 7 business days." }
```

```json
{ "text": "Transferring you now.", "transfer_to": "+2348000000000" }
```

## Step 3 — Inbound screening (optional)

If the agent has `screening_enabled`, Voicebip fires **one** pre-greeting request with `event: "call.screening"` (empty `messages`, no `transcription`). Reply `{ "end_call": true }` to **reject** the caller (optionally with `text` spoken before hangup) or `{ "end_call": false }` to **allow** (optionally with `text` as a dynamic greeting override). On error/timeout, the agent's `screening_fail_closed` policy decides.

## Step 4 — Add RAG yourself (optional)

BYOM doesn't auto-retrieve. Query a [knowledge base](/use-cases/knowledge-base-deep-dive) and inject the chunks into your prompt before calling your model:

```js
const { chunks } = await queryKB(turn.transcription, 3);
const context = chunks.map(c => c.content).join("\n---\n");
// prepend `context` to your system message
```

## Messaging BYOM

The same pattern covers WhatsApp/SMS: inbound messages arrive as a `BYOMMessagingWebhookRequest` (`{agent_id, conversation_id, channel, from_number, to_number, inbound_message, history, metadata}`) at the same `webhook_url`, and you return the reply. See [WhatsApp AI agent](/use-cases/whatsapp-agent).

## Fail-closed signing

BYOM requests are signed, and **Voicebip refuses to send unsigned** — if your signing secret can't be resolved, the request is dropped, not sent in the clear (tracked via `voicebip_byom_signing_errors_total`). On *your* side, verify every request and reject mismatches. Secret rotation moves the old secret to a 24-hour grace window (`signing_secret_previous`), so accept **either** the current or previous secret during rotation. Rotate via `POST /v1/workspace/signing-secret/rotate` (the plaintext is shown once).

## Other stacks

Reference handlers for **Flask**, **Go**, and **Next.js**, plus **Claude** and **OpenAI** integrations, live in [Code Examples](/reference/code-examples) — the same verify → model → reply shape.

## Test it

BYOM turns are dispatched to your `webhook_url` during a live call, so they aren't part of `POST /v1/webhooks/test` (which covers `call.*` / `message.*` lifecycle events). Test by **POSTing a sample `BYOMVoiceWebhookRequest` (above) to your own endpoint** with a valid signature, then place a real call in the sandbox to see it end-to-end.

## Troubleshooting

| Symptom                       | Likely cause                                                                                |
| ----------------------------- | ------------------------------------------------------------------------------------------- |
| Agent says nothing / hangs up | Your endpoint took over 5 s or returned non-2xx; the circuit breaker may have opened.       |
| `401` storm in your logs      | Signature computed over parsed JSON, not the **raw** body; or wrong secret during rotation. |
| System prompt "ignored"       | Expected — the dashboard `system_prompt` doesn't apply to BYOM; set it in your own code.    |
| Reply not spoken              | Response missing the required `text` field.                                                 |

## Next steps

* Prefer Voicebip to run the model and call *your functions*? → [Tool-calling](/use-cases/tool-calling).
* Add retrieval to your prompt → [Knowledge Base deep-dive](/use-cases/knowledge-base-deep-dive).
* Per-language handling and latency tips → [Best Practices](/reference/best-practices).