VoixCall

VoixCall for developers

Quickstart

Three ways in, each under five minutes: Claude Code, a claude.ai or ChatGPT connector, or plain curl against the REST API.

A. Connect Claude Code

Claude Code speaks MCP over HTTP and handles OAuth itself, including registration by its own client metadata document.

Terminal
claude mcp add --transport http voixcall https://api.voixcall.com/mcp
claude mcp login voixcall
  1. The second command opens app.voixcall.com in your browser. Sign in if you need to.
  2. The consent page names the client (Claude Code), the permissions it asked for, and the host it will redirect to. Choose Allow.
  3. Back in Claude Code, ask: which VoixCall account am I connected to? It calls voixcall_whoami and answers with the account's name and email.

To disconnect later, either run claude mcp remove voixcall or open Settings → Connected apps in the web app and choose Disconnect; the second one revokes the tokens on our side as well.

B. Connect claude.ai or ChatGPT

In claude.ai, add a custom connector with the URL https://api.voixcall.com/mcp. Leave the client id and secret fields empty: the connector discovers the authorization server from the URL, registers itself and runs the OAuth flow. You approve once on app.voixcall.com, then the VoixCall tools appear in the chat. Hosted connectors use the same standard MCP discovery and OAuth 2.1 flow. Unlike Claude Code (which is verified against production), they connect from the vendor's servers with an https callback, which we have not yet exercised end to end.

ChatGPT's connector settings take the same server URL and use the same standard MCP discovery and OAuth 2.1 flow; like claude.ai, it connects from the vendor's servers with an https callback, which we have not yet exercised end to end. If you try it, tell us at support@voixcall.com.

Any other MCP client that supports Streamable HTTP and OAuth works the same way. Details and the tool list are on the MCP page.

C. Call the REST API with curl

The API accepts OAuth 2.1 access tokens (and, once the Settings screen for them ships, personal API keys). Getting a token by hand is the standard authorization-code flow with PKCE; the steps below do it with curl and a browser and take about two minutes. Nothing here needs a client secret: every client is a public client.

1. Read the server metadata

All endpoints, supported scopes and PKCE methods come from the authorization-server metadata document (RFC 8414). Nothing in this guide is hard-coded that the document does not also say.

Terminal
curl -s https://api.voixcall.com/.well-known/oauth-authorization-server

2. Register a client

If you host a public client metadata document (an https URL that returns your client's metadata, with client_id equal to that URL), skip this step and use the URL as your client_id. Otherwise register once with Dynamic Client Registration. Loopback redirect URIs (http://127.0.0.1, http://localhost, http://[::1], any port) are allowed for native and script clients.

Terminal
curl -s https://api.voixcall.com/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My VoixCall script",
    "redirect_uris": ["http://127.0.0.1:8765/callback"],
    "application_type": "native",
    "token_endpoint_auth_method": "none",
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"]
  }'
Response (201 Created)
{
  "client_id": "dcr_…",
  "client_id_issued_at": 1790612000,
  "redirect_uris": ["http://127.0.0.1:8765/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "client_name": "My VoixCall script",
  "application_type": "native"
}

Keep the client_id. Registrations that are never used are purged after 30 days.

3. Send the user to the authorization page

Build a PKCE verifier and challenge, then open the authorization URL. Ask only for the scopes you need. The example requests the five read scopes that connections are offered today (rates:read credits:read calls:read contacts:read numbers:read), so every call in the next section works; /v1/me alone works with any one of them. calls:place, messages:read, messages:history, transcripts:read and webhooks:manage are not offered until their features ship. resource is required and must be exactly https://api.voixcall.com/v1 (or …/mcp for an MCP client); the token is bound to it.

Terminal (macOS: open; Linux: xdg-open)
CLIENT_ID="dcr_…"                       # from the registration response
CODE_VERIFIER=$(openssl rand -base64 48 | tr -d '=+/')
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)

