Complete endpoint reference for the Volume Reach Public API.
Base URL: https://api.volumereach.com/api/v1/public
Auth: Authorization: Bearer vr_live_...
Content-Type: application/json
Rate limits: 60 req/min per tenant on write routes, 120 req/min per tenant on read routes. Limits are shared across all API keys on a single tenant — a customer with 3 keys still has one shared budget. Exceeding → 429 rate_limited with a Retry-After header. Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers.
Conventions
- All request/response bodies are JSON.
- Timestamps are ISO-8601 in UTC.
- Phone numbers are E.164 (
+15551234567). - All routes are tenant-scoped — objects created with one API key are invisible to API keys from other tenants, even if you guess the ID.
- Error envelope shape is consistent:
{ error: { code, message, details? } }. idempotencyKeyis opt-in but recommended on write routes to protect against CRM retries.
Calls
POST /calls — Trigger an outbound qualification call
Scope: calls:place
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
agentId | string | ✅ | CUID of a Voice Agent you own. |
phoneNumber | string (E.164) | ✅ | Destination number. |
fromPhoneNumberId | string | — | Force a specific outbound number. Omit for health-weighted auto-selection. |
variables | object | — | Key/value data injected into the agent's <context> block. Max 100 entries × 4 KB each. |
metadata | object | — | Opaque passthrough (e.g. your CRM's lead ID). Max 16 KB. Echoed in every webhook. Never shown to the LLM. |
idempotencyKey | string | — | Up to 255 chars. Scoped to your API key. Replays of the same key + body return the stored call record. 24h retention. |
knownConsent | bool | — | Default false. You attest that documented TCPA consent exists for this call. See compliance.md. |
consentRecordId | string | — | Your own reference to the consent record. Stored + echoed in every webhook. |
Response 201 Created:
{
"call": {
"id": "call_xyz",
"status": "queued",
"agentId": "va_abc",
"phoneNumber": "+15551234567",
"fromPhoneNumber": "+15559876543",
"direction": "ai_outbound",
"outcome": null,
"endedReason": null,
"durationSeconds": null,
"startedAt": null,
"endedAt": null,
"createdAt": "2026-04-22T14:03:21.000Z",
"transcript": null,
"extraction": null,
"extractionError": null,
"variables": { "leadName": "Jane Smith" },
"metadata": { "crmLeadId": "hubspot-987" },
"knownConsent": true,
"consentRecordId": null
}
}
Error codes:
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_request | A required field is missing, has the wrong type, or exceeds size limits. details lists specifics. |
| 400 | invalid_phone_number | phoneNumber could not be parsed as E.164. |
| 400 | missing_variables | The agent's prompt references {{varName}} tokens not provided in variables. details.missing lists them. |
| 401 | invalid_api_key / Unauthorized | Missing or invalid Authorization header. |
| 402 | insufficient_balance | PAYG tenant: wallet balance too low to cover estimated call cost. Top up and retry. |
| 403 | insufficient_scope / Forbidden | API key lacks calls:place. |
| 403 | public_api_disabled | Public API not yet enabled for your tenant. Contact support. |
| 404 | agent_not_found | agentId doesn't exist or isn't active in your tenant. |
| 404 | phone_number_not_found | fromPhoneNumberId not owned by your tenant. |
| 409 | idempotency_conflict | Same idempotencyKey was used previously with a different request body. |
| 429 | rate_limited | Per-tenant rate limit exceeded (60 writes/min or 120 reads/min, shared across all your API keys). Retry after Retry-After seconds. |
| 429 | concurrent_limit_exceeded | Tenant's concurrent-call limit reached. Wait for in-progress calls to finish. |
| 500 | placement_failed | Something went wrong inside Volume Reach. Safe to retry with the same idempotencyKey. |
| 503 | idempotency_in_progress | Same key + same body while the original request is still dispatching. Retry after ~1s (we set Retry-After: 1). |
GET /calls/:id — Read a call
Scope: calls:read
Response 200 OK — same shape as the POST /calls response, with fields populated as the call progresses:
status:queued→ringing→in-progress→completed/no_answer/busy/failed/canceled.transcript: full transcript text (populated after call ends).extraction: structured output (populated if the agent hasextractionSchemaconfigured).recordingUrl: only returned via this endpoint (not in webhook). Signed GCS URL, expires in 15 minutes. Fetch fresh when you need to download.durationSeconds: conversation duration.nullif the call was never answered.
Error codes:
| HTTP | Code | When |
|---|---|---|
| 404 | call_not_found | No call with this ID in your tenant or the call wasn't created via the Public API (dialer calls are not exposed here; use internal Call Logs instead). |
POST /calls/:id/end — Force-terminate a call
Scope: calls:place
Hang up an in-progress call. Idempotent — safe to call multiple times; if the call has already ended, returns the current state.
Response 200 OK:
{ "id": "call_xyz", "status": "ending" }
Agents
GET /agents — List voice agents
Scope: read
Response 200 OK:
{
"agents": [
{
"id": "va_abc",
"name": "Roofing Lead Qualifier",
"isDefault": true,
"extractionSchema": { "type": "object", "properties": { "interested": { "type": "boolean" } }, "required": ["interested"] },
"updatedAt": "2026-04-22T13:00:00.000Z"
}
]
}
Only public-facing agent fields are exposed. Full configuration (system prompt, voice settings, LLM tuning) is managed in the Volume Reach app.
GET /agents/:id — Read a single agent
Scope: read
Same shape as the list item. 404 agent_not_found if the ID doesn't exist or isn't in your tenant.
POST /agents/:id/extraction-schema — Save / update extraction schema
Scope: write
Request body:
{ "schema": { "type": "object", "properties": {...}, "required": [...] } }
Pass { "schema": null } to clear the schema (disable extraction for this agent).
See extraction.md for schema authoring tips and examples.
Error codes:
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_extraction_schema | Schema is not an object, has unknown type, references a missing required field, or exceeds 32 KB. details.path indicates the problem. |
| 404 | agent_not_found | No such agent in your tenant. |
Webhooks
See webhooks.md for event types, payload shapes, and signature verification.
GET /webhooks — List webhook endpoints
Scope: webhooks
Response:
{
"endpoints": [
{
"id": "whe_abc",
"name": "Production",
"url": "https://your-app.com/webhooks",
"method": "POST",
"signingSecret": "****xxxx",
"isActive": true,
"createdAt": "2026-04-22T..."
}
]
}
Signing secrets are masked (last 4 chars only). The full secret is only returned once, at creation or regeneration time.
POST /webhooks — Create an endpoint
Scope: webhooks
Request body:
{
"name": "Production webhook",
"url": "https://your-app.com/webhooks/volumereach",
"method": "POST",
"headers": { "X-Custom": "value" }
}
| Field | Required | Description |
|---|---|---|
name | ✅ | 1–100 chars. Human label. |
url | ✅ | HTTPS in production. Must not resolve to a private IP. |
method | — | POST (default) or PUT. |
headers | — | Extra headers to include on every delivery. Security-critical headers (Host, Authorization, Content-Type, X-Webhook-*, etc.) are stripped. |
Response 201 Created:
{
"endpoint": {
"id": "whe_abc",
"name": "Production webhook",
"url": "https://your-app.com/webhooks/volumereach",
"method": "POST",
"signingSecret": "<HEX>",
"isActive": true
}
}
⚠️ Save signingSecret now — it's only shown once.
Errors:
| HTTP | Reason |
|---|---|
| 400 | Invalid URL / private-IP URL / non-HTTPS in production / too many endpoints (max 10 per tenant) |
DELETE /webhooks/:id — Delete an endpoint
Scope: webhooks
Removes the endpoint and any pending retry deliveries targeting it. Triggers referencing this endpoint are also cleaned up.
Webhook deliveries (DLQ inspection + replay)
Every webhook attempt creates a WebhookDeliveryLog row. Successful deliveries are marked success; failures retry on the schedule documented in webhooks.md. After 6 failed attempts the row is marked failed and lands in the DLQ. These endpoints let you inspect and replay deliveries programmatically (all require webhooks scope).
GET /webhook-deliveries — List deliveries
Scope: webhooks
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
status | string | — | Filter to success, retrying, failed, or pending. |
since | ISO-8601 | — | Bound the lookback. Max 30 days — anything older is silently clamped. |
page | int | 1 | 1-indexed page. |
limit | int | 20 | Max 50. |
Response 200 OK:
{
"deliveries": [
{
"id": "dlv_abc123",
"createdAt": "2026-04-22T14:05:47.123Z",
"eventType": "call.completed",
"status": "failed",
"attempts": 6,
"responseStatus": 500,
"errorMessage": "HTTP 500",
"nextRetryAt": null,
"lastAttemptAt": "2026-04-23T14:08:02.100Z",
"endpoint": { "id": "whe_abc", "name": "Production", "url": "https://your-app.com/webhooks" },
"trigger": { "id": "trg_xyz", "name": "All call completions" }
}
],
"pagination": { "page": 1, "limit": 20, "total": 4, "totalPages": 1 }
}
GET /webhook-deliveries/:id — Single delivery
Scope: webhooks
Returns the same shape as the list endpoint PLUS payload — the full JSON body we sent (useful for forensics when your handler logs were insufficient).
POST /webhook-deliveries/:id/retry — Replay a failed delivery
Scope: webhooks
Re-dispatches the stored payload to the original endpoint using the same X-Webhook-Delivery-Id so your idempotent handler sees it as a retry, not a new event.
Response 200 OK — returns the updated delivery record (status flipped to retrying or success).
Error codes:
| HTTP | Code | When |
|---|---|---|
| 404 | delivery_not_found | ID doesn't exist, isn't owned by your tenant, or is older than the retention window. |
| 400 | invalid_request | Delivery is still in pending/retrying state — manual replay only makes sense after the retry loop exhausted or explicitly for failed / success entries. See the error message for specifics. |
Automation triggers (event → endpoint subscriptions)
A webhook endpoint is a URL; an automation trigger subscribes an endpoint to an event type.
POST /automation-triggers — Subscribe
Scope: webhooks
Request body:
{
"name": "All call completions",
"eventType": "call.completed",
"webhookEndpointId": "whe_abc",
"conditions": { "outcome": ["INTERESTED", "APPOINTMENT"] }
}
| Field | Required | Description |
|---|---|---|
name | ✅ | 1–100 chars. |
eventType | ✅ | Currently allowed: call.completed. See webhooks.md. |
webhookEndpointId | ✅ | Must belong to your tenant. |
conditions.outcome | — | Array of outcome strings; only fires when the call outcome matches one of them. Omit to fire on every outcome. |
Max 25 triggers per tenant.
GET /automation-triggers — List
Scope: webhooks
{
"triggers": [
{
"id": "at_abc",
"name": "...",
"eventType": "call.completed",
"webhookEndpointId": "whe_abc",
"conditions": {},
"isActive": true,
"webhookEndpoint": { "id": "whe_abc", "name": "Production", "url": "https://..." }
}
]
}
GET /automation-triggers/:id — Read
Scope: webhooks
DELETE /automation-triggers/:id — Unsubscribe
Scope: webhooks
Future additions
Reserved space — will be added when we receive consistent customer requests. Documented so integrators can plan around eventual support.
| Feature | Status |
|---|---|
| Mid-call tool execution (agent calls your HTTPS endpoint during the conversation) | Deferred |
scheduledAt to delay a call to a future time | Deferred |
| Inbound agent routing (phone number → voice agent) | Deferred |
| Webhook signing-secret rotation with overlap window | Deferred |
Webhook delivery-log replay API (POST /webhook-deliveries/:id/retry) | Deferred |
More granular call lifecycle events (call.ringing, call.answered, etc.) | Deferred; currently you receive call.started + call.completed |
| Platform-enforced TCPA floors (hard-stop midnight-6am, per-number daily cap) | Deferred; MVP is tenant-attestation only |