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.completedwebhook, 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 /callsrequest (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
200from webhook handlers within 10 seconds — move slow work into a background job. - Do NOT persist
recording_url— it expires in 15 minutes. Fetch fresh viaGET /calls/:idwhen you need to play it back. - Log
X-Webhook-Delivery-Idon 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.