VEXYL AI

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>] failed and 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_seconds follows the time the call spent listening.
  • tts_billed_chars and tts_cached_chars do not overlap: characters served from the TTS cache are counted separately and cost nothing. tts_spoken_chars is their sum.
  • llm_total_tokens is always an integer or null.
  • llm_source tells you how far to trust the AI cost. per-turn-usage, session-reconciled and realtime-usage are actual figures. flat-estimate and realtime-no-cost-event are estimates, and llm_cost_is_actual is false for 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.