open "https://api.voixcall.com/oauth/authorize?response_type=code\
&client_id=$CLIENT_ID\
&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback\
&scope=rates%3Aread%20credits%3Aread%20calls%3Aread%20contacts%3Aread%20numbers%3Aread\
&state=$STATE\
&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256\
&resource=https%3A%2F%2Fapi.voixcall.com%2Fv1"

Sign in and choose Allow. The browser is redirected to http://127.0.0.1:8765/callback?code=…&state=…&iss=https://api.voixcall.com. Nothing is listening there, so the page shows a connection error: that is expected. Copy the code value from the address bar. It is single-use and expires 60 seconds after it was issued, so do step 4 right away.

4. Exchange the code for tokens

Terminal
CODE="…"                                # the code= value from the redirect URL

curl -s https://api.voixcall.com/oauth/token \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d code_verifier="$CODE_VERIFIER" \
  -d client_id="$CLIENT_ID" \
  -d redirect_uri=http://127.0.0.1:8765/callback \
  -d resource=https://api.voixcall.com/v1
Response (200 OK)
{
  "access_token": "vcat_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "vcrt_…",
  "scope": "rates:read credits:read calls:read contacts:read numbers:read"
}

Access tokens (vcat_) last one hour. Refresh tokens (vcrt_) last 30 days, or 14 days without use, and are rotated on every refresh: the old one stops working and presenting it again revokes the whole grant.

5. Call /v1/me

Terminal
TOKEN="vcat_…"

curl -s https://api.voixcall.com/v1/me \
  -H "Authorization: Bearer $TOKEN"
Response (200 OK)
{
  "object": "user",
  "id": "usr_0f8c2b6a4d1e4c7b9a3e5d2f1c0b9a87",
  "email": "you@example.com",
  "display_name": "Your Name",
  "country_code": null,
  "time_zone": null,
  "livemode": true
}

id is the account id with the usr_ prefix. country_code and time_zone are always null today (not stored yet). livemode is true for OAuth tokens and live API keys. /v1/me works with any scope. The read endpoints below need their own scope each; the reference has every field.

6. Refresh

client_id is required on refresh (public client). A scope parameter may narrow the token's scopes, never widen them.

Terminal
curl -s https://api.voixcall.com/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token="vcrt_…" \
  -d client_id="$CLIENT_ID"

Read the account: rates, credits, calls, contacts, numbers

Live since 2026-09-28. Every endpoint is a GET on https://api.voixcall.com/v1, takes the same bearer token, acts on the personal account only (organization calls, numbers and contacts are never visible, even to an organization admin) and counts against the reads budget on the rate limits page. Money is always a decimal string with its currency ("12.40", "0.0297"), never a float. Samples use fake ids and numbers; the field order and the nulls are exactly what the API returns.

EndpointScopeReturns
GET /v1/rates?to=rates:readPer-minute rate and billing increment for one E.164 number
GET /v1/credits/balancecredits:readAvailable credit, low-balance flag, where to add credit
GET /v1/credits/transactionscredits:readPurchases, call and service charges, app-store refunds and the initial account credit (list)
GET /v1/callscalls:readCall history, filterable by direction, start time and number (list)
GET /v1/calls/{id}calls:readOne call, with live figures while it is active
GET /v1/contacts?q=contacts:readContacts matching a name, company or number fragment (list)
GET /v1/numbersnumbers:readYour virtual numbers, excluding released ones (list)
GET /v1/caller-idnumbers:readCurrent caller ID, every selectable option, verified numbers

A token without the scope gets 403 insufficient_scope with WWW-Authenticate: Bearer error="insufficient_scope", scope="…"; tokens never gain scopes after consent, so the user has to reconnect.

Rates

to must be a full E.164 number. Encode the + as %2B in a URL (an unencoded + is read as a space, which the API tolerates, but encoding is safer). This is the rate the app quotes before a call; the rate actually charged is fixed when the call is set up and appears as rate_per_minute on the call. Nothing in the response depends on your balance. rate_basis says whether the quote was priced from your selected caller ID or the platform default.

