Skip to main content

Deepgram Voice Agent Integration

This guide walks you through building a real-time voice AI agent on Bandwidth's Voice Network using the Deepgram Voice Agent API. Unlike integrations that stitch together separate speech-to-text, LLM, and text-to-speech services, Deepgram's Voice Agent runs the whole conversational loop — Nova-3 transcription, a hosted LLM, and Aura-2 speech — over a single WebSocket. You already have Bandwidth Voice; Deepgram is the add-on that turns your calls into a conversation.

Rather than a framework, this integration is a small FastAPI bridge you run yourself: it answers Bandwidth's voice webhook with BXML, accepts Bandwidth's bidirectional media WebSocket, and forwards audio to and from the Deepgram agent socket. It leans on standard libraries — fastapi, uvicorn, httpx, websockets, and python-dotenv. Bandwidth carries calls as 8 kHz μ-law and the agent socket speaks mulaw at 8000 Hz natively in both directions, so the audio crosses the bridge unchanged.

info

This integration uses Bandwidth Programmable Voice with Media Streaming. Your application receives μ-law audio frames directly over a bidirectional WebSocket and feeds them into a single Deepgram Voice Agent socket, all over plain WebSockets rather than SIP-to-SIP routing.

One socket, one key

Deepgram hosts the LLM for you, so the agent's STT, reasoning, and TTS all run through Deepgram on a single connection authenticated with a single Deepgram API key. There's no separate LLM provider key to manage unless you deliberately choose to bring your own.

What you'll need

  • A Bandwidth Programmable Voice account with:
    • A purchased phone number assigned to a Voice Application
    • API credentials (OAuth 2.0 client ID + secret)
    • Don't have an account yet? Try Bandwidth Build for free — get a real phone number and 3000 credits to start building immediately.
    • If you have a full Bandwidth App account but haven't set it up yet, check out our Account Setup guide.
  • A Deepgram API key (create one from the Deepgram console)
  • Python 3.11+
  • A publicly accessible URL for your application (e.g., using ngrok)

Call Flow

Before we dive in, let's walk through what an inbound call flow looks like with this integration.

Let's break it down:

  1. A user calls your Bandwidth number.
  2. Bandwidth POSTs a Basic-Auth-protected webhook to /bxml with the inbound call event, including callId and accountId.
  3. Your application mints a one-time correlation token bound to those server-trusted IDs and responds with a <StartStream name="deepgram_agent"> BXML pointing at wss://<your-host>/stream/{token}, followed by a <StopStream name="deepgram_agent" wait="true"/> that keeps the call leg alive while the WebSocket session runs.
  4. Bandwidth opens a WebSocket to your application. Your app validates the token and recovers the trusted callId/accountId before accepting the stream.
  5. Your app opens one WebSocket to Deepgram and sends a Settings message describing the audio format and the agent's listen/think/speak configuration. Deepgram immediately synthesizes the greeting and streams it back as μ-law audio, which your app wraps in playAudio events — so the agent speaks first.
  6. Caller audio arrives as base64 μ-law media events. Your app decodes them and forwards the raw bytes straight to the Deepgram agent socket; both sides already speak mulaw at 8000 Hz, so the bytes pass through unchanged. Deepgram transcribes, runs the LLM, and streams back the reply as μ-law audio.
  7. When the caller talks over the agent, Deepgram sends UserStartedSpeaking; your app forwards a clear event so Bandwidth drops any audio still queued for playback (barge-in).
  8. When the caller hangs up (or Bandwidth sends a stop event), your app ends the call via the Bandwidth Voice API using the trusted callId/accountId — never an ID read off the WebSocket itself.
Why not trust the WebSocket's own metadata?

Community samples for other telephony providers routinely read the call identifier straight out of the media WebSocket's first start event and use it to control the call. That event isn't authenticated — anyone who discovers your /stream endpoint could open a connection and feed it an arbitrary callId, triggering a hang-up (or worse) against a live call in your account.

This guide closes that gap the same way the Cartesia and Pipecat guides do: trust only the authenticated /bxml webhook body for callId and accountId, and bind them to the WebSocket via a short-lived, server-issued correlation token. The WebSocket's start event is used only to confirm the stream came up — never as a source of trusted IDs.

Let's Build It!

