Skip to Content
🚀 Voxtra v0.3.1 is live. Read the docs
VoxtraGuidesWebhooks

Webhooks

BackendWebhook is the bridge between Voxtra’s in-process event stream and your downstream services. Configure a URL once, and every VoxtraEvent POSTs to it as a fire-and-forget request — without blocking the call pipeline.

When to use webhooks

  • Sync call state to a CRM (Salesforce, HubSpot).
  • Fire billing events when a call answers / ends.
  • Stream live transcripts to a dashboard.
  • Trigger downstream automation (e.g. Slack notification on barge-in).
  • Mirror call activity into your data warehouse.

Configure

from voxtra import VoxtraApp, BackendWebhook from voxtra.config import WebhookConfig webhook = BackendWebhook( WebhookConfig( url="https://api.example.com/webhooks/voxtra", signing_secret="please-rotate-me", events=["call.started", "call.answered", "call.ended"], max_retries=3, retry_backoff=1.0, ), ) app = VoxtraApp( ari_url="...", ari_user="...", ari_password="...", webhook=webhook, )

events=[] (the default) emits every event type.

What gets sent

Each request is a POST with a JSON body matching the event schema:

{ "id": "8a91c2...", "type": "call.started", "session_id": "ch-1234", "timestamp": "2026-05-04T08:31:21.512Z", "data": { "caller_id": "+265888111111", "called_number": "+265999000001", "direction": "inbound" } }

Headers:

HeaderValue
Content-Typeapplication/json
User-Agentvoxtra-webhook/1
X-Voxtra-EventThe event type, e.g. call.started
X-Voxtra-Event-IdThe event’s id (idempotency key)
X-Voxtra-Session-IdThe session_id
X-Voxtra-Signaturehmac_sha256(secret, body) (when signing_secret set)

Verify the signature

import hashlib import hmac from fastapi import FastAPI, Header, HTTPException, Request WEBHOOK_SECRET = b"please-rotate-me" app = FastAPI() @app.post("/webhooks/voxtra") async def voxtra( request: Request, x_voxtra_signature: str = Header(...), x_voxtra_event_id: str = Header(...), ): body = await request.body() expected = hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, x_voxtra_signature): raise HTTPException(status_code=401, detail="bad signature") # Idempotency: drop if we've seen this event before. if await seen(x_voxtra_event_id): return {"status": "duplicate"} payload = await request.json() await dispatch(payload) return {"status": "ok"}

Retry semantics

OutcomeRetry?
2xxDone.
4xxNo — receiver bug, won’t fix.
5xxYes, with exponential backoff.
Transport errorYes, with exponential backoff.

Backoff doubles each attempt starting at retry_backoff seconds. After max_retries failed attempts the event is logged at WARNING and dropped.

Webhook delivery is at-most-once. For at-least-once semantics (sufficient for billing / CRM use cases), persist events on the receiver side using X-Voxtra-Event-Id as the idempotency key, then reconcile periodically against authoritative state from your own database.

Filtering events

Pass an allowlist to keep traffic small:

WebhookConfig( url="https://api.example.com/webhooks/voxtra", events=[ "call.started", "call.answered", "call.ended", "user.transcript", # only finals (no partials) "agent.response", ], )

The full list of EventType values is in the Events reference.

Production deployment

Receiver should respond fast

Voxtra’s HTTP timeout defaults to 10 seconds. Long-running work on the receiver — DB writes that take seconds, complex orchestration — should return 2xx quickly and process asynchronously (queue, worker, etc.).

Health check the receiver

Add a /health endpoint your monitoring polls. Webhook drops are silent in Voxtra logs (by design — they don’t break calls); you need external observability.

Rotate the signing secret

WebhookConfig.signing_secret can be a string or fetched from a secret manager at construction time. Rotate quarterly. To roll without downtime: support both old + new secrets on the receiver, switch Voxtra to the new secret, then drop the old one.

Observe drift

A reconciliation job that periodically pulls authoritative call state from Asterisk (or your DB) and compares against what your webhook receiver wrote is the safety net against missed events. Luso8 does this every five minutes — see Call reconciler .

Sharing an HTTP client

By default BackendWebhook owns its httpx.AsyncClient and closes it on app.stop(). Pass an external client to share it across multiple emitters or other code:

import httpx shared = httpx.AsyncClient(timeout=10.0) webhook = BackendWebhook( WebhookConfig(url="..."), http_client=shared, ) # Voxtra won't close `shared` — you do, when your app shuts down.

Reference

BackendWebhook API reference →

Last updated on