Terminal
curl -s "https://api.voixcall.com/v1/rates?to=%2B442071234567" \
  -H "Authorization: Bearer $TOKEN"
Response (200 OK)
{
  "object": "rate",
  "livemode": true,
  "destination": "+442071234567",
  "country_code": "GB",
  "country_name": "United Kingdom",
  "destination_type": "fixed_line",
  "rate_per_minute": "0.0297",
  "currency": "USD",
  "billing_increment_seconds": 60,
  "rate_basis": "selected_caller_id"
}

A malformed number is 400 invalid_destination, a destination we do not call is 422 destination_blocked, and a pricing lookup that fails upstream is 503 rate_unavailable (retry).

Credits

Terminal
curl -s https://api.voixcall.com/v1/credits/balance \
  -H "Authorization: Bearer $TOKEN"
Response (200 OK)
{
  "object": "balance",
  "livemode": true,
  "amount": "12.40",
  "currency": "USD",
  "low_balance": false,
  "billing_url": "https://voixcall.com/get?to=credits"
}

low_balance is true below the account's low-balance threshold. billing_url is where the user adds credit, inside VoixCall; there is no purchase endpoint and there will not be one.

Terminal
curl -s "https://api.voixcall.com/v1/credits/transactions?limit=1" \
  -H "Authorization: Bearer $TOKEN"
Response (200 OK)
{
  "object": "list",
  "data": [
    {
      "id": "txn_5b1e9c0d7a2f4e3b8c6d1a0f9e8b7c6d",
      "object": "transaction",
      "livemode": true,
      "type": "deduction",
      "amount": "-0.18",
      "currency": "USD",
      "balance_after": "12.40",
      "description": "Call to +442071234567 (312 seconds)",
      "created_at": "2026-09-28T09:20:14Z"
    }
  ],
  "has_more": true
}

type is purchase, deduction, refund, promotion, adjustment or addition; treat unknown types as informational. amount is the signed change to the balance: negative for call and service charges (deduction) and for refunds of app-store purchases (refund), positive for purchases (purchase) and the initial account credit (addition). balance_after is the balance recorded after the entry; amounts are rounded to cents, so a running sum can differ from balance_after by rounding. promotion is positive and adjustment carries its own sign (neither is used today). balance_after and description can be null on older entries.

Calls

Filters: direction (outbound or inbound), started_at[gte] and started_at[lte] (RFC 3339), and to (E.164, exact match). A bad filter is 400 invalid_filter with param naming it.

Terminal
curl -s "https://api.voixcall.com/v1/calls?limit=1&direction=outbound" \
  -H "Authorization: Bearer $TOKEN"
Response (200 OK)
{
  "object": "list",
  "data": [
    {
      "id": "call_8f2d4b6a1c3e4f5a9b7d0e2c4a6f8b1d",
      "object": "call",
      "livemode": true,
      "direction": "outbound",
      "placement": "webrtc",
      "origin": "app",
      "status": "completed",
      "to_number": "+442071234567",
      "from_number": "+14155550142",
      "counterparty_name": "London office",
      "started_at": "2026-09-28T09:15:02Z",
      "answered_at": null,
      "ended_at": "2026-09-28T09:20:14Z",
      "duration_seconds": 312,
      "rate_per_minute": "0.0297",
      "currency": "USD",
      "cost": "0.18",
      "is_recorded": false,
      "transcript_status": null,
      "session": null
    }
  ],
  "has_more": true
}
  • cost is the recorded charge and is null unless status is completed.
  • rate_per_minute is the rate fixed at call setup and charged for this call, which can differ from what /v1/rates quotes today.
  • counterparty_name is filled only when the token also has contacts:read, and only when a contact's stored number exactly matches the other party; otherwise null.
  • answered_at is not recorded for app calls today, so it is null. session is for callback calls, which are not live yet, so it is null; placement is webrtc or inbound and origin is app for every call today.
  • status values: initiating, connecting, active, completed, failed, no-answer, busy, canceled. New values may appear.

One call by id adds three fields. While the call is active:

