Webhooks and billing
The gateway can tell your systems what happened on a call, and can hand you a durable billing record for every call. This chapter covers two separate feeds: lifecycle webhooks (call started, call ended, with the transcript) and billing records (one costed record per call, delivered with retries). It ends with the cost-rate file that prices those records.
Both feeds are plain JSON over HTTP POST to your endpoint. Settings go in .env; restart the gateway after changing them.
Lifecycle webhooks
Set WEBHOOK_URL to your endpoint to switch the feed on. Empty means off.
WEBHOOK_URL=https://hooks.example.com/voice
WEBHOOK_SECRET=change-me-long-random
WEBHOOK_AUTH_TOKEN=
LOG_LATENCY=false
| Variable | Default | Meaning |
|---|---|---|
WEBHOOK_URL |
empty | Your endpoint. Empty disables webhooks. |
WEBHOOK_METHOD |
POST |
HTTP method used for delivery. |
WEBHOOK_TIMEOUT |
5000 |
Milliseconds to wait for your endpoint. |
WEBHOOK_SECRET |
empty | Signing key. Empty means requests are unsigned. See Verifying the signature. |
WEBHOOK_AUTH_TOKEN |
empty | Token sent with every request. Empty means no auth header. |
WEBHOOK_AUTH_HEADER |
Authorization |
Header that carries the token. |
WEBHOOK_AUTH_SCHEME |
Bearer |
Prefix before the token. Set it empty to send a bare token. |
WEBHOOK_MAX_PENDING |
200 |
Deliveries allowed in flight. Beyond this, new events are skipped. |
WEBHOOK_SSRF_CHECK |
on | Refuses a URL that resolves to a private or loopback address. Set false when your receiver is on the local network. |
The token goes out as <WEBHOOK_AUTH_HEADER>: <WEBHOOK_AUTH_SCHEME> <token>, so the defaults produce Authorization: Bearer <token>.
Delivery rules
- Delivery is fire and forget. There are no retries.
- A failure is logged as
WARN: Lifecycle webhook [<event>] failedand never affects the call. - At shutdown the gateway waits up to 10 seconds for pending deliveries.
- If you need guaranteed delivery with retries, use Billing records instead.
Events and headers
| Event | Sent |
|---|---|
call.started |
Once the call is answered. Browser sessions send it on the first start or greeting, so the caller fields are filled. |
call.ended |
At cleanup. Carries the transcript, duration and performance. |
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
VEXYL-AI/1.0 |
X-Webhook-Type |
lifecycle |
X-Webhook-Signature |
HMAC-SHA256 hex digest. Only with WEBHOOK_SECRET. |
Authorization |
Bearer <token>. Only with WEBHOOK_AUTH_TOKEN (or the header you configured). |
call.started payload
{
"event": "call.started",
"timestamp": "2026-10-24T09:15:02.114Z",
"session": {
"uuid": "5b1f0c1e-7a52-4d0e-9c43-2f7d1a9e6b10",
"caller_id": "+9198xxxxxxxx",
"caller_name": "Asha",
"channel": "SIP/trunk-0001",
"language_code": "ml-IN",
"call_direction": "inbound",
"mode": "pipeline",
"bot_id": "default",
"initiated_at": "2026-10-24T09:15:00.020Z",
"answered_at": "2026-10-24T09:15:02.100Z"
},
"providers": {
"llm": "litebot",
"tts": "sarvam",
"stt": "sarvam",
"stt_actual": "sarvam"
}
}
call_direction is inbound or outbound. mode is pipeline or realtime; in realtime mode providers holds a single realtime entry instead of llm, tts and stt.
call.ended payload
{
"event": "call.ended",
"timestamp": "2026-10-24T09:15:44.300Z",
"session": {
"uuid": "5b1f0c1e-7a52-4d0e-9c43-2f7d1a9e6b10",
"caller_id": "+9198xxxxxxxx",
"language_code": "ml-IN",
"call_direction": "inbound",
"mode": "pipeline",
"bot_id": "default",
"initiated_at": "2026-10-24T09:15:00.020Z",
"answered_at": "2026-10-24T09:15:02.100Z",
"duration_ms": 42000
},
"providers": { "llm": "litebot", "tts": "sarvam", "stt": "sarvam", "stt_actual": "sarvam" },
"conversation": {
"message_count": 4,
"chat_history": [
{ "role": "assistant", "content": "Hello Asha, how can I help you today?" },
{ "role": "user", "content": "I want to book an appointment." }
]
},
"performance": {
"session_start": "2026-10-24T09:15:02.100Z",
"api_calls": 2,
"avg_response_time": 1800
},
"hangup": {
"hangup_reason": "bot_requested",
"hangup_source": "bot",
"hangup_at": "2026-10-24T09:15:44.200Z"
},
"metadata": { "campaign": "autumn" }
}
The blocks below appear in call.ended only when they apply.
| Block | Present when | Fields |
|---|---|---|
transfer |
The call was handed to a person. | transfer_status, transferred_at, transfer_source |
hangup |
The call ended by a hangup request. | hangup_reason, hangup_source, hangup_at |
outbound |
The call was outbound. | destination_number, greeting_mode, call_status |
realtime |
The call ran in realtime mode. | provider, bot_id, model, voice, language |
performance.latency |
LOG_LATENCY=true |
turns, avg_buffer_ms, avg_stt_ms, avg_llm_ms, avg_tts_ms, avg_send_ms, avg_response_ms, avg_total_ms, p90_response_ms, max_response_ms, and a per_turn list |
tools and tools_dropped |
WEBHOOK_TOOL_DETAILS=true |
See below |
metadata |
You posted custom keys in the session metadata. | Your keys, unchanged |
Cost does not travel in the webhook. Per-call cost lives only in the billing record; the webhook carries performance.
Latency detail
LOG_LATENCY is off by default. Turn it on to get per-turn latency console lines, a performance CSV in the logs folder, and the performance.latency block above. See Operations for reading the logs.
Tool invocations
Set WEBHOOK_TOOL_DETAILS=true to add a tools list to call.ended. Each entry carries name, id, success, at, duration_ms, truncated and control.
| Variable | Default | Meaning |
|---|---|---|
WEBHOOK_TOOL_DETAILS |
false |
Emit tools at all. |
WEBHOOK_TOOL_PAYLOADS |
false |
Also include the args and result bodies. They may hold caller personal data. |
WEBHOOK_TOOL_MAX_BYTES |
2048 |
Cap per field. Over the cap the value is cut and truncated is true. |
WEBHOOK_TOOL_MAX_ENTRIES |
50 |
Entries kept per call. The rest are counted in tools_dropped. |
Entries are merged by tool call id. Realtime LiteBot sends arguments but no result; the text pipeline sends the result but no arguments. A field the brain did not send is left out, never invented. See AI brain and Realtime mode.
Hosted customers do not set these variables; see Hosted customers.
Verifying the signature
When WEBHOOK_SECRET is set, every request carries X-Webhook-Signature: the lowercase hex HMAC-SHA256 of the raw request body, keyed with the secret. Compute it over the exact bytes received, before any JSON parsing, and compare in constant time.
raw = read the request body as bytes
expected = hex( hmac_sha256( secret, raw ) )
sig = request header "X-Webhook-Signature"
if not constant_time_equal( sig, expected ): reply 401 and stop
event = parse_json( raw )
handle( event ) and reply 200
Billing records use the same procedure with BILLING_CDR_SECRET. A request without the header means the secret is not configured on the gateway side.
Billing records
Lifecycle webhooks are best effort. Billing records are not: the gateway writes one JSON record per finished call to disk first, then delivers it with retries. A record is never dropped; after the last attempt it moves to a dead-letter folder for you to inspect.
BILLING_CDR_ENABLED=true
BILLING_CDR_DIR=./cdr
BILLING_CDR_URL=https://billing.example.com/cdr
BILLING_CDR_SECRET=another-long-random
The feed is independent of WEBHOOK_URL. With the default BILLING_CDR_ENABLED=false the module does nothing.
| Variable | Default | Meaning |
|---|---|---|
BILLING_CDR_ENABLED |
false |
Master switch. |
BILLING_CDR_DIR |
./cdr |
Store root, relative to the working directory. Subfolders are created automatically. |
BILLING_CDR_URL |
empty | Billing endpoint. Empty means store only: records stay in pending/. |
BILLING_CDR_SECRET |
empty | Signing key. Keep it separate from WEBHOOK_SECRET. |
BILLING_CDR_AUTH_HEADER |
Authorization |
Header that carries the token. |
BILLING_CDR_AUTH_TOKEN |
empty | Token sent with each delivery. |
BILLING_CDR_AUTH_SCHEME |
Bearer |
Prefix before the token. Empty sends a bare token. |
BILLING_CDR_TIMEOUT_MS |
5000 |
Milliseconds to wait for your endpoint. |
BILLING_CDR_MAX_ATTEMPTS |
10 |
Attempts before a record is dead-lettered. |
BILLING_CDR_SWEEP_MS |
60000 |
How often the gateway scans for records due a retry. |
BILLING_CDR_KEEP_DELIVERED |
false |
true archives delivered records in sent/. |
Store layout
| Folder | Holds |
|---|---|
cdr/pending/ |
<uuid>.json files awaiting delivery. |
cdr/sent/ |
Delivered records, only with BILLING_CDR_KEEP_DELIVERED=true. |
cdr/dead/ |
Records abandoned after the maximum attempts. Never deleted automatically. |
cdr/.tmp/ |
Staging for writes in progress. |
The /health response shows billing.pending and billing.dead. A dead count above zero is the one to alert on. See HTTP API.
Delivery and retries
The record is the JSON body, with no envelope. Headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
VEXYL-AI/1.0 |
X-Webhook-Type |
billing |
X-Idempotency-Key |
The session uuid. |
X-Delivery-Attempt |
Attempt number, starting at 1. |
X-Webhook-Signature |
HMAC-SHA256 hex. Only with BILLING_CDR_SECRET. |
Authorization |
Bearer token. Only with BILLING_CDR_AUTH_TOKEN. |
Your endpoint must answer with a 2xx status to acknowledge. Anything else, 4xx included, schedules a retry. The wait between attempts is 1 minute, 5 minutes, 15 minutes, 1 hour, then 6 hours for each later attempt, up to BILLING_CDR_MAX_ATTEMPTS; on defaults that spans about 32 hours.
A record can legitimately arrive twice, for example when your endpoint processed it but the acknowledgement was lost. Deduplicate on X-Idempotency-Key.
Replaying records
After fixing your endpoint, move dead letters back with the replay call:
curl -X POST http://127.0.0.1:8081/billing/replay \
-H "Content-Type: application/json" \
-d '{"scope":"dead"}'
dead moves every dead letter back to pending with its attempt counter reset, then sweeps. pending forces an immediate sweep without waiting for the timer. The response reports how many were moved and the pending and dead counts. Details are in HTTP API.
Record fields
Records carry schema_version 2. A value of null means that stage never ran on the call, which is different from a zero. Costs are in rupees unless the field ends in _usd.
| Group | Key fields |
|---|---|
| Identity and routing | uuid (invoice line key and idempotency key), caller_id, destination_number, call_direction, bot_id, channel, language_code, mode |
| Timing | initiated_at, answered_at, ended_at, duration_ms, billable_seconds (duration rounded up to whole seconds) |
| Outcome | hangup_reason, hangup_source, transfer_status, transferred_at |
| Totals | currency, cost_total (speech recognition plus speech synthesis plus AI, four decimals), usd_inr_rate |
| Speech recognition | stt_provider, stt_mode, stt_billed_seconds, stt_requests, stt_cost, stt_rate_per_hour |
| Speech synthesis | tts_billed_chars, tts_cached_chars, tts_spoken_chars, tts_requests, tts_cost, tts_rate_per_1k_chars, tts_by_provider list |
| AI brain | llm_provider, llm_model, llm_turns, llm_prompt_tokens, llm_completion_tokens, llm_cached_tokens, llm_total_tokens, llm_cost_usd, llm_cost, rate fields, llm_rate_source, and a per-turn turns list |
| Trust flags | llm_source, llm_cost_is_actual, llm_reconciled, stt_configured, stt_actual |
Reading the less obvious fields:
- Streaming speech recognition bills listening time, not speech, so
stt_billed_secondsfollows the time the call spent listening. tts_billed_charsandtts_cached_charsdo not overlap: characters served from the TTS cache are counted separately and cost nothing.tts_spoken_charsis their sum.llm_total_tokensis always an integer ornull.llm_sourcetells you how far to trust the AI cost.per-turn-usage,session-reconciledandrealtime-usageare actual figures.flat-estimateandrealtime-no-cost-eventare estimates, andllm_cost_is_actualisfalsefor them.
Cost tracking
Costs in the billing record come from the cost tracker, which prices each call from a rate table.
| Variable | Default | Meaning |
|---|---|---|
COST_TRACKING_ENABLED |
on | Compute costs for each call. |
COST_USD_INR |
87 |
Conversion rate from US dollar list prices to rupees. Set your own. |
COST_RATES_FILE |
empty | Path to your rates file. When empty the gateway reads config/pricing.json. |
The built-in table holds list prices as of the release. They date quickly, so override them with your contract rates. No config/pricing.json ships; create it from the example below. Your file is merged over the built-in table, so it only needs the entries you change.
{
"usdInr": 87,
"stt": {
"sarvam": 30
},
"tts": {
"google": 2.61,
"smallest": 1.5225
},
"llm_models": {
"gemini-2.5-flash": {
"text_input": 0.3,
"text_output": 2.5,
"cached_input": 0.075,
"audio_input": 1.0
}
}
}
| Section | Unit | Keyed by |
|---|---|---|
usdInr |
Rupees per US dollar | Not keyed |
stt |
Rupees per hour | Speech recognition provider |
tts |
Rupees per 1,000 characters | Provider, or provider plus model |
llm_models |
US dollars per 1 million tokens | Model id. Fields: text_input, text_output, cached_input, audio_input, audio_output |
The numbers above are examples, not current prices.
How rates are looked up
For speech synthesis the tracker tries the most specific key first, then the bare provider name.
| Call used | Keys tried, in order |
|---|---|
Sarvam, model bulbul:v4-flash |
sarvam:bulbul:v4-flash, then sarvam |
Gemini, model gemini-3.8-flash-lite-tts |
gemini:gemini-3.8-flash-lite-tts, then gemini |
OpenRouter, model <vendor/model> |
openrouter:<vendor/model>, then openrouter |
| Any other provider | The bare name: google, smallest, elevenlabs, deepgram, azure, murf, vexyl |
Speech recognition keys are the bare provider names: sarvam, groq, openai, deepgram, google, gemini and vexyl-stt.
An AI model with no entry in llm_models produces null rates in the record, not zero. Add every model your brain bills, using the model id without any models/ prefix.
Hosted customers
On the hosted service Vexyl sets the webhook URL and secret for you; see Hosted. Billing records are not exposed to hosted customers.