The sections below walk through a complete server.py. It's the same bridge pattern as the Cartesia guide, simplified: because Deepgram's Voice Agent orchestrates STT, the LLM, and TTS itself, there's only one downstream socket to manage instead of two, and no manual LLM seam to wire up.

Set Up Your Environment

python -m venv .venv && source .venv/bin/activate
pip install fastapi 'uvicorn[standard]' httpx websockets python-dotenv

Create .env:

# Bandwidth OAuth 2.0 credentials, used to hang up the call via the Voice API
BANDWIDTH_CLIENT_ID=
BANDWIDTH_CLIENT_SECRET=

# Webhook Basic Auth. Set the same username/password on your Bandwidth
# Voice Application's inbound callback.
BANDWIDTH_WEBHOOK_USERNAME=
BANDWIDTH_WEBHOOK_PASSWORD=

# Deepgram
DEEPGRAM_API_KEY=

# Public hostname for the BXML StartStream destination (https://...)
PUBLIC_URL=
GREETING=Hi! I'm a Bandwidth voice agent powered by Deepgram. How can I help?

Note there's no BANDWIDTH_ACCOUNT_ID or BANDWIDTH_APPLICATION_ID to configure — the trusted callId/accountId for each call come from the authenticated /bxml webhook body, not from a static config value.

Imports and the Deepgram Settings Message

import asyncio
import base64
import json
import logging
import os
import secrets
import time
from contextlib import suppress
from urllib.parse import urlsplit

import httpx
import websockets
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect, status
from fastapi.responses import Response

load_dotenv()

logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
logger = logging.getLogger("deepgram-bridge")

PUBLIC_URL = os.environ["PUBLIC_URL"]
DEEPGRAM_API_KEY = os.environ["DEEPGRAM_API_KEY"]
GREETING = os.environ.get("GREETING", "Hi! I'm a Bandwidth voice agent powered by Deepgram. How can I help?")
PROMPT = os.environ.get(
"PROMPT",
"You are a friendly, concise voice assistant on a phone call. Keep replies short and conversational.",
)

BANDWIDTH_WEBHOOK_USERNAME = os.environ["BANDWIDTH_WEBHOOK_USERNAME"]
BANDWIDTH_WEBHOOK_PASSWORD = os.environ["BANDWIDTH_WEBHOOK_PASSWORD"]

# Deepgram's Voice Agent runs its whole pipeline (STT -> LLM -> TTS) over one
# socket. It reads and writes 8 kHz mu-law natively, matching Bandwidth's
# audio/pcmu exactly, so audio crosses the bridge byte-for-byte -- no resampling.
DEEPGRAM_AGENT_URL = "wss://agent.deepgram.com/v1/agent/converse"

# STT / LLM / TTS models. The LLM is Deepgram-hosted, so no bring-your-own key.
LISTEN_MODEL = os.environ.get("LISTEN_MODEL", "nova-3")
THINK_PROVIDER = os.environ.get("THINK_PROVIDER", "open_ai")
THINK_MODEL = os.environ.get("THINK_MODEL", "gpt-4o-mini")
SPEAK_MODEL = os.environ.get("SPEAK_MODEL", "aura-2-thalia-en")

BANDWIDTH_VOICE_BASE = "https://voice.bandwidth.com/api/v2"
BANDWIDTH_OAUTH_URL = "https://api.bandwidth.com/api/v1/oauth2/token"


def agent_settings() -> dict:
"""The Settings message that configures the Deepgram agent for a Bandwidth call."""
return {
"type": "Settings",
"audio": {
"input": {"encoding": "mulaw", "sample_rate": 8000},
"output": {"encoding": "mulaw", "sample_rate": 8000, "container": "none"},
},
"agent": {
"listen": {"provider": {"type": "deepgram", "model": LISTEN_MODEL}},
"think": {"provider": {"type": THINK_PROVIDER, "model": THINK_MODEL}, "prompt": PROMPT},
"speak": {"provider": {"type": "deepgram", "model": SPEAK_MODEL}},
"greeting": GREETING,
},
}


app = FastAPI()