Terminal
curl -s https://api.voixcall.com/v1/calls/call_3c7e1a9b5d2f4e6a8c0b1d3f5a7e9c2b \
  -H "Authorization: Bearer $TOKEN"
Response (200 OK)
{
  "id": "call_3c7e1a9b5d2f4e6a8c0b1d3f5a7e9c2b",
  "object": "call",
  "livemode": true,
  "direction": "outbound",
  "placement": "webrtc",
  "origin": "app",
  "status": "active",
  "to_number": "+18005550199",
  "from_number": "+14155550142",
  "counterparty_name": null,
  "started_at": "2026-09-28T10:02:40Z",
  "answered_at": null,
  "ended_at": null,
  "duration_seconds": null,
  "rate_per_minute": "0.0270",
  "currency": "USD",
  "cost": null,
  "is_recorded": false,
  "transcript_status": null,
  "session": null,
  "cost_so_far": "0.05",
  "max_duration_seconds": null,
  "transcript": null
}
  • cost_so_far is set only while status is active (null before, including initiating and connecting, and after). It counts from started_at, so it includes ring time.
  • max_duration_seconds is the time limit the balance allowed at setup, only while active and only with credits:read. It is not recorded for calls placed from the app today, so it is null for them.
  • transcript is null unless you pass expand[]=transcript, which returns the call's redacted summary-level transcript (or null when it has none). It needs transcripts:read (else 403 insufficient_scope) and the user's “Allow connected apps to read call summaries” switch in Settings → Connected apps (else 403 not_enabled with settings_url).
  • An unknown id, or a call on another account, is 404 call_not_found.

Contacts

q is required, 2 to 64 characters, and matches name, company and number fragments; a q that is itself a number (spaces, dashes and brackets ignored) matches the digits of stored numbers. Outside that length it is 400 invalid_query. There is no endpoint that lists every contact.

Terminal
curl -s "https://api.voixcall.com/v1/contacts?q=Rahul" \
  -H "Authorization: Bearer $TOKEN"
Response (200 OK)
{
  "object": "list",
  "data": [
    {
      "id": "ct_2a4c6e8f0b1d3f5a7c9e1b3d5f7a9c0e",
      "object": "contact",
      "livemode": true,
      "name": "Rahul Mehta",
      "company": "Mehta Exports",
      "phone_number": "+919812345678",
      "created_at": "2026-08-14T06:41:09Z"
    }
  ],
  "has_more": false
}

Numbers and caller ID

Terminal
curl -s https://api.voixcall.com/v1/numbers \
  -H "Authorization: Bearer $TOKEN"
Response (200 OK)
{
  "object": "list",
  "data": [
    {
      "id": "num_7d9f1b3e5a7c4e2d8b0f6a1c3e5d7b9f",
      "object": "number",
      "livemode": true,
      "phone_number": "+14155550142",
      "country_code": "US",
      "status": "active",
      "renewal_at": "2026-10-21T00:00:00Z",
      "capabilities": ["voice", "sms"],
      "messages_opt_in": false
    }
  ],
  "has_more": false
}

status is active, grace_period (renewal payment failed; the number is kept until renewal_at) or pending_deactivation. messages_opt_in is always false today: reading messages through the API ships later, behind a per-number switch.

Terminal
curl -s https://api.voixcall.com/v1/caller-id \
  -H "Authorization: Bearer $TOKEN"
Response (200 OK)
{
  "object": "caller_id",
  "livemode": true,
  "current": "+14155550142",
  "options": [
    {
      "phone_number": "+447700900123",
      "type": "verified",
      "display_name": "Verified Personal Number",
      "status": "verified",
      "country_code": null,
      "is_active": true
    },
    {
      "phone_number": "+14155550142",
      "type": "virtual",
      "display_name": "Virtual DID (US)",
      "status": "active",
      "country_code": "US",
      "is_active": true
    }
  ],
  "verified_numbers": [
    { "phone_number": "+447700900123", "verified_at": "2026-07-02T11:08:31Z" }
  ]
}

