API Webhooks
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 five subscribable events.
| 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. This is the only event that supports conditions.outcome. |
sms.received | A contact texted you back. Payload includes the full message body. |
sms.delivered | The carrier confirmed a text you sent reached the phone. Fires once per text — a 2,500-text blast fires roughly 2,500 of these over about 15 minutes. |
sms.failed | The carrier could not deliver a text you sent (Twilio failed or undelivered). Includes error_code and a plain-English reason. Does not fire for texts we never attempted to send (e.g. a number that already opted out, or a send rejected before it reached the carrier). |
lead.qualified | An AI call qualified a lead, a contact moved into a qualified pipeline stage, or a contact was pushed to your CRM (from the contact page, an automation "Push to external CRM" step, or the assistant). Payload's origin field tells you which: ai_call, pipeline_stage, automation, contact_page, or assistant. |
sms.sent and sms.ai_reply are internal-only and are not subscribable — they overlap with sms.delivered/sms.failed/sms.received, which cover the same lifecycle exactly once per text.
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).campaign.started,campaign.completed— batch operations.contact.created,contact.updated,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.
sms.received / sms.delivered / sms.failed payloads
All three text events share the same data shape; sms.received adds body, and sms.failed adds error_code and reason.
{
"event": "sms.received",
"timestamp": "2026-04-22T14:05:47.123Z",
"data": {
"message_id": "sms_abc123",
"contact_id": "prop_xyz789",
"phone": "+13105550100",
"campaign_id": "camp_def456",
"campaign_name": "Austin absentee owners",
"body": "Yes, call me tomorrow morning"
}
}
{
"event": "sms.failed",
"timestamp": "2026-04-22T14:05:47.123Z",
"data": {
"message_id": "sms_abc123",
"contact_id": "prop_xyz789",
"phone": "+13105550100",
"campaign_id": "camp_def456",
"campaign_name": "Austin absentee owners",
"error_code": "30006",
"reason": "Landline — can't receive texts"
}
}
Field notes
message_idis our internal SMS message id.contact_idis our internal contact id. It isnullfor an owner "notify me" text that isn't tied to a contact.phoneis always the contact's number — who texted you (sms.received) or who you texted (sms.delivered/sms.failed) — never your own sending number.campaign_id/campaign_nameare the parent campaign, ornullfor a text sent outside a campaign (a one-off thread reply, for example).error_codeis Twilio's numeric error code as a string;reasonis a short plain-English translation of it.
lead.qualified payload
The shape differs by origin. A push from the contact page, the assistant, or a pipeline-stage move looks like this:
{
"event": "lead.qualified",
"timestamp": "2026-04-22T14:05:47.123Z",
"data": {
"origin": "contact_page",
"contactId": "prop_xyz789",
"statusName": "Hot lead",
"source": "manual",
"campaignId": null,
"contact": {
"fullName": "Jane Doe",
"phone": "+13105550100",
"owners": [
{
"name": "Jane Doe",
"status": null,
"phones": [{ "number": "+13105550100", "label": null, "type": "mobile", "dnc": false }]
}
],
"tags": ["motivated"],
"doNotContact": false
},
"property": {
"id": "prop_xyz789",
"address1": "123 Main St",
"city": "Austin",
"state": "TX",
"zip": "78701",
"outcome": "INTERESTED",
"dialerStatus": "completed",
"callAttempts": 2,
"leadStatus": { "id": "stage_abc", "name": "Hot lead" },
"customFields": { "Roof Age": "12" },
"createdAt": "2026-04-01T10:00:00.000Z"
}
}
}
An automation "Push to external CRM" step (origin: "automation") sends a smaller block: contact has only fullName and phone, property has only id, address1, city, state, zip, and an extra automation object (sequenceId, enrollmentId, nodeId, name) names the automation that sent it. statusName is "Automation".
A lead an AI call qualified (origin: "ai_call") carries a different, richer shape instead — leadId, a lead object (id, fullName, phone, email, customFieldKeyCount), property, and call (id, outcome, durationSeconds, transcript, recordingUrl, startedAt, answeredAt). transcript is subject to the same transcript egress rules as call.completed.
Field notes
originis one ofai_call,pipeline_stage,automation,contact_page,assistant— use it to branch your handler.contact/propertyare best-effort snapshots and may be omitted if we couldn't resolve them at dispatch time.property.customFields(contact page, assistant and pipeline-stage pushes) is your own contact's extra CSV columns, sent to your own endpoint, capped at 16K characters (dropped if larger). The AI-call shape never includes a lead's raw custom fields, onlycustomFieldKeyCount.statusNameis the pipeline stage name for a pipeline-stage move or a contact-page push (null when the contact has no stage).
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 | Two buckets per tenant: 100/minute for call.completed, sms.received, and lead.qualified; 600/minute for sms.delivered and sms.failed (their own bucket, since a blast fires one of each per text and would otherwise crowd out everything else). Exceeding a bucket never drops events — they're queued as retrying and sent by the retry job within minutes. |
| 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?")
For sms.received / sms.delivered / sms.failed, the delivery id is stable per text per event (not random) — the same text's sms.delivered event always carries the same X-Webhook-Delivery-Id, even across retries. Dedup on it the same way.
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 stays in the DLQ. Exception: sms.delivered and sms.failed deliveries (one per text, so high volume) are deleted 30 days after they finish, whether they succeeded or failed. 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.