A few things to note:

  1. DEEPGRAM_AGENT_URL is the single Voice Agent endpoint. Everything — transcription, reasoning, and speech — happens over this one connection.
  2. The Settings message declares mulaw at 8000 Hz for both input and output (with container: "none" on the output), matching Bandwidth's native audio/pcmu exactly, so audio crosses the bridge byte-for-byte in either direction.
  3. think.provider.type is open_ai with model gpt-4o-mini, but Deepgram hosts that model for you, so your Deepgram key is the only credential the agent needs. Swap in anthropic, google, or another supported provider by changing THINK_PROVIDER/THINK_MODEL. nova-3 is a current Deepgram streaming STT model and aura-2-thalia-en a current Aura-2 voice. (Deepgram also offers Flux, a turn-aware STT model built for voice agents — worth a look if you want to tune interruption/end-of-turn behavior later.)
  4. There's no agent.language key here — it's deprecated; the providers default to English. Set listen/speak provider-level language if you need another.

Authentication and Correlation Tokens

These helpers are the security spine of the bridge, so define them before the handlers that use them. _verify_webhook_auth rejects any /bxml request that doesn't present your Basic Auth credentials. _issue_token and _consume_token implement the one-time correlation token: /bxml issues a token bound to the webhook's trusted callId/accountId, and the WebSocket handler redeems it exactly once, so the IDs can never be spoofed by whatever connects to the socket.

# Correlation tokens: token -> (call_id, account_id, expires_at_monotonic).
# In-memory is fine for a single-process demo; use a shared store (e.g. Redis)
# if you run more than one worker.
_TOKENS: dict[str, tuple[str, str, float]] = {}
TOKEN_TTL_SECONDS = 30


def _verify_webhook_auth(request: Request) -> None:
"""Enforce HTTP Basic Auth on the inbound webhook; raise 401 on mismatch."""
header = request.headers.get("Authorization", "")
scheme, _, encoded = header.partition(" ")
if scheme.lower() == "basic":
with suppress(ValueError):
username, _, password = base64.b64decode(encoded).decode().partition(":")
user_ok = secrets.compare_digest(username, BANDWIDTH_WEBHOOK_USERNAME)
pass_ok = secrets.compare_digest(password, BANDWIDTH_WEBHOOK_PASSWORD)
if user_ok and pass_ok:
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing webhook credentials",
headers={"WWW-Authenticate": "Basic"},
)


def _issue_token(call_id: str, account_id: str) -> str:
"""Mint a single-use, URL-safe token bound to server-trusted call IDs."""
now = time.monotonic()
# Opportunistically drop expired tokens so calls whose WebSocket never
# connects don't accumulate in memory.
for stale in [t for t, (_, _, exp) in _TOKENS.items() if exp <= now]:
_TOKENS.pop(stale, None)
token = secrets.token_urlsafe(32)
_TOKENS[token] = (call_id, account_id, now + TOKEN_TTL_SECONDS)
return token


def _consume_token(token: str) -> tuple[str, str] | None:
"""Redeem a token exactly once. Returns (call_id, account_id) or None."""
entry = _TOKENS.pop(token, None)
if entry is None:
return None
call_id, account_id, expires_at = entry
if time.monotonic() >= expires_at:
return None
return call_id, account_id


def _stream_url(token: str) -> str:
"""Build the wss:// media-stream destination from PUBLIC_URL.

PUBLIC_URL must be a bare https:// origin (Bandwidth needs a wss://
destination). Parse it so a trailing slash, a stray path/query, or a
non-https scheme can't produce a malformed destination.
"""
parts = urlsplit(PUBLIC_URL)
if parts.scheme != "https" or not parts.netloc:
raise RuntimeError(f"PUBLIC_URL must be an https:// origin (got {PUBLIC_URL!r})")
return f"wss://{parts.netloc}/stream/{token}"

_issue_token purges expired entries on each call, so a caller who is issued a token but whose media WebSocket never connects doesn't leak a _TOKENS entry. _stream_url builds the wss:// destination defensively — parsing PUBLIC_URL with urlsplit and rebuilding from the validated netloc — so a trailing slash, a stray path/query, or a non-https scheme can't emit a malformed destination like //stream/....

Handle the Inbound Voice Webhook

When a caller dials your Bandwidth number, Bandwidth POSTs to /bxml. The handler authenticates the request, extracts the trusted callId / accountId from the body, mints a one-time correlation token, and returns a <StartStream> BXML with a keepalive verb.