current is null when calls go out with the platform default. options is every number the account may present, with is_active saying whether it can be used right now.

Lists and pagination

Lists are {"object": "list", "data": [...], "has_more": …}, newest first, with no totals. limit is 1 to 100 (default 10). Pass the last id you received as starting_after to get older objects, or the first id as ending_before to get newer ones, never both. A list response is capped at 32 KB, so a page can hold fewer than limit items with has_more: true: keep paging from the last id until has_more is false, and never treat a short page as the end.

Terminal: the next page
curl -s "https://api.voixcall.com/v1/calls?limit=1&starting_after=call_8f2d4b6a1c3e4f5a9b7d0e2c4a6f8b1d" \
  -H "Authorization: Bearer $TOKEN"

Node and Python

Plain HTTP with the standard library of each language; nothing to install.

Node (save as calls.mjs)
// Node 18+ (built-in fetch), ES module: save as calls.mjs and run: node calls.mjs
// Pages through every call from September 2026.
const TOKEN = process.env.VOIXCALL_TOKEN; // vcat_… access token or vc_live_… key

async function* allCalls() {
  let after = null;
  for (;;) {
    const url = new URL("https://api.voixcall.com/v1/calls");
    url.searchParams.set("limit", "100");
    url.searchParams.set("started_at[gte]", "2026-09-01T00:00:00Z");
    url.searchParams.set("started_at[lte]", "2026-09-30T23:59:59Z");
    if (after) url.searchParams.set("starting_after", after);

    const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
    const body = await res.json();
    if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);

    yield* body.data;
    if (!body.has_more || body.data.length === 0) return;
    after = body.data[body.data.length - 1].id;
  }
}

let total = 0;
for await (const call of allCalls()) {
  if (call.cost !== null) total += Number(call.cost); // display only; keep money as strings in storage
}
console.log(`Completed calls in September: $${total.toFixed(2)}`);
Python
# Python 3.9+, standard library only.
import json, os, urllib.error, urllib.parse, urllib.request
from decimal import Decimal

TOKEN = os.environ["VOIXCALL_TOKEN"]  # vcat_… access token or vc_live_… key

def get(path, **params):
    url = "https://api.voixcall.com/v1" + path
    if params:
        url += "?" + urllib.parse.urlencode(params)
    req = urllib.request.Request(url, headers={"Authorization": f"Bearer {TOKEN}"})
    try:
        with urllib.request.urlopen(req) as res:
            return json.load(res)
    except urllib.error.HTTPError as e:
        err = json.load(e)["error"]
        raise RuntimeError(f"{err['code']}: {err['message']} ({err['request_id']})") from None

rate = get("/rates", to="+442071234567")     # urlencode sends + as %2B
balance = get("/credits/balance")
minutes = Decimal(balance["amount"]) / Decimal(rate["rate_per_minute"])
print(f"{rate['country_name']}: {rate['rate_per_minute']} {rate['currency']}/min, "
      f"about {int(minutes)} minutes on {balance['amount']} {balance['currency']}")

Prefer a typed client? See the TypeScript SDK below.

TypeScript SDK

@voixcall/sdk is the official TypeScript client, generated from the OpenAPI document. It is an ES module with bundled types and no runtime dependencies, and runs on Node 20 or later and in modern browsers (keep API keys on the server).

Terminal
npm install @voixcall/sdk
TypeScript or Node (save as calls.mjs)
// Node 20+, ES module: save as calls.mjs and run: node calls.mjs
import { VoixCall, RateLimitError, VoixCallError } from "@voixcall/sdk";

// An OAuth access token (vcat_…). With a personal API key, pass { apiKey: "vc_live_…" } instead.
const voixcall = new VoixCall({ accessToken: process.env.VOIXCALL_TOKEN });

