Volume Reach

API Webhooks

How Volume Reach delivers call-lifecycle events to your system.


How it fits together

Volume Reach uses a two-object model:

  1. Webhook endpoint — the HTTPS URL + signing secret you control. One tenant can have up to 10 endpoints.
  2. 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.

EventWhen it fires
call.completedA 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.receivedA contact texted you back. Payload includes the full message body.
sms.deliveredThe 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.failedThe 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.qualifiedAn 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

  • outcome is 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_seconds is the billed duration (answered + talking). null or 0 if the call was never answered.
  • transcript is capped at 10 000 characters; if the call was unusually long, the tail is truncated with ...[truncated].
  • recording_url is always null in the webhook payload. Fetch a fresh signed URL from GET /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_id are only populated for calls triggered via POST /calls (they come from the original API request). For internal dialer-originated calls, these fields will be absent.
  • extraction is populated only when the agent has an extractionSchema configured. Null otherwise.
  • extraction_error is a short error message if extraction was attempted but failed (e.g. OpenAI rate-limit during a burst). Inspect transcript manually 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_id is our internal SMS message id.
  • contact_id is our internal contact id. It is null for an owner "notify me" text that isn't tied to a contact.
  • phone is 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_name are the parent campaign, or null for a text sent outside a campaign (a one-off thread reply, for example).
  • error_code is Twilio's numeric error code as a string; reason is 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

  • origin is one of ai_call, pipeline_stage, automation, contact_page, assistant — use it to branch your handler.
  • contact / property are 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, only customFieldKeyCount.
  • statusName is the pipeline stage name for a pipeline-stage move or a contact-page push (null when the contact has no stage).

Delivery guarantees

AspectBehavior
DeliveryAt-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.
OrderingNot guaranteed. Two events for the same call can theoretically arrive out of order under high concurrency.
Rate limitTwo 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.
Timeout10 seconds per delivery. Your handler must return a status code within that window.
SuccessHTTP 2xx. Anything else triggers retry.
Retry scheduleExponential 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

  1. Confirm you have BOTH a webhook endpoint and an automation trigger: GET /webhooks and GET /automation-triggers.
  2. Confirm isActive: true on both.
  3. Check the Settings → Automations page in the Volume Reach app — it shows recent delivery attempts (success, failed, retrying) with the response codes you returned.
  4. 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. 5xx from 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-Timestamp age — 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.