@app.post("/bxml")
async def bxml(request: Request) -> Response:
"""Bandwidth voice webhook. Basic Auth required; trust the body's IDs."""
_verify_webhook_auth(request)

body = await request.json()
call_id = body.get("callId")
account_id = body.get("accountId")
if not call_id or not account_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Webhook body missing callId or accountId",
)

token = _issue_token(str(call_id), str(account_id))
ws_url = _stream_url(token)
bxml_body = (
'<?xml version="1.0" encoding="UTF-8"?>'
"<Response>"
f'<StartStream name="deepgram_agent" destination="{ws_url}" mode="bidirectional" tracks="inbound"/>'
'<StopStream name="deepgram_agent" wait="true"/>'
"</Response>"
)
return Response(content=bxml_body, media_type="application/xml")

A few things to note:

  1. _verify_webhook_auth enforces HTTP Basic Auth using BANDWIDTH_WEBHOOK_USERNAME / BANDWIDTH_WEBHOOK_PASSWORD, rejecting the request with 401 if the credentials don't match.
  2. callId and accountId are read only from the authenticated webhook body — never from anything the WebSocket sends later.
  3. <StartStream> is non-blocking — BXML execution continues immediately after the stream starts, and the call would end (closing the WebSocket) if there were no verb left to run. <StopStream name="deepgram_agent" wait="true"/> holds the call open until your WebSocket handler closes it, with no artificial time cap. See the <StartStream> and <StopStream> references for the details.

Accept the WebSocket and Bridge to the Agent

Bandwidth opens a WebSocket to /stream/{token}. The handler validates the token, recovers the trusted IDs, opens the single Deepgram agent socket, sends the Settings message, and runs two tasks that pump audio in each direction.

@app.websocket("/stream/{token}")
async def stream(ws: WebSocket, token: str) -> None:
"""Validate the correlation token, then bridge audio to/from the Deepgram agent."""
trusted = _consume_token(token)
if trusted is None:
await ws.close(code=1008) # 1008 = policy violation
return
call_id, account_id = trusted

# Everything past token redemption is wrapped so the call leg is always torn
# down -- an accept failure, a bad start event, a Deepgram connection failure,
# or a failed Settings send must not leave the caller stranded in silence.
try:
await ws.accept()

# Bandwidth's first frame is a "start" event. We use it only to confirm the
# stream came up -- callId/accountId come from the trusted token mapping
# above, never from this (unauthenticated) WebSocket payload.
start_event = json.loads(await ws.receive_text())
if start_event.get("eventType") != "start":
await ws.close(code=4400)
return

# Deepgram authenticates via the "token" WebSocket subprotocol.
async with websockets.connect(
DEEPGRAM_AGENT_URL, subprotocols=["token", DEEPGRAM_API_KEY]
) as agent:
await agent.send(json.dumps(agent_settings()))

# One task pumps caller audio into the agent; the other plays the
# agent's replies back to the caller and handles barge-in.
pump = asyncio.create_task(_caller_audio_to_agent(ws, agent))
replies = asyncio.create_task(_agent_events_to_caller(ws, agent))
try:
done, _ = await asyncio.wait(
{pump, replies}, return_when=asyncio.FIRST_COMPLETED
)
# Retrieve the finished task's result so a Deepgram Error or a
# decode failure is logged rather than swallowed as an
# unretrieved task exception.
for task in done:
if (exc := task.exception()) is not None:
logger.error("bridge task failed: %r", exc)
finally:
for task in (pump, replies):
task.cancel()
await asyncio.gather(pump, replies, return_exceptions=True)
except WebSocketDisconnect:
pass
except Exception as exc: # log and tear down; never leak a raw traceback
logger.error("stream handler error: %r", exc)
finally:
await _hang_up(account_id, call_id)