try {
  const me = await voixcall.me.get();
  const balance = await voixcall.credits.balance();
  console.log(me.email, balance.amount, balance.currency); // "12.50" "USD"

  const rate = await voixcall.rates.get({ to: "+447700900123" });
  console.log(rate.rate_per_minute); // "0.0130"

  // Every call from September 2026; pages are fetched as you iterate.
  for await (const call of voixcall.calls.list({
    started_at_gte: "2026-09-01T00:00:00Z",
    started_at_lte: "2026-09-30T23:59:59Z",
    limit: 100,
  })) {
    console.log(call.id, call.to_number, call.cost); // cost is a decimal string, or null
  }
} catch (err) {
  if (err instanceof RateLimitError) console.error(`rate limited, retry in ${err.retryAfter}s`);
  else if (err instanceof VoixCallError) console.error(err.code, err.message, err.requestId);
  else throw err;
}
  • Authentication: pass exactly one of accessToken or apiKey, as a string or a function that returns one (sync or async), so a refreshed token is picked up.
  • Resources: me.get(), rates.get({ to }), credits.balance(), credits.transactions.list(), calls.list(), calls.get(id), contacts.search({ q }), numbers.list() and callerId.get().
  • Pagination: await a list for one page, or for await it to walk every page; .toArray(n) collects up to n objects and .pages() iterates page by page. Short pages from the 32 KB cap are handled.
  • Retries: 429 and 503 are retried (twice by default, maxRetries), waiting for Retry-After when the server sends it; 502, 504, network errors and timeouts are retried for GET only.
  • Idempotency: every POST gets an Idempotency-Key automatically, reused across retries, or pass your own with { idempotencyKey }.
  • Typed errors: every failure is a subclass of VoixCallError (AuthenticationError, PermissionError, NotFoundError, RateLimitError with retryAfter, InvalidRequestError, APIError, APIConnectionError and so on) carrying type, code, message, status, requestId and docUrl.
  • Money is a string: amounts ("12.50") and rates ("0.0130") stay decimal strings with a currency, never floats.
  • Personal account only: like the REST API, the SDK reads the personal account of the user the token or key belongs to; organization data is never reachable.

The SDK sends the VoixCall-Version it was built for (2026-10-01), so a new API version does not change its behaviour until you upgrade. Full reference in the package README on npm.

Test keys

Test API keys (vc_test_) read a separate test partition, never live data: the balance is a test balance seeded at 10.00 USD, calls, transactions, contacts and numbers are empty lists, caller ID has no numbers, and /v1/rates returns a fixed 0.0200 per minute in 60-second increments for any valid number except the magic numbers +15005550001 (invalid_destination) and +15005550010 (destination_blocked). Every object has livemode: false. There is no self-serve way to create a key yet (see below).

API keys

Personal API keys are sent the same way, as a bearer token: Authorization: Bearer vc_live_…. Keys work on /v1 only and are rejected at /mcp; test keys (vc_test_) additionally see only test-mode data (see test keys). Keys carry a name, a subset of scopes, an expiry (90 days by default, one year at most) and an optional IP allowlist.

coming The management endpoints exist, but the screen to create a key in Settings has not shipped yet, so there is no self-serve way to obtain one today. Use OAuth until it does; the changelog will say when.

Terminal
curl -s https://api.voixcall.com/v1/me \
  -H "Authorization: Bearer vc_live_…"

What a failure looks like

Every error is the same JSON envelope. Every response carries Request-Id; /v1 responses carry VoixCall-Version; rate-limited responses carry the RateLimit headers.

Terminal
curl -s https://api.voixcall.com/v1/me
# HTTP 401, WWW-Authenticate: Bearer error="invalid_token", resource_metadata="https://api.voixcall.com/.well-known/oauth-protected-resource/v1"
{
  "error": {
    "type": "authentication_error",
    "code": "invalid_token",
    "message": "Provide a bearer access token.",
    "param": null,
    "doc_url": "https://voixcall.com/developers/errors#invalid_token",
    "request_id": "req_Fa3rR6ZW8v3AM31xlv9CYQ"
  }
}

The error catalogue lists every code with its status and what to do; rate limits explains the headers. Quote the request_id when you write to support@voixcall.com.