How Volume Reach delivers call-lifecycle events to your system.
How it fits together
Volume Reach uses a two-object model:
- Webhook endpoint — the HTTPS URL + signing secret you control. One tenant can have up to 10 endpoints.
- Automation trigger — a subscription that wires an event type to an endpoint, with optional filtering (e.g. outcome-based).
To start receiving events:
# 1. Create the endpoint
curl -X POST https://api.volumereach.com/api/v1/public/webhooks \
-H "Authorization: Bearer vr_live_..." \
-d '{"name": "Production", "url": "https://your-app.com/webhooks/volumereach"}'
# Save the returned signingSecret — shown ONCE.
# 2. Subscribe to the event
curl -X POST https://api.volumereach.com/api/v1/public/automation-triggers \
-H "Authorization: Bearer vr_live_..." \
-d '{"name": "All completions", "eventType": "call.completed", "webhookEndpointId": "whe_abc"}'
Without a trigger, nothing fires. With a trigger, every matching event is delivered.
Event types
v1 emits one primary event; more will follow as they're requested.
| Event | When it fires |
|---|---|
call.completed | A call has finished (or ended without being answered). Payload is enriched with transcript, outcome, and extraction. Fires for every outcome by default — filter via conditions.outcome on the trigger if you only care about some. |
The following events exist in the internal event bus but are not yet subscribable via the Public API — they will be enabled when there's concrete customer demand:
call.started— call was placed (ringing).call.failed— platform-side failure (dispatch error, LiveKit unreachable).sms.sent,sms.received,sms.ai_reply— SMS lifecycle.campaign.started,campaign.completed— batch operations.contact.created,contact.updated,lead.qualified,opportunity.created— CRM lifecycle.
Open a support ticket if you need additional events exposed to the Public API.
call.completed payload
{
"event": "call.completed",
"timestamp": "2026-04-22T14:05:47.123Z",
"data": {
"call_id": "call_xyz",
"outcome": "INTERESTED",
"notes": null,
"duration_seconds": 127,
"started_at": "2026-04-22T14:03:41.000Z",
"recording_url": null,
"transcript": "Agent: Hi Jane...\nJane: Yes, I'm interested...\n(full transcript, capped at 10000 chars)",
"contact": {
"name": "Jane Smith",
"phone": "+15551234567",
"address": "123 Oak St",
"city": "Springfield",
"state": "IL",
"zip": "62704"
},
"callback": null,
"campaign": null,
"variables": { "leadName": "Jane Smith", "propertyAddress": "123 Oak St" },
"metadata": { "crmLeadId": "hubspot-987" },
"known_consent": true,
"consent_record_id": null,
"extraction": {
"interested": true,
"budgetRange": "$5k-15k",
"timelineMonths": 3,
"appointmentDateTimeISO": "2026-04-29T18:00:00.000Z"
},
"extraction_error": null
}
}
Field notes
outcomeis the classifier's best guess, e.g.INTERESTED,NOT_INTERESTED,CALLBACK,DNC,NO_ANSWER,BUSY,VOICEMAIL,WRONG_NUMBER,NOT_OWNER. Your agent's active-outcomes configuration determines the full set.duration_secondsis the billed duration (answered + talking).nullor0if the call was never answered.transcriptis capped at 10 000 characters; if the call was unusually long, the tail is truncated with...[truncated].recording_urlis alwaysnullin the webhook payload. Fetch a fresh signed URL fromGET /calls/:id— the URL expires in 15 minutes, so embedding it in a webhook that may be retried over 30+ minutes is unsafe.variables/metadata/known_consent/consent_record_idare only populated for calls triggered viaPOST /calls(they come from the original API request). For internal dialer-originated calls, these fields will be absent.extractionis populated only when the agent has anextractionSchemaconfigured. Null otherwise.extraction_erroris a short error message if extraction was attempted but failed (e.g. OpenAI rate-limit during a burst). Inspecttranscriptmanually in that case.
Delivery guarantees
| Aspect | Behavior |
|---|---|
| Delivery | At-least-once. Design your handler to be idempotent — the same call_id may be delivered more than once if retries fire after a transient failure. |
| Ordering | Not guaranteed. Two events for the same call can theoretically arrive out of order under high concurrency. |
| Rate limit | 100 dispatches / minute / tenant. If you exceed this, some events are dropped with a webhook_dispatch_rate_limited log entry. |
| Timeout | 10 seconds per delivery. Your handler must return a status code within that window. |
| Success | HTTP 2xx. Anything else triggers retry. |
| Retry schedule | Exponential backoff at 1m, 5m, 30m, 2h, 6h, 24h — up to 6 attempts with ±25% jitter on each delay to defeat thundering-herd retries after your endpoint recovers. After the 6th failure the delivery is marked failed and enters the DLQ — you can list or replay from DLQ programmatically (see below). |
Request headers
Every webhook delivery includes:
POST /your/path HTTP/1.1
Host: your-app.com
Content-Type: application/json
User-Agent: VolumeReach-Webhook/1.0
X-Webhook-Signature: sha256=<hex>
X-Webhook-Timestamp: <unix-seconds>
X-Webhook-Delivery-Id: <uuid — stable across retries>
X-Webhook-Delivery-Id is a stable UUID for each logical delivery. If a retry fires after your endpoint was slow / 5xx, the same ID is resent — use it as the primary dedup key in your handler. (The call_id inside the payload is also stable but not unique per delivery attempt; use X-Webhook-Delivery-Id when you want "did I already process THIS retry attempt?")
Plus any headers you configured on the endpoint (except security-critical ones which are stripped).
Verifying signatures
The X-Webhook-Signature header is HMAC-SHA256 over <timestamp>.<raw-body> using your endpoint's signing secret. Always verify — otherwise your endpoint is an unauthenticated "anyone can POST here" target.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
// IMPORTANT: use raw body parser so you can verify the signature against exact bytes.
// Express's default json() parser re-serializes, breaking the HMAC.
app.use(
"/webhooks/volumereach",
express.raw({ type: "application/json" }),
);
app.post("/webhooks/volumereach", (req, res) => {
const signature = req.header("X-Webhook-Signature"); // "sha256=<hex>"
const timestamp = req.header("X-Webhook-Timestamp");
const rawBody = req.body; // Buffer
// Reject if headers are missing or timestamp is >5 min old (replay protection)
if (!signature || !timestamp) return res.sendStatus(401);
const ageSeconds = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return res.sendStatus(401);
const signingSecret = process.env.VOLUMEREACH_SIGNING_SECRET;
const expected =
"sha256=" +
crypto
.createHmac("sha256", signingSecret)
.update(`${timestamp}.${rawBody.toString("utf8")}`)
.digest("hex");
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.sendStatus(401);
}
// Signature is valid — now parse and act.
const payload = JSON.parse(rawBody.toString("utf8"));
if (payload.event === "call.completed") {
handleCallCompleted(payload.data);
}
res.sendStatus(200);
});
Python (Flask)
import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_SECRET = os.environ["VOLUMEREACH_SIGNING_SECRET"].encode()
@app.post("/webhooks/volumereach")
def volumereach():
signature = request.headers.get("X-Webhook-Signature", "")
timestamp = request.headers.get("X-Webhook-Timestamp", "")
raw = request.get_data() # bytes
if not signature or not timestamp:
abort(401)
try:
ts = int(timestamp)
except ValueError:
abort(401)
if abs(time.time() - ts) > 300: # 5 min
abort(401)
expected = "sha256=" + hmac.new(
SIGNING_SECRET,
f"{timestamp}.{raw.decode('utf-8')}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
data = request.get_json() # safe to parse after verification
# ... handle event ...
return "", 200
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"time"
)
var signingSecret = []byte(os.Getenv("VOLUMEREACH_SIGNING_SECRET"))
func volumereachHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Webhook-Signature")
timestamp := r.Header.Get("X-Webhook-Timestamp")
raw, err := io.ReadAll(r.Body)
if err != nil || signature == "" || timestamp == "" {
http.Error(w, "", http.StatusUnauthorized)
return
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || time.Now().Unix()-ts > 300 {
http.Error(w, "", http.StatusUnauthorized)
return
}
mac := hmac.New(sha256.New, signingSecret)
mac.Write([]byte(timestamp + "." + string(raw)))
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expected)) {
http.Error(w, "", http.StatusUnauthorized)
return
}
// ... parse raw as JSON and handle ...
w.WriteHeader(http.StatusOK)
}
Troubleshooting
No webhooks are arriving
- Confirm you have BOTH a webhook endpoint and an automation trigger:
GET /webhooksandGET /automation-triggers. - Confirm
isActive: trueon both. - Check the Settings → Automations page in the Volume Reach app — it shows recent delivery attempts (success, failed, retrying) with the response codes you returned.
- Verify your webhook URL:
- Uses HTTPS (production only; HTTP is accepted in non-production for local development).
- Is reachable from the public internet (not behind a VPN /
localhost). - Returns within 10 seconds. Long-running operations in your handler should be enqueued to a background job.
Deliveries stuck in "retrying"
- Check the response status on the Automations page.
5xxfrom your endpoint → retry with backoff.4xx(except 429) → still retries, but typically means a bug in your handler. - If you've redeployed your endpoint to fix the handler, you can either wait for the next scheduled retry or force one immediately via the DLQ replay endpoint (see below).
Replaying dead-lettered deliveries
After the 6th failed attempt a delivery is marked failed and sits in the DLQ indefinitely. Two programmatic endpoints let you inspect and replay (both require an API key with the webhooks scope):
List recent deliveries:
curl "https://api.volumereach.com/api/v1/public/webhook-deliveries?status=failed&since=2026-04-20T00:00:00Z" \
-H "Authorization: Bearer vr_live_..."
?status=success|retrying|failed|pending— filter by status.?since=<ISO-8601>— bound the lookback (max 30 days).?page=1&limit=20— pagination (max 50 per page).
Inspect a single delivery (includes full payload):
curl "https://api.volumereach.com/api/v1/public/webhook-deliveries/dlv_abc123" \
-H "Authorization: Bearer vr_live_..."
Replay a dead-lettered delivery:
curl -X POST "https://api.volumereach.com/api/v1/public/webhook-deliveries/dlv_abc123/retry" \
-H "Authorization: Bearer vr_live_..."
The replay re-dispatches with the same X-Webhook-Delivery-Id so your idempotent handler can deduplicate — you will not receive a second logical event, only another attempt at the original one.
Signature verification fails intermittently
- Ensure you're verifying against the raw bytes of the request, not a re-serialized parsed JSON body. Most JSON middleware reformats whitespace, which breaks HMAC.
- Check the
X-Webhook-Timestampage — if your server clock drifts more than 5 minutes, signatures are rejected as replays.
Old secret was leaked
- In the Volume Reach app → Settings → Automations, rotate the signing secret for the endpoint. The old secret is invalidated immediately; update your environment variable on your webhook server accordingly. No overlap window in v1 — plan for a brief deploy window.