Key points:

  1. _consume_token pops the token from server-side state — single-use, short TTL. Invalid or expired tokens get rejected with WS close code 1008.
  2. The start event's presence is checked, but its metadata is deliberately ignored — the trusted values came from the token mapping instead.
  3. The agent socket authenticates with the token WebSocket subprotocol: subprotocols=["token", DEEPGRAM_API_KEY]. (A server-side client can equivalently send an Authorization: Token <key> header via additional_headers; the subprotocol form is used here because it's what Deepgram's own telephony samples use.)
  4. Sending Settings immediately after connecting tells Deepgram to synthesize the greeting right away, so the agent speaks first — before the caller has said anything.
  5. The whole session — starting at ws.accept() — lives inside an outer try/…/finally, so _hang_up always runs no matter how the session ends (accept failure, malformed start event, Deepgram connection failure, or a mid-call error), and unexpected errors are logged rather than leaked as a raw ASGI traceback. WebSocketDisconnect is a normal end; CancelledError deliberately isn't caught, so task/shutdown cancellation still propagates. asyncio.wait(..., FIRST_COMPLETED) returns as soon as either task finishes — so if the agent-reply task dies, the call tears down instead of lingering — then its result is retrieved (a Deepgram Error is logged, not dropped) and both tasks are cancelled and gathered so neither is orphaned.

Pump Caller Audio to the Agent

async def _caller_audio_to_agent(ws: WebSocket, agent) -> None:
"""Bandwidth media events (base64 mu-law JSON) -> raw binary to the agent."""
try:
async for raw in ws.iter_text():
event = json.loads(raw)
kind = event.get("eventType")
if kind == "media":
await agent.send(base64.b64decode(event["payload"]))
elif kind == "stop":
break
except WebSocketDisconnect:
pass

Every media event Bandwidth sends over the WebSocket carries base64-encoded μ-law audio for the inbound track. This decodes it and forwards the raw bytes straight to the Deepgram agent socket; both sides already agree on mulaw at 8000 Hz, so the bytes cross unchanged. The agent buffers, transcribes, and reasons over the audio on its own; there's nothing else to send.

Play the Agent's Replies (and Handle Barge-In)

async def _agent_events_to_caller(ws: WebSocket, agent) -> None:
"""Agent messages -> Bandwidth. Binary frames are TTS audio; JSON is control."""
try:
async for message in agent:
if isinstance(message, (bytes, bytearray)):
# Agent TTS audio is raw mu-law -- wire-compatible with audio/pcmu.
await ws.send_text(json.dumps({
"eventType": "playAudio",
"media": {
"contentType": "audio/pcmu",
"payload": base64.b64encode(message).decode("ascii"),
},
}))
continue

event = json.loads(message)
kind = event.get("type")
if kind == "UserStartedSpeaking":
# Barge-in: caller interrupted, so drop audio already queued at Bandwidth.
await ws.send_text(json.dumps({"eventType": "clear"}))
elif kind == "ConversationText":
logger.info("%s: %s", event.get("role"), event.get("content"))
elif kind == "Error":
raise RuntimeError(f"Deepgram agent error: {event}")
except WebSocketDisconnect:
pass

The agent socket carries two kinds of messages:

  1. Binary frames are raw μ-law TTS audio. They're wire-compatible with Bandwidth's audio/pcmu, so each frame is base64-encoded and wrapped in a playAudio event straight back over the caller's WebSocket, byte-for-byte.
  2. JSON frames are control events. UserStartedSpeaking means the caller talked over the agent, so we send Bandwidth a clear event to drop any audio still queued for playback (barge-in). ConversationText carries the running transcript of both sides, handy for logging. Error surfaces a problem with your Settings or the session.

Hang Up Cleanly

async def _bandwidth_token(client: httpx.AsyncClient) -> str:
resp = await client.post(
BANDWIDTH_OAUTH_URL,
auth=(os.environ["BANDWIDTH_CLIENT_ID"], os.environ["BANDWIDTH_CLIENT_SECRET"]),
data={"grant_type": "client_credentials"},
)
resp.raise_for_status()
return resp.json()["access_token"]


async def _hang_up(account_id: str, call_id: str) -> None:
"""End the call leg via the Voice API using the trusted IDs.

This is terminal cleanup that runs in a `finally`, so a failure here is
logged rather than raised -- there's nothing downstream to recover it, and
letting it propagate would only mask why the session actually ended.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
token = await _bandwidth_token(client)
resp = await client.post(
f"{BANDWIDTH_VOICE_BASE}/accounts/{account_id}/calls/{call_id}",
headers={"Authorization": f"Bearer {token}"},
json={"state": "completed"},
)
if resp.status_code == 404:
# Idempotency choice: the call is usually already gone (caller hung
# up first). Log it rather than assume, since a 404 can also mean a
# wrong callId/accountId.
logger.info("hang-up got 404 for call %s (already ended?)", call_id)
elif resp.status_code != 200:
resp.raise_for_status()
except Exception as exc:
logger.error("hang-up failed for call %s: %r", call_id, exc)

_hang_up runs in the outer finally of the WebSocket handler above, using the trusted account_id / call_id recovered from the correlation token — not anything read from the WebSocket. It fetches a fresh OAuth token via client_credentials and POSTs {"state": "completed"} to end the call. A 404 is usually a call that already ended (the caller hung up first), but it can also mean a wrong callId/accountId, so it's logged rather than blindly assumed. Because this is terminal cleanup, any failure is logged instead of raised — a dead-air call from a botched hang-up is worse than a logged error.

Connect to a Public URL

Bandwidth needs a publicly reachable HTTPS host to deliver the inbound webhook and open the media-stream WebSocket. Start the server and expose it in a second terminal:

uvicorn server:app --host 0.0.0.0 --port 8000
ngrok http 8000

Copy ngrok's HTTPS URL into .env as PUBLIC_URL and restart the server so the BXML response references the correct destination.

Configure your Bandwidth Voice Application

Finally, point your Bandwidth Voice Application at your public webhook URL.

  1. Log in to the Bandwidth App and open your Voice Application (or create a new one).
  2. Under Call initiated, select POST as the callback method and set the Callback URL to https://<your-public-host>/bxml.
  3. Tick Use a callback username and password and enter the same BANDWIDTH_WEBHOOK_USERNAME / BANDWIDTH_WEBHOOK_PASSWORD values you put in .env. Bandwidth will send these in a Basic Auth header on every inbound call event, and /bxml rejects the request without them.
  4. Click Save.
  5. Make sure the application is linked to a Voice Configuration Package and that the package is assigned to your Bandwidth phone number.

Test the Integration

Call your Bandwidth phone number. You should hear the agent greet you (GREETING), and when you speak, it answers conversationally. Talk over it and it stops to listen (barge-in). When you hang up — or the app decides the conversation is over — the WebSocket handler tears down the agent socket and ends the call leg via the Bandwidth Voice API using the trusted callId.

Configuration

The Deepgram-facing knobs all live in the Settings message:

ParameterWhereValue usedNotes
audio.input / audio.outputSettingsmulaw @ 8000Matches Bandwidth's audio/pcmu, so audio crosses unchanged. Output uses container: "none".
listen.provider.modelSettings.agentnova-3A current Deepgram streaming STT model. For turn-aware, low-latency agents see Flux.
think.provider.type/modelSettings.agentopen_ai / gpt-4o-miniDeepgram-hosted, billed through Deepgram. Swap for anthropic, google, etc. See LLM providers.
think.promptSettings.agentyour system promptThis is the seam for your agent's behavior, tone, and tools
speak.provider.modelSettings.agentaura-2-thalia-enAny Aura-2 voice
greetingSettings.agentyour greeting textSpoken immediately on connect, before the caller says anything

The Bandwidth-facing knobs are the BANDWIDTH_WEBHOOK_USERNAME / BANDWIDTH_WEBHOOK_PASSWORD pair (must match the Voice Application's callback credentials) and the correlation token's TTL (short-lived by design — a token is only needed for the few seconds between the /bxml response and the WebSocket connecting).

What's next

  • Give the agent a job. The think.prompt is where your agent's personality and instructions live. Add function calling to let it look things up, book appointments, or transfer the call.
  • Transfer to a human. Use a function-calling tool that triggers a Bandwidth <Transfer> via the Voice API — the same PUT /calls/{callId} pattern used in the OpenAI Realtime guide.
  • Bring your own LLM or voice. Point think at your own model provider with an endpoint and key, or pick a different Aura-2 voice for speak.
  • Record and transcribe. Add <StartRecording transcribe="true"/> before <StartStream> to capture the call independent of the agent stream.
  • Harden further. Verify Bandwidth's webhook signature in addition to Basic Auth, IP-allowlist Bandwidth's egress ranges, and back the token store with Redis if you run more than one worker.

Resources