Developers · Public REST API v1

The voxalways.com API

Originate outbound voice-AI calls, read post-call intelligence and receive signed webhooks the moment a call completes, programmatically, from any stack.

Base URL https://app.voxalways.com/api/v1

Overview

The voxalways.com Public REST API lets you drive the voice platform from your own software: place outbound calls, poll their status, pull the saved transcript and post-call intelligence, and subscribe to webhooks for completion events. Every call you originate rides the same pipeline as the dashboard: the same trunk, quota, compliance and post-call analysis.

  • REST over HTTPS; all request/response bodies are JSON.
  • Responses are snake_case; timestamps are Unix epoch milliseconds.
  • Every key is scoped to one tenant (your organization), so you only ever see your own data.
Base URL. Replace the host with your own dashboard domain if you're on a dedicated deployment. All paths below are relative to /api/v1. Don't have access yet? Email hello@voxalways.com.

Authentication

Authenticate every request with a secret API key. Create keys in the dashboard under Settings → API keys. A key is shown in full exactly once at creation (it is stored only as a SHA-256 hash and cannot be recovered), so copy it then. Keys look like:

vx_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4

Pass it on every request in either header (Bearer is preferred):

Authorization: Bearer vx_live_...
# or
X-API-Key: vx_live_...

Scopes

Each key carries a coarse scope set. A request to an endpoint whose scope the key lacks returns 403.

ScopeGrants
calls:readList calls, retrieve a call, fetch transcripts.
calls:writeOriginate outbound calls.
Keep keys server-side. Anyone with a key can place calls billed to your account. Revoke a leaked key instantly from Settings → API keys; revocation takes effect immediately.

Rate limits

Each API key is limited to 120 requests per 60 seconds (fixed window). Exceeding it returns 429 Too Many Requests with a Retry-After header (seconds to wait):

HTTP/1.1 429 Too Many Requests
Retry-After: 42
Content-Type: application/json

{ "error": "Rate limit exceeded" }

Back off for the indicated number of seconds, then retry.

Errors

Errors return the matching HTTP status and a JSON body with an error message (plus occasional context fields, e.g. blocked for a compliance stop):

{ "error": "This number is on the DND/suppression list and cannot be called.", "blocked": "dnd" }
StatusMeaning
200OK (GET).
202Accepted: the call is being placed.
400Validation error (e.g. malformed phone number).
401Missing, invalid or revoked API key.
403Key lacks the required scope, or a compliance gate blocked the call.
404Resource not found for this tenant.
429Rate limit exceeded.
500Server error (e.g. SIP trunk not configured).

Originate a call

POST /api/v1/calls scope: calls:write

Place an outbound call. The call is dispatched asynchronously and the endpoint returns immediately with 202. Watch it complete by polling GET /calls/{id} or subscribing to the call.completed webhook. Quota, calling-window and DND/suppression checks apply exactly as in the dashboard.

Body parameters

FieldTypeDescription
phoneNumberstringrequiredNumber to dial. E.164 or a national number with common separators.
agentIdstringoptionalSaved agent persona to use for this call.
systemPromptstringoptionalInline system prompt that overrides the agent's default.
agentNamestringoptionalDisplay name for the agent on this call.
promptstringoptionalPer-call context / instructions for the agent.
voicestringoptionalVoice id (e.g. priya).
languagestringoptionalLanguage code (e.g. en, hi).
contactNamestringoptionalCaller's name, stored on the call.
categoryIdstringoptionalVertical/category for post-call field extraction.
fieldValuesobjectoptionalValues for the agent's tool/category fields.
campaignstringoptionalCampaign name (denormalized onto the call).
campaignIdstringoptionalCampaign id to attribute this call to.
maxDurationnumberoptionalHard cap on call length, in minutes.
allowTransferbooleanoptionalPermit the agent to transfer to a human.
retrybooleanoptionalHint to retry on no-answer (carried as metadata).

Request

