Volume Reach/Docs

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 one primary event; more will follow as they're requested.

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.

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

  • 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.

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 limit100 dispatches / minute / tenant. If you exceed this, some events are dropped with a webhook_dispatch_rate_limited log entry.
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?")

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 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-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.