Volume Reach/Docs

Direct API integration code you can copy-paste into your backend. Covers the full trigger-and-handle loop.


curl — smoke test

1. Create a webhook endpoint

curl -X POST https://api.volumereach.com/api/v1/public/webhooks \
  -H "Authorization: Bearer $VR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My backend",
    "url": "https://mybackend.com/webhooks/volumereach"
  }'

Save the returned signingSecret to your environment.

2. Subscribe to call.completed

curl -X POST https://api.volumereach.com/api/v1/public/automation-triggers \
  -H "Authorization: Bearer $VR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "All completions",
    "eventType": "call.completed",
    "webhookEndpointId": "whe_abc"
  }'

3. Place a test call

curl -X POST https://api.volumereach.com/api/v1/public/calls \
  -H "Authorization: Bearer $VR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "va_abc",
    "phoneNumber": "+15551234567",
    "variables": {
      "leadName": "Jane Smith",
      "propertyAddress": "123 Oak St"
    },
    "metadata": { "test": true },
    "idempotencyKey": "smoke-test-1",
    "knownConsent": true
  }'

4. Read the call

curl https://api.volumereach.com/api/v1/public/calls/call_xyz \
  -H "Authorization: Bearer $VR_API_KEY"

5. Cancel an in-flight call

curl -X POST https://api.volumereach.com/api/v1/public/calls/call_xyz/end \
  -H "Authorization: Bearer $VR_API_KEY"

Node.js (Express)

Complete backend that:

  • Exposes an endpoint your CRM posts new-lead events to.
  • Triggers a Volume Reach call.
  • Receives the call.completed webhook, verifies its signature, and updates your CRM.
// server.mjs
import express from "express";
import crypto from "node:crypto";

const app = express();
const PORT = process.env.PORT || 3000;

const VR_API_KEY = process.env.VR_API_KEY; // vr_live_...
const VR_AGENT_ID = process.env.VR_AGENT_ID; // va_...
const VR_SIGNING_SECRET = process.env.VR_SIGNING_SECRET;
const VR_BASE = "https://api.volumereach.com/api/v1/public";

// ─── Trigger a call when your CRM notifies us of a new lead ─────────────────
app.use(express.json());

app.post("/internal/new-lead", async (req, res) => {
  const { phone, name, email, leadId, propertyAddress } = req.body;

  if (!phone) return res.status(400).json({ error: "phone is required" });

  const response = await fetch(`${VR_BASE}/calls`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${VR_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      agentId: VR_AGENT_ID,
      phoneNumber: phone,
      variables: {
        leadName: name || "there",
        leadEmail: email || "",
        propertyAddress: propertyAddress || "",
      },
      metadata: { crmLeadId: leadId },
      idempotencyKey: `lead-${leadId}-v1`, // survive CRM retries
      knownConsent: true,
      consentRecordId: `crm-consent-${leadId}`,
    }),
  });

  if (!response.ok) {
    const err = await response.json().catch(() => ({}));
    console.error("volume reach call failed", { status: response.status, err });
    return res.status(502).json({ error: err?.error?.code || "call_failed" });
  }

  const { call } = await response.json();
  res.json({ callId: call.id, status: call.status });
});

// ─── Receive the call.completed webhook ─────────────────────────────────────
//
// IMPORTANT: use express.raw() so the raw bytes are available for HMAC verify.
// express.json() re-serializes and will break the signature check.
app.post(
  "/webhooks/volumereach",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("X-Webhook-Signature");
    const timestamp = req.header("X-Webhook-Timestamp");
    const rawBody = req.body; // Buffer

    if (!verifySignature({ signature, timestamp, rawBody, secret: VR_SIGNING_SECRET })) {
      console.warn("invalid webhook signature", { signature, timestamp });
      return res.sendStatus(401);
    }

    const payload = JSON.parse(rawBody.toString("utf8"));

    if (payload.event !== "call.completed") return res.sendStatus(200);

    // Do the expensive work async so we can return 200 quickly.
    handleCallCompleted(payload.data).catch((err) => {
      console.error("handler error", err);
    });

    res.sendStatus(200);
  },
);

function verifySignature({ signature, timestamp, rawBody, secret }) {
  if (!signature || !timestamp || !rawBody) return false;
  const ts = parseInt(timestamp, 10);
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;

  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.${rawBody.toString("utf8")}`)
      .digest("hex");

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

async function handleCallCompleted(data) {
  const crmLeadId = data.metadata?.crmLeadId;
  const extraction = data.extraction || {};

  // 1. Update your CRM with the outcome + transcript
  await yourCrm.updateLead(crmLeadId, {
    vr_call_id: data.call_id,
    vr_outcome: data.outcome,
    vr_transcript: data.transcript,
    vr_summary: extraction.summary,
  });

  // 2. If interested + appointment confirmed, book the calendar slot
  if (extraction.interested && extraction.appointmentDateTimeISO) {
    await yourCalendar.createEvent({
      summary: `Site visit: ${data.contact.name || "Lead"}`,
      startISO: extraction.appointmentDateTimeISO,
      durationMinutes: extraction.appointmentDurationMinutes || 60,
      attendeeEmail: extraction.contactEmail,
      description: `Outcome: ${data.outcome}\n\n${data.transcript}`,
    });
  }

  // 3. If DNC or NOT_OWNER, add to suppression list
  if (["DNC", "NOT_OWNER", "WRONG_NUMBER"].includes(data.outcome)) {
    await yourSuppressionList.add(data.contact.phone);
  }
}

app.listen(PORT, () => console.log(`listening on :${PORT}`));

Python (Flask)

# server.py
import os, hmac, hashlib, time, json, requests
from flask import Flask, request, abort, jsonify

app = Flask(__name__)
VR_API_KEY = os.environ["VR_API_KEY"]
VR_AGENT_ID = os.environ["VR_AGENT_ID"]
VR_SIGNING_SECRET = os.environ["VR_SIGNING_SECRET"].encode()
VR_BASE = "https://api.volumereach.com/api/v1/public"

# ─── Trigger a call ─────────────────────────────────────────────────────────
@app.post("/internal/new-lead")
def new_lead():
    body = request.get_json() or {}
    phone = body.get("phone")
    if not phone:
        return jsonify({"error": "phone is required"}), 400

    response = requests.post(
        f"{VR_BASE}/calls",
        headers={
            "Authorization": f"Bearer {VR_API_KEY}",
            "Content-Type": "application/json",
        },
        json={
            "agentId": VR_AGENT_ID,
            "phoneNumber": phone,
            "variables": {
                "leadName": body.get("name") or "there",
                "leadEmail": body.get("email") or "",
                "propertyAddress": body.get("propertyAddress") or "",
            },
            "metadata": {"crmLeadId": body.get("leadId")},
            "idempotencyKey": f"lead-{body.get('leadId')}-v1",
            "knownConsent": True,
        },
        timeout=15,
    )
    if not response.ok:
        return jsonify({"error": response.json().get("error", {}).get("code", "call_failed")}), 502

    call = response.json()["call"]
    return jsonify({"callId": call["id"], "status": call["status"]})


# ─── Receive + verify webhook ───────────────────────────────────────────────
@app.post("/webhooks/volumereach")
def webhook():
    signature = request.headers.get("X-Webhook-Signature", "")
    timestamp = request.headers.get("X-Webhook-Timestamp", "")
    raw = request.get_data()  # raw bytes

    if not verify_signature(signature, timestamp, raw, VR_SIGNING_SECRET):
        abort(401)

    data = json.loads(raw.decode())
    if data.get("event") != "call.completed":
        return ("", 200)

    handle_call_completed(data["data"])
    return ("", 200)


def verify_signature(signature: str, timestamp: str, raw: bytes, secret: bytes) -> bool:
    if not signature or not timestamp or not raw:
        return False
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - ts) > 300:
        return False

    expected = "sha256=" + hmac.new(
        secret, f"{timestamp}.{raw.decode('utf-8')}".encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)


def handle_call_completed(data: dict) -> None:
    crm_lead_id = (data.get("metadata") or {}).get("crmLeadId")
    extraction = data.get("extraction") or {}
    # ... your CRM + calendar + suppression-list logic here ...

Go (net/http)

// main.go
package main

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

var (
    vrAPIKey        = os.Getenv("VR_API_KEY")
    vrAgentID       = os.Getenv("VR_AGENT_ID")
    vrSigningSecret = []byte(os.Getenv("VR_SIGNING_SECRET"))
    vrBase          = "https://api.volumereach.com/api/v1/public"
)

func newLeadHandler(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Phone, Name, Email, LeadID, PropertyAddress string
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad json", http.StatusBadRequest)
        return
    }

    body, _ := json.Marshal(map[string]any{
        "agentId":     vrAgentID,
        "phoneNumber": req.Phone,
        "variables": map[string]string{
            "leadName":        req.Name,
            "leadEmail":       req.Email,
            "propertyAddress": req.PropertyAddress,
        },
        "metadata":       map[string]string{"crmLeadId": req.LeadID},
        "idempotencyKey": fmt.Sprintf("lead-%s-v1", req.LeadID),
        "knownConsent":   true,
    })

    httpReq, _ := http.NewRequest("POST", vrBase+"/calls", bytes.NewReader(body))
    httpReq.Header.Set("Authorization", "Bearer "+vrAPIKey)
    httpReq.Header.Set("Content-Type", "application/json")
    resp, err := http.DefaultClient.Do(httpReq)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()

    w.WriteHeader(resp.StatusCode)
    io.Copy(w, resp.Body)
}

func webhookHandler(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 == "" {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || time.Now().Unix()-ts > 300 {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }

    mac := hmac.New(sha256.New, vrSigningSecret)
    mac.Write([]byte(timestamp + "." + string(raw)))
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    if !hmac.Equal([]byte(signature), []byte(expected)) {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }

    var evt struct {
        Event string `json:"event"`
        Data  json.RawMessage `json:"data"`
    }
    _ = json.Unmarshal(raw, &evt)
    if evt.Event == "call.completed" {
        go handleCallCompleted(evt.Data)
    }
    w.WriteHeader(http.StatusOK)
}

func handleCallCompleted(data json.RawMessage) {
    // ... your logic ...
}

func main() {
    http.HandleFunc("/internal/new-lead", newLeadHandler)
    http.HandleFunc("/webhooks/volumereach", webhookHandler)
    http.ListenAndServe(":3000", nil)
}

Testing locally with a public tunnel

To receive webhooks on localhost during development, use a tunnel like ngrok or Cloudflare Tunnel:

# ngrok
ngrok http 3000
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000

Register the ngrok URL as your webhook endpoint via POST /webhooks. Trigger a test call and watch the webhook arrive in your logs.

Remember: production endpoints must be publicly reachable HTTPS URLs. Don't register ngrok URLs as production endpoints — they're for development only.


Production checklist

  • Use idempotency keys on every POST /calls request (derive from your CRM's lead/deal ID).
  • Verify webhook signatures (timing-safe compare, timestamp freshness check).
  • Make webhook handlers idempotent (keyed on call_id). At-least-once delivery means duplicates can happen.
  • Return 200 from webhook handlers within 10 seconds — move slow work into a background job.
  • Do NOT persist recording_url — it expires in 15 minutes. Fetch fresh via GET /calls/:id when you need to play it back.
  • Log X-Webhook-Delivery-Id on every received delivery for support debugging.
  • Monitor Volume Reach error codes and alert on unexpected spikes (especially 429, 402 insufficient_balance, 403 public_api_disabled).
  • Keep the signing secret in a secrets manager (not in git). Rotate it if any instance of it leaks.