curl -X POST "https://app.voxalways.com/api/v1/calls" \
  -H "Authorization: Bearer vx_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+919876543210",
    "agentId": "a_front_desk",
    "contactName": "Alice",
    "campaign": "Q3 Outreach",
    "language": "en"
  }'

Response · 202 Accepted

{
  "id": "call-919876543210-5f8c",
  "room": "call-919876543210-5f8c",
  "status": "dispatched",
  "recording": true
}
A 403 with "blocked": "dnd" means the number is on your suppression list; a 403 with a scope message means the key can't write calls.

List calls

GET /api/v1/calls scope: calls:read

Return recent calls for your tenant, newest first, with post-call intelligence once analyzed.

Query parameters

ParamTypeDefaultDescription
pagenumber11-based page number.
page_sizenumber25Rows per page (max 100).

Request

curl "https://app.voxalways.com/api/v1/calls?page=1&page_size=25" \
  -H "Authorization: Bearer vx_live_..."

Response · 200 OK

{
  "data": [
    {
      "id": "call-919876543210-5f8c",
      "room": "call-919876543210-5f8c",
      "phone_number": "+919876543210",
      "contact_name": "Alice",
      "direction": "outbound",
      "status": "completed",
      "created_at": 1719000000000,
      "ended_at": 1719001200000,
      "summary": "Caller confirmed the appointment and asked about parking.",
      "sentiment": "positive",
      "outcome": "booked",
      "recording_url": "https://.../call-919876543210-5f8c.ogg",
      "cost_usd": 0.048,
      "agent_name": "Ava (Front Desk)"
    }
  ],
  "page": 1,
  "page_size": 25,
  "total": 247,
  "has_more": true
}

Retrieve a call

GET /api/v1/calls/{id} scope: calls:read

Fetch one call. Poll this after originating to watch it move dispatched → active → completed. The intelligence fields (summary, sentiment, outcome) fill in a few seconds after the call ends, once post-call analysis runs.

Call object

FieldTypeDescription
idstringCall id.
roomstringInternal room name.
phone_numberstringThe dialed number.
contact_namestring | nullCaller name, if provided.
directionstring | nulloutbound or inbound.
statusstringdispatched · active · completed · failed.
created_atnumberStart time (epoch ms).
ended_atnumber | nullEnd time (epoch ms).
summarystring | nullAI summary (after analysis).
sentimentstring | nullpositive · neutral · negative.
outcomestring | nullinterested · not_interested · callback · booked · no_answer · unknown.
recording_urlstring | nullRecording URL, if recording is enabled.
cost_usdnumber | nullEstimated call cost (USD).
agent_namestring | nullAgent used on the call.

Request

curl "https://app.voxalways.com/api/v1/calls/call-919876543210-5f8c" \
  -H "Authorization: Bearer vx_live_..."

Response · 200 OK (while active)

{
  "id": "call-919876543210-5f8c",
  "status": "active",
  "summary": null,
  "sentiment": null,
  "outcome": null,
  "recording_url": "https://.../call-919876543210-5f8c.ogg",
  "agent_name": "Ava (Front Desk)"
}

Get transcript

GET /api/v1/calls/{id}/transcript scope: calls:read

Return the saved transcript, ordered by seq. lines is empty until the agent has persisted turns (or if transcript saving is off for the tenant).

Request

curl "https://app.voxalways.com/api/v1/calls/call-919876543210-5f8c/transcript" \
  -H "Authorization: Bearer vx_live_..."

Response · 200 OK

{
  "id": "call-919876543210-5f8c",
  "lines": [
    { "role": "agent",  "text": "Hello! This is Ava from Meridian Clinics...", "seq": 0, "at": 1719000010000 },
    { "role": "caller", "text": "Hi, yes, that time still works.",             "seq": 1, "at": 1719000016000 }
  ]
}

Webhooks

Instead of polling, subscribe an HTTPS endpoint under Settings → Webhooks and voxalways.com will POST an event to it. The signing secret (whsec_...) is shown once at creation, so store it to verify signatures. Available event types:

EventFires when
call.completedA call ended and post-call analysis finished (summary, sentiment, outcome, lead snapshot ready).
call.failedA call failed (e.g. unanswered after retries).
recording.readyA call's recording URL is available.
lead.createdA new lead was created from a call.
lead.updatedA lead's status or intelligence changed.
campaign.completedA campaign run finished.
transfer.createdA call was transferred to a human.
whatsapp.reply.receivedAn inbound WhatsApp reply arrived.

Headers

Content-Type: application/json
User-Agent: Voxalways-Webhooks/1.0
X-Voxalways-Event: call.completed
X-Voxalways-Delivery: whd_1a2b3c...
X-Voxalways-Signature: t=1719001200,v1=6b6f...9c1a

Example: call.completed payload

{
  "id": "whd_1a2b3c4d5e",
  "event": "call.completed",
  "createdAt": 1719001200000,
  "data": {
    "call": {
      "id": "call-919876543210-5f8c",
      "room": "call-919876543210-5f8c",
      "phoneNumber": "+919876543210",
      "direction": "outbound",
      "contactName": "Alice",
      "campaign": "Q3 Outreach",
      "campaignId": "cmp_1720000001",
      "agentName": "Ava (Front Desk)",
      "status": "completed",
      "error": null,
      "createdAt": 1719000000000,
      "endedAt": 1719001200000,
      "durationSeconds": 1200,
      "summary": "Caller confirmed the appointment and asked about parking.",
      "sentiment": "positive",
      "outcome": "booked",
      "intel": { "appointment": "Tomorrow · 2:15 PM", "branch": "Indiranagar" },
      "recordingUrl": "https://.../call-919876543210-5f8c.ogg",
      "transcriptUrl": "https://app.voxalways.com/transcripts?room=call-919876543210-5f8c"
    },
    "lead": {
      "id": "ld_1720000010",
      "phone": "+919876543210",
      "name": "Alice",
      "status": "booked",
      "score": 88,
      "intel": { "appointment": "Tomorrow · 2:15 PM" },
      "callsCount": 1,
      "nextFollowUpAt": null,
      "updatedAt": 1719001200000
    }
  }
}
data.lead is null for calls that don't map to a lead. Webhook payloads use the platform's native camelCase call shape (the REST read endpoints above are snake_case).

Verifying signatures

Every delivery is signed Stripe-style. The X-Voxalways-Signature header is t=<unix-seconds>,v1=<hex>, where v1 = HMAC-SHA256(secret, "<t>.<raw-body>"). Verify against the raw request body (not a re-serialized object), and reject if it doesn't match.

const crypto = require("crypto");

function verify(rawBody, sigHeader, secret) {
  const parts = Object.fromEntries(sigHeader.split(",").map((kv) => kv.split("=")));
  const expected = crypto
    .createHmac("sha256", secret)
    .update(parts.t + "." + rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}

// Express: capture the raw body with express.raw({ type: "application/json" }).
app.post("/webhooks/voxalways", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verify(req.body.toString(), req.headers["x-voxalways-signature"], process.env.VOX_WEBHOOK_SECRET);
  if (!ok) return res.status(401).end();
  const event = JSON.parse(req.body.toString());
  // handle event.event / event.data ...
  res.status(200).end();
});

The t value inside the signed string is a replay guard: reject deliveries whose timestamp is far from your current clock.


Delivery & retries

Return any 2xx within 10 seconds to acknowledge a delivery. A non-2xx, a timeout, or a network error is retried on this schedule, then marked exhausted:

AttemptWhen
1immediately
2+1 minute
3+10 minutes
4+1 hour
5+6 hours
exhausted (no further retries)

Make your handler idempotent: dedupe on X-Voxalways-Delivery (the delivery id) since a slow-but-successful endpoint may occasionally see a retry. Inspect and manually redeliver failed deliveries from Settings → Webhooks → delivery log.

Ready to build? Create a key in Settings → API keys and place your first call with the originate example above.