Skip to content

Connecting

REST API

The complete v1 API reference. Everything is JSON, errors share one shape, and money is always an integer in hundredths of a koruna - the Hal suffix on every price field - so no rounding decisions are left to you.

Authentication

Every request carries an API key in the Authorization: Bearer vk_... header. Get your key in the portal, under API and MCP (more detail in the Quickstart).

bash
curl https://volai.cz/v1/balance \
  -H "Authorization: Bearer vk_YOUR_KEY"

Error format

A failure always has the same shape, no matter what broke:

json
{
  "error": {
    "code": "insufficient_credit",
    "message": "Insufficient credit. Account balance is 3.00 CZK. Top up at volai.cz/en/credit and try again."
  }
}

These four can show up almost anywhere - the endpoint reference below only lists situational codes on top of them:

HTTPCodeMeaning
401unauthorizedThe Authorization header is missing or invalid.
402insufficient_creditNot enough credit left for this action.
429rate_limitedRate limit exceeded - wait as long as the Retry-After header says.
500internal_errorAn error on our side - please try again.

400 (invalid input) and 404 (record not found) can also show up almost anywhere, but their code is specific to the field or endpoint - the exact list is always with the individual call below. A few actions can also return 502 (an error from an external provider - the phone network, ElevenLabs) or 503 (temporarily unavailable, try again shortly).

Idempotency

POST /v1/messages, POST /v1/calls, POST /v1/relay, POST /v1/numbers, POST /v1/agents, POST /v1/tools, and POST /v1/agents/{id}/test-call accept an Idempotency-Key header. Send the same value when retrying a request (typically after a timeout, when you don't know if the first attempt went through) and within 24 hours you get back exactly the same response as the first time - nothing gets sent or called twice. Feel free to use your own app's order ID.

bash
curl -X POST https://volai.cz/v1/messages \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: objednavka-4471" \
  -d '{"to": "+420777123456", "body": "Thanks for your order."}'

Rate limits

60 requests per minute per API key (MCP shares the same limit - it uses the same key). Going over returns 429 and a Retry-After header with the number of seconds until the next attempt.

POST /v1/messages has its own extra cap on top of that: at most one SMS every 2 seconds, and 100 SMS per account per day.

Outbound calls (POST /v1/calls, relay, and test calls) share their own cap: 10 calls per minute and 200 per day per account, with at most 2 built-in-agent calls running at once.

Pagination

GET /v1/messages and GET /v1/calls take limit (max records to return - 50 by default, capped at 200) and before (a millisecond timestamp - only returns older records). For the next page, send the timestamp of the last record from the previous page as before.

What the API can't do

You control the whole phone side through API and MCP - numbers, calls, SMS, agents, webhooks, relay, and the do-not-call list. The account around it, though, stays in the portal, and that's by design, not an oversight. So your agent doesn't waste a call looking for an endpoint that isn't there:

WhatWhere it is, and why
Adding creditPortal /credit (one-off top-ups and auto-recharge alike). Paying by card is a human step - an agent that tops up its own credit so it can keep calling is exactly what we don't want. When an action hits insufficient_credit, tell the user and send them here.
API keysPortal /api-and-mcp. A key minting another key would mean one leaked key grants permanent access that can never be revoked.
Account detailsPortal /settings - name, billing address, password.
Tax invoicesPortal /credit, Invoices section - a downloadable PDF is waiting there for every credit top-up. The API can't issue or download an invoice: it's an accounting document with its own sequential numbering that never changes once issued and must never be created twice.
Billing detailsPortal /settings - company name, Company ID, and VAT ID. These get printed on invoices issued AFTERWARD; an invoice already issued isn't amended retroactively, so this data can't be rewritten programmatically.
Inbound SMSNowhere - volai doesn't accept it today. More detail under the Messages section below.

Going the other way, exactly one thing is missing: releasing a number you bought (DELETE /v1/numbers/{e164}) exists in both the REST API and the portal, but not among the MCP tools - an irreversible action is one misread sentence away from being too risky there.

Credit

balanceHal and every rate in this API are exclusive of VAT - volai is VAT-registered, but VAT is only applied when you top up credit by card in the portal (see the /pricing page), the API itself doesn't factor it in.

GET/v1/balance

The account's current credit balance.

Request

bash
curl https://volai.cz/v1/balance \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "balanceHal": 8730,
  "balanceCzk": 87.30,
  "currency": "CZK"
}

Numbers

GET/v1/numbers

The phone numbers on the account, each with its current routing.

Request

bash
curl https://volai.cz/v1/numbers \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "numbers": [
    {
      "e164": "+420601234567",
      "routing": { "mode": "agent", "agentId": "ag_kx91fa2b" },
      "monthlyFeeHal": 2500,
      "boughtAt": 1756111640000
    }
  ]
}

POST/v1/numbers

Buys a number. An empty body {} assigns any free number from the current listing, or send a specific { "e164": "..." } from GET /v1/numbers/available. Pick a region with { "region": "brno" } - allowed values are praha, brno, internet; a number from a different region is ordered through POST /v1/numbers/orders. This charges a monthly fee of 25.00 CZK (~EUR 1.04), and routing starts at none - set it right away with PATCH.

Request

bash
curl -X POST https://volai.cz/v1/numbers \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

Response

json
{
  "number": {
    "e164": "+420601234567",
    "routing": { "mode": "none" },
    "monthlyFeeHal": 2500,
    "boughtAt": 1756111640000
  }
}

Error codes

  • 402insufficient_creditYour credit doesn't cover the monthly fee of 25.00 CZK (~EUR 1.04).
  • 503pool_emptyWe're out of numbers in the current listing - order one from another region (POST /v1/numbers/orders), or join the waitlist in the portal.

GET/v1/numbers/available

The listing of numbers available to buy (max 5 per region). numbers holds the numbers from the selected region (Prague if you skip the parameter), regions always returns the whole listing at once.

Request

bash
curl "https://volai.cz/v1/numbers/available?region=brno" \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "region": "brno",
  "numbers": ["+420510510129", "+420510510132"],
  "regions": [
    {
      "id": "praha",
      "label": "Prague",
      "description": "A landline with a Prague area code.",
      "numbers": ["+420266266643", "+420266266645"]
    },
    {
      "id": "brno",
      "label": "Brno",
      "description": "A landline with a Brno area code.",
      "numbers": ["+420510510129", "+420510510132"]
    },
    {
      "id": "internet",
      "label": "Internet number",
      "description": "Area code 910, not tied to any region - works from anywhere.",
      "numbers": ["+420910084012"]
    }
  ]
}

GET/v1/numbers/address-options

Addresses for ordering a number from a region outside the listing. This walks the carrier's cascading directory: send psc and get municipalities, add obec and get districts, cobce returns streets, and ulice returns building numbers. Once cp is picked, a readable address arrives in the recap field. Don't invent codes - only the ones returned here are valid. A faster path that skips the cascade is query, see the callout below.

Request

bash
curl "https://volai.cz/v1/numbers/address-options?psc=70200&obec=554821&cobce=413950" \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "obce": [{ "value": "554821", "label": "Ostrava" }],
  "casti": [{ "value": "413950", "label": "Moravská Ostrava" }],
  "ulice": [{ "value": "353710", "label": "28. října" }],
  "cp": [],
  "recap": null
}

Error codes

  • 400validationquery is longer than 200 characters, doesn't include a postal code, or the carrier has no matching option for the level it resolved to.
  • 502provisioning_failedAddresses couldn't be loaded from the carrier at all.
  • 503orders_disabledOrders are temporarily paused.

A faster path: the whole address in one query

Instead of the cascade, send query with the whole address (e.g. "Nadrazni 100, 702 00 Ostrava") - the server drives the cascade for you. Only the postal code is read directly out of the text; everything else is just matched against the options the carrier returned for that level - the server never invents a code.

bash
curl -G https://volai.cz/v1/numbers/address-options \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  --data-urlencode "query=Nádražní 100, 702 00 Ostrava"
json
{
  "obce": [],
  "casti": [],
  "ulice": [],
  "cp": [],
  "recap": "28. října 102/1, Ostrava, 70200",
  "selection": {
    "psc": "70200",
    "obec": "554821",
    "cobce": "413950",
    "ulice": "353710",
    "cp": "3180026"
  }
}

When the address is ambiguous, only the level where that becomes clear is returned (ambiguousLevel: obec/cobce/ulice/cp) with options to choose from in the matching field, for example:

json
{
  "obce": [
    { "value": "554821", "label": "Ostrava" },
    { "value": "554813", "label": "Fulnek" }
  ],
  "casti": [],
  "ulice": [],
  "cp": [],
  "recap": null,
  "selection": { "psc": "70200", "obec": "", "cobce": "", "ulice": "", "cp": "" },
  "ambiguousLevel": "obec",
  "message": "The given address is ambiguous - choose the municipality (obec) from the options. Repeat the query WITHOUT query and with the step parameters: psc=70200, obec=<value from the options>."
}

selection always carries what was already resolved - continue with the same step parameter (psc/obec/cobce/ulice/cp) you would use without query. If the address can't be resolved at all, or the time budget runs out mid-lookup, a readable message arrives in the message field instead of recap, along with the selection resolved so far - continue from there with the step parameters.

query and the step parameters DO NOT MIX in one request - send both together and the endpoint returns validation. So after an ambiguous response, don't repeat the same query with an added obec (or another level) from the options - send the next request WITHOUT query, using only step parameters. The exact shape (including values already resolved) is spelled out right in the message field of that ambiguous response.

POST/v1/numbers/orders

Orders a number from a region outside the listing (Ostrava, Plzeň, Budějovice...). Address codes must come from GET /v1/numbers/address-options. The endpoint waits while we set up an emergency-services address with the carrier and buy the number - usually under a minute; the finished number then comes back in the e164 field with status done. If it doesn't work out on the first try, it returns the order with status pending and a cron job finishes it. The 25.00 CZK (~EUR 1.04) fee is only charged once the number is ready.

Request

bash
curl -X POST https://volai.cz/v1/numbers/orders \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"psc":"70200","obec":"554821","cobce":"413950","ulice":"353710","cp":"3180026"}'

Response

json
{
  "order": {
    "id": "Xa3Kd9pQmR2v",
    "status": "pending",
    "address": "PSČ 70200",
    "createdAt": 1756111640000,
    "updatedAt": 1756111640000
  }
}

Error codes

  • 400incomplete_addresspsc, obec, cobce or cp is missing (ulice is optional - not every address has one).
  • 402insufficient_creditYour credit doesn't cover the monthly fee of 25.00 CZK (~EUR 1.04).
  • 503orders_disabledOrders are temporarily paused.

GET/v1/numbers/orders

Order status: pending (queued), provisioning (being set up right now), done (the number is in the e164 field and belongs to the account), failed (the reason is in the error field, nothing was charged).

Request

bash
curl https://volai.cz/v1/numbers/orders \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "orders": [
    {
      "id": "Xa3Kd9pQmR2v",
      "status": "done",
      "address": "28. října 102/1, Ostrava, 70200",
      "e164": "+420596123456",
      "createdAt": 1756111640000,
      "updatedAt": 1756118840000
    }
  ]
}

DELETE/v1/numbers/{e164}

Releases the number back into the pool - disconnects routing and any agent registration. The month already paid for is not refunded.

Request

bash
curl -X DELETE "https://volai.cz/v1/numbers/+420601234567" \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "released": true
}

Error codes

  • 404not_foundThe number doesn't exist or doesn't belong to your account.

PATCH/v1/numbers/{e164}

Changes the number's routing.

  • mode: "agent" + agentId - the voice agent picks up calls.
  • mode: "forward" + forwardTo (E.164) - call forwarding - you pay for both legs.
  • mode: "sip" + sipUri - routes to your own SIP server (see SIP).
  • mode: "none" - the number just receives calls without routing them anywhere.

Request

bash
curl -X PATCH "https://volai.cz/v1/numbers/+420601234567" \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"routing": {"mode": "agent", "agentId": "ag_kx91fa2b"}}'

Response

json
{
  "number": {
    "e164": "+420601234567",
    "routing": { "mode": "agent", "agentId": "ag_kx91fa2b" },
    "monthlyFeeHal": 2500,
    "boughtAt": 1756111640000
  }
}

Error codes

  • 400validationrouting.mode requires the matching field (agentId / forwardTo / sipUri).
  • 404not_foundThe number doesn't exist or doesn't belong to your account.

GET/v1/numbers/{e164}/sip

The number's SIP credentials - for your own softphone or PBX. Step-by-step setup is on the SIP.

Request

bash
curl "https://volai.cz/v1/numbers/+420601234567/sip" \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "server": "sip.volai.cz",
  "username": "123456",
  "password": "a1b2c3d4e5f6"
}

Error codes

  • 404not_foundThe number doesn't exist or doesn't belong to your account.

Messages

Inbound SMS never reaches the API

Carriers deliver inbound SMS only to the SIM card, never to the API - so a reply to your message never shows up in GET /v1/messages or in a webhook. Don't rely on two-way SMS conversation yet.

GET/v1/messages

The history of sent SMS, newest first.

Request

bash
curl "https://volai.cz/v1/messages?limit=20" \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "messages": [
    {
      "id": "msg_7c1f9a2e",
      "to": "+420777123456",
      "from": "volai",
      "body": "Ahoj z volai! - Moje Appka",
      "status": "sent",
      "priceHal": 136,
      "segments": 1,
      "createdAt": 1756111500000,
      "source": "api"
    }
  ]
}

POST/v1/messages

Sends an SMS. Czech and Slovak numbers only (+420 / +421). Price is 1.36 CZK (~EUR 0.06) per segment - longer messages (or ones with characters outside the GSM-7 alphabet, typically Czech diacritics) split into more segments, each billed separately - see segments in the response.

The from field is always fixed ("volai") - carriers don't allow a custom SMS sender name. Put your own identity directly in the message text instead, like in the example below.

  • Without diacritics (the GSM-7 alphabet), one segment holds 160 characters; longer text splits into chunks of 153.
  • With diacritics or any character outside GSM-7 (UCS-2), the limit is 70 characters per segment; longer text splits into chunks of 67.
  • Tip: to fit in a single segment, write without diacritics - Prilis zlutoucky kun instead of Příliš žluťoučký kůň.

Request

bash
curl -X POST https://volai.cz/v1/messages \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "+420777123456", "body": "Ahoj z volai! - Moje Appka"}'

Response

json
{
  "id": "msg_7c1f9a2e",
  "status": "sent",
  "priceHal": 136,
  "segments": 1
}

Error codes

  • 400invalid_numberThat's not a valid Czech or Slovak phone number.
  • 400invalid_bodyThe message text must be 1 to 765 characters.
  • 400unsupported_countryNumber outside CZ/SK - not supported in the MVP.
  • 400recipient_cannot_receive_smsLandline number - the SMS wouldn't arrive. We don't charge for it.
  • 402insufficient_creditYour credit doesn't cover the cost of all the message's segments.
  • 429rate_limitedMore than 1 SMS every 2 seconds, or over 100 SMS on the account today.
  • 502send_failedThe phone network rejected the request. We didn't charge your credit - please try again.
  • 502send_unknownWe handed the message to the network, but no delivery confirmation arrived - it's still billed, so please don't resend it blindly.

A message's status: pending (stored, still sending), sent (the carrier confirmed delivery), failed (the carrier rejected it - not billed, priceHal 0), and unknown (no delivery confirmation arrived, typically a timeout on the carrier's side) - it's still billed at full price, since the message may have gone through. If you get unknown, please don't resend it blindly - contact support and we'll refund any duplicate charge.

Example: a message with diacritics over 70 characters means more segments

This message is 112 characters and contains diacritics, so it's counted as UCS-2 (a 67-character limit per segment for multi-part messages) - it comes out to 2 segments, so 2.72 CZK (~EUR 0.11), not 1.36 CZK (~EUR 0.06):

bash
curl -X POST https://volai.cz/v1/messages \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "+420777123456", "body": "Příliš žluťoučký kůň úpěl ďábelské ódy - a tahle věta má přes sedmdesát znaků, takže spadne do druhého segmentu."}'
json
{
  "id": "msg_9a3f1c7d",
  "status": "sent",
  "priceHal": 272,
  "segments": 2
}

GET/v1/messages/{id}

The detail of a single message.

Request

bash
curl https://volai.cz/v1/messages/msg_7c1f9a2e \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "message": {
    "id": "msg_7c1f9a2e",
    "to": "+420777123456",
    "from": "volai",
    "body": "Ahoj z volai! - Moje Appka",
    "status": "sent",
    "priceHal": 136,
    "segments": 1,
    "createdAt": 1756111500000,
    "source": "api"
  }
}

Error codes

  • 404not_foundThe message doesn't exist or doesn't belong to your account.

Calls

GET/v1/calls

The call history, newest first. The optional direction parameter (in or out) limits the list to inbound or outbound calls only.

Request

bash
curl "https://volai.cz/v1/calls?limit=20" \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "calls": [
    {
      "id": "c_8f2ac1d4",
      "direction": "in",
      "from": "+420777123456",
      "to": "+420601234567",
      "status": "completed",
      "startedAt": 1756111400000,
      "durationSecs": 47,
      "priceHal": 236,
      "agentId": "ag_kx91fa2b",
      "source": "inbound",
      "kind": "inbound",
      "answeredBy": "human",
      "endReason": "completed",
      "hasRecording": true,
      "data": {
        "name": "Jane Smith",
        "coffee_count": 2,
        "urgency": "normal"
      }
    }
  ]
}

POST/v1/calls

Starts an outbound call to to. Send exactly ONE of: agentId (your voice agent runs it according to its systemPrompt), from (a direct connection between two numbers, no agent - a bridge, see below), or systemPrompt (a trial call with no number of your own - no purchase or agent setup needed, see below). Never more than one, never none. With the agentId variant you can also send variables (custom values for the prompt) and ringingTimeoutSecs (how long to let it ring, see the callout below).

Request

bash
curl -X POST https://volai.cz/v1/calls \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+420777123456",
    "agentId": "ag_kx91fa2b",
    "variables": { "jmeno_zakaznika": "Jana", "cislo_objednavky": "A-42" },
    "ringingTimeoutSecs": 30
  }'

Response

json
{
  "id": "c_9d4e2b7f",
  "status": "initiated"
}

Error codes

  • 400invalid_numberto is not a valid phone number.
  • 400validationMissing, or more than one, of agentId/from/systemPrompt; with agentId, variables is out of limits (max 20 keys, 64-character keys, 512-character values) or ringingTimeoutSecs is outside the 5-60 range; with systemPrompt, it's missing/over 1200 characters, firstMessage is over 300 characters, or ringingTimeoutSecs is outside the 5-30 range.
  • 400on_dncThat number is on your do-not-call list (/v1/dnc).
  • 400destination_auto_blockedThe destination was auto-blocked for 30 days after repeated failures.
  • 400cannot_call_own_numberLoop protection - you can't call your own volai number.
  • 400cannot_call_volai_numberLoop protection - the destination is another account's volai number.
  • 404bridge_needs_numberA bridge (from) needs at least one of your own volai numbers on the account.
  • 409destination_busyYou already have another call running to the same number.
  • 404agent_not_foundagentId doesn't exist or doesn't belong to your account.
  • 404agent_no_numberThe agent has no phone number assigned.
  • 402insufficient_creditYour credit doesn't cover the minimum to start a call.
  • 503capacity_busyAll outbound lines are busy right now, try again in a minute.
  • 503trial_calls_disabledWith systemPrompt: trial calls are temporarily disabled by an operational switch - call through your own agent (agentId) instead.
  • 403trial_email_unverifiedWith systemPrompt: only for accounts with a verified email.
  • 500trial_not_configuredWith systemPrompt: the shared volai demo agent isn't configured correctly - a temporary error on our side.
  • 502call_rejectedWith systemPrompt: the voice platform rejected the connection outright, nothing was charged.
  • 429rate_limitedWith systemPrompt: the daily cap of 3 trial calls per account is used up, or the shared cap across all accounts is temporarily saturated - calling with agentId doesn't have this limit.

Custom variables and ringing time

variables is a string-to-string object the agent receives as dynamic values - reference them in the prompt as {{jmeno_zakaznika}}. Limits: at most 20 keys, keys up to 64 characters, values up to 512 characters. A key may only contain letters, digits and underscores, and can't start with a digit. The names attempt_id, caller_number, called_number, trial_prompt, and trial_first_message are reserved - we fill those in ourselves, and any values you send under those names are discarded.

ringingTimeoutSecs is 5 to 60 seconds, default 25. Once it elapses, an unanswered call ends and its detail gets endReason: "no_answer". A longer ring means a better chance the customer picks up, but also a longer-held outbound slot.

Without an agent: bridging two numbers directly

Send from instead of agentId - your own number, or another number belonging to the customer. volai calls from first, and once someone picks up, it dials to. Both legs are billed separately at 0.92 CZK/min (~EUR 0.04).

bash
curl -X POST https://volai.cz/v1/calls \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "+420777123456", "from": "+420601234567"}'

A trial call with no number of your own

Instead of agentId/from, send systemPrompt (instructions for the agent, for this one call only, up to 1200 characters) and optionally firstMessage (the opening line, up to 300 characters). The call goes out from volai's shared demo number, not your own - so the response also carries trial: true and from (the demo number). You can't send voiceId for this variant, it runs on the default voice. ringingTimeoutSecs has a lower cap of 5 to 30 (default 25) - it holds a slot from the shared pool.

bash
curl -X POST https://volai.cz/v1/calls \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+420777123456",
    "systemPrompt": "You are the front desk at cafe Nula. You take pickup orders and keep it brief and friendly.",
    "firstMessage": "Hello, this is the volai trial agent, how can I help you?"
  }'
json
{
  "id": "c_2f7a91mn",
  "status": "initiated",
  "trial": true,
  "from": "+420266266641"
}

Only for accounts with a verified email, at most 3 calls per account per 24 hours (plus a shared cap across all accounts, so a surge doesn't overwhelm the demo number). Billed exactly like a normal call, including the agent surcharge - no discount. Anyone who needs more buys their own number (POST /v1/numbers) and creates an agent (POST /v1/agents) - that cap doesn't apply there.

If trial calls are temporarily switched off (an operational toggle), systemPrompt returns a readable error (trial_calls_disabled) instead of placing the call; use your own agent (agentId) in the meantime.

GET/v1/calls/{id}

The detail of a call. Optionally, waitSecs (see the callout below) makes the server wait for the result instead of you polling. For calls with an agent, it also includes transcript (a turn-by-turn transcript) and summary (a short summary) - see Webhooks for the same shape delivered automatically once the call ends. Both the list and the detail also carry answeredBy (human, voicemail, or unknown when the transcript is missing), endReason (completed, no_answer, busy, rejected, capacity, blocked, loop_guard, credit_blocked, failed), and kind (agent, bridge, relay, inbound, transfer) - see the table below. The tools-and-recordings wave added two more fields: data (what the agent captured from the call per its dataFields; a null value means it wasn't mentioned) and hasRecording (the call has a recording available for download, see the endpoint below). Older calls don't have these fields and they're missing from the response.

Request

bash
curl https://volai.cz/v1/calls/c_8f2ac1d4 \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "call": {
    "id": "c_8f2ac1d4",
    "direction": "in",
    "from": "+420777123456",
    "to": "+420601234567",
    "status": "completed",
    "startedAt": 1756111400000,
    "durationSecs": 47,
    "priceHal": 236,
    "agentId": "ag_kx91fa2b",
    "source": "inbound",
    "kind": "inbound",
    "answeredBy": "human",
    "endReason": "completed",
    "hasRecording": true,
    "data": {
      "name": "Jane Smith",
      "coffee_count": 2,
      "urgency": "normal"
    },
    "transcript": [
      { "role": "agent", "message": "Hi, this is cafe Nula, how can I help you?" },
      { "role": "caller", "message": "I would like to order two lattes to go." },
      { "role": "agent", "message": "Sure, two lattes for pickup, they will be ready in fifteen minutes." }
    ],
    "summary": "The caller ordered two lattes to go, pickup in 15 minutes."
  }
}

Error codes

  • 404call_not_foundThe call doesn't exist or doesn't belong to your account.
  • 400validationwaitSecs is not an integer from 0 to 45.

Wait for the result instead of polling (waitSecs)

Right after POST /v1/calls, call GET /v1/calls/{id} with waitSecs (0 to 45, default 0) - the server responds once the call reaches a terminal state (completed/failed/missed/no_answer), returning transcript and summary right away, or once waitSecs elapses, whichever happens first.

bash
curl "https://volai.cz/v1/calls/c_9d4e2b7f?waitSecs=30" \
  -H "Authorization: Bearer vk_YOUR_KEY"
json
{
  "call": {
    "id": "c_9d4e2b7f",
    "direction": "out",
    "from": "+420601234567",
    "to": "+420777123456",
    "status": "ringing",
    "startedAt": 1756111400000,
    "agentId": "ag_kx91fa2b",
    "source": "api"
  },
  "stillRunning": true
}

If the time runs out before the call ends, stillRunning is true and the fields carry the current (non-terminal) state - it never errors. Try GET (or the MCP get_call) again, with a higher waitSecs if you like.

GET/v1/calls/{id}/recording

The call's recording. Unlike the rest of the API, this response is not JSON - the body is an MP3 (content-type: audio/mpeg, 128 kbps, 16 kHz mono). We don't keep a copy of the recording ourselves: the stream flows from the voice platform straight through us, and we only verify the call belongs to your account.

?stahnout=1 switches content-disposition to attachment (the browser saves the file instead of playing it). This route doesn't offer ranges (Range) - it's meant for machine integrations that download the whole file. Only calls handled by an agent with recordCalls turned on are recorded, and we keep them for 90 days after the call.

Request

bash
curl https://volai.cz/v1/calls/c_8f2ac1d4/recording \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -o call-c_8f2ac1d4.mp3

Error codes

  • 404call_not_foundThe call doesn't exist or doesn't belong to your account.
  • 404no_recordingThe call has no recording - it wasn't handled by an agent, or recording was turned off.
  • 410recording_expiredThere was a recording, but 90 days have passed and it's been deleted. The transcript remains.

Call status: the status field

status is the only field that changes over time on a call. It can still move during the first five minutes after the call starts; after that it's final - four of these states are terminal.

statusMeaning
initiatedThe call has been placed but hasn't started ringing yet.
ringingIt's ringing at the called party.
in_progressThe call is in progress.
completedTerminal. A connected call that ended normally - for the vast majority expect durationSecs > 0 (exception: a call with endReason: credit_blocked below has both a duration and a price of 0).
no_answerTerminal. An outbound call rang and the called party didn't pick up.
missedTerminal. An inbound call went unanswered.
failedTerminal. The call never connected at all - the platform rejected it or the network failed. endReason gives the specific reason.

Call type: the kind field

kind says how a call came to exist - and therefore how it's billed. It's the only field that lets you tell a bring-your-own (BYO) agent's call apart from a built-in agent's call on an invoice, since relay legs don't carry the agent surcharge.

kindWhat it isBilling
agentAn outbound call from the built-in voice agent.0.92 CZK/min + 2.50 CZK/min
bridgeA direct connection between two numbers, no agent.0.92 CZK/min (~EUR 0.04) for each of the two legs
relayAn outbound call from your own agent through POST /v1/relay.0.92 CZK/min (~EUR 0.04), no agent surcharge
inboundAn inbound call to your number.0.50 CZK/min (+ agent, if one handled it)
transferThe second leg created by transferring an inbound call to a human. The agent leaves the call after the transfer, so this leg is billed as a regular outbound call.0.92 CZK/min, no agent surcharge

Why a call ended: the endReason field

We add endReason to every closed call. Watch out: a call blocked for unpaid debt has status completed, but endReason is credit_blocked and the price is 0 - status alone won't tell it apart from a call that actually went through. The three "unanswered" variants are deliberately distinct, not one shared state.

endReasonMeaning
completedThe call went through and ended normally.
no_answerIt rang and the callee didn't pick up.
busyA busy signal - this is NOT the same as not picking up.
rejectedThe platform rejected the connection outright.
capacityThe outbound lines (relay pool) were full.
blockedThe destination is on the do-not-call list or in the automatic 30-day block (see Do-not-call list below).
loop_guardProtection against calling your own or another account's volai number.
credit_blockedAn inbound call to an agent whose owner is over the debt limit - the agent hangs up right after its opening line, price 0.
failedThe call never connected at all (platform or network).

Agents

GET/v1/agents

The voice agents on the account.

Request

bash
curl https://volai.cz/v1/agents \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "agents": [
    {
      "id": "ag_kx91fa2b",
      "name": "Front desk",
      "systemPrompt": "You are the front desk at cafe Nula. You take pickup orders...",
      "firstMessage": "Hi, this is cafe Nula, how can I help you?",
      "language": "en",
      "voiceId": "MpbYQvoTmXjHkaxtLiSh",
      "numberE164": "+420601234567",
      "createdAt": 1756111000000,
      "status": "active",
      "toolIds": ["tl_5c2a91f4"],
      "transferTo": "+420777123456",
      "transferCondition": "The caller explicitly asks to be connected with a human.",
      "dataFields": [
        {
          "key": "name",
          "type": "string",
          "description": "The caller's name, as they stated it."
        }
      ],
      "recordCalls": true
    }
  ]
}

POST/v1/agents

Creates a new voice agent. Only name and systemPrompt are required - how to write a good prompt is covered on the Voice agent page. If you send numberE164, the agent is attached to that number right away (replacing whatever routing it had). voiceId picks the voice - the valid values (currently just one) come from GET /v1/voices, see the Voices section below; without voiceId the agent inherits the template's voice. It also optionally takes four fields from the tools-and-recordings wave: toolIds, transferTo with transferCondition, dataFields, and recordCalls - each is described in the table below the endpoints.

Request

bash
curl -X POST https://volai.cz/v1/agents \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Front desk",
    "systemPrompt": "You are the front desk at cafe Nula. You take pickup orders and answer questions about opening hours. Never make up prices you do not know. End the call by summarising the order.",
    "firstMessage": "Hi, this is cafe Nula, how can I help you?",
    "language": "en",
    "numberE164": "+420601234567",
    "toolIds": ["tl_5c2a91f4"],
    "transferTo": "+420777123456",
    "transferCondition": "The caller wants to speak with staff, or has a complaint.",
    "dataFields": [
      {"key": "name", "type": "string", "description": "The caller's name, as they stated it."},
      {"key": "coffee_count", "type": "number", "description": "How many coffees the caller ordered."}
    ],
    "recordCalls": true
  }'

Response

json
{
  "id": "ag_kx91fa2b"
}

Error codes

  • 400invalid_nameThe agent's name must be 1 to 60 characters.
  • 400invalid_system_promptThe agent's instructions must be 10 to 6000 characters.
  • 400invalid_languagelanguage must be cs, sk, en, de or pl.
  • 400agent_limitThe account is already at its maximum number of agents (10).
  • 400tool_limittoolIds has more than 10 tools.
  • 400invalid_data_fieldsdataFields has an invalid key, type, description, or more than 10 entries.
  • 400transfer_looptransferTo points at a volai number on the same account - the call would loop back on itself.
  • 400blocked_destinationtransferTo is a premium-rate line with special pricing.
  • 400invalid_numbertransferTo is not a valid phone number.
  • 404tool_not_foundA tool from toolIds doesn't exist or doesn't belong to your account.
  • 404not_foundnumberE164 doesn't exist or doesn't belong to your account.
  • 400validationvoiceId doesn't match any entry from GET /v1/voices and doesn't look like a raw ElevenLabs ID - the message lists the valid values.

GET/v1/agents/{id}

The detail of a single agent - the same fields as in the list, just for one. A deleted agent behaves as if it never existed: 404 agent_not_found.

Request

bash
curl https://volai.cz/v1/agents/ag_kx91fa2b \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "agent": {
    "id": "ag_kx91fa2b",
    "name": "Front desk",
    "systemPrompt": "You are the front desk at cafe Nula. You take pickup orders...",
    "firstMessage": "Hi, this is cafe Nula, how can I help you?",
    "language": "en",
    "voiceId": "MpbYQvoTmXjHkaxtLiSh",
    "numberE164": "+420601234567",
    "createdAt": 1756111000000,
    "status": "active",
    "toolIds": ["tl_5c2a91f4"],
    "transferTo": "+420777123456",
    "transferCondition": "The caller explicitly asks to be connected with a human.",
    "dataFields": [
      {
        "key": "name",
        "type": "string",
        "description": "The caller's name, as they stated it."
      },
      {
        "key": "urgency",
        "type": "string",
        "description": "How urgent the request was.",
        "enumValues": ["low","normal","high"]
      }
    ],
    "recordCalls": true
  }
}

Error codes

  • 404agent_not_foundThe agent doesn't exist, was deleted, or doesn't belong to your account.

PATCH/v1/agents/{id}

Updates an existing agent - the same fields as on creation, all of them optional. Send only what needs to change. Two of them, though, are always sent IN FULL, because they replace the existing list: toolIds and dataFields. An empty array therefore turns the feature off ("toolIds": [] removes all of the agent's tools), whereas a missing key means "leave it as is". Handoff is turned off with an empty string: "transferTo": "".

Request

bash
curl -X PATCH https://volai.cz/v1/agents/ag_kx91fa2b \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"firstMessage": "Hi, cafe Nula, what can I get you?"}'

Response

json
{
  "agent": {
    "id": "ag_kx91fa2b",
    "name": "Front desk",
    "systemPrompt": "You are the front desk at cafe Nula. You take pickup orders...",
    "firstMessage": "Hi, cafe Nula, what can I get you?",
    "language": "en",
    "voiceId": "MpbYQvoTmXjHkaxtLiSh",
    "numberE164": "+420601234567",
    "createdAt": 1756111000000,
    "status": "active",
    "toolIds": ["tl_5c2a91f4"],
    "transferTo": "+420777123456",
    "transferCondition": "The caller explicitly asks to be connected with a human.",
    "recordCalls": true
  }
}

Error codes

  • 400invalid_nameThe agent's name must be 1 to 60 characters.
  • 400invalid_system_promptThe agent's instructions must be 10 to 6000 characters.
  • 400invalid_languagelanguage must be cs, sk, en, de or pl.
  • 400tool_limittoolIds has more than 10 tools.
  • 400invalid_data_fieldsdataFields has an invalid key, type, description, or more than 10 entries.
  • 400transfer_looptransferTo points at a volai number on the same account.
  • 400blocked_destinationtransferTo is a premium-rate line with special pricing.
  • 404tool_not_foundA tool from toolIds doesn't exist or doesn't belong to your account.
  • 404agent_not_foundThe agent doesn't exist or doesn't belong to your account.
  • 400validationvoiceId doesn't match any entry from GET /v1/voices and doesn't look like a raw ElevenLabs ID - the message lists the valid values.

DELETE/v1/agents/{id}

Deletes the agent from both volai and ElevenLabs. A number that was attached to it stays yours - just give it new routing through PATCH /v1/numbers/{e164}.

Request

bash
curl -X DELETE https://volai.cz/v1/agents/ag_kx91fa2b \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "deleted": true
}

Error codes

  • 404agent_not_foundThe agent doesn't exist or doesn't belong to your account.

New agent fields

Besides the name, prompt, voice, and number, POST /v1/agents and PATCH /v1/agents/{id} also take these fields. All are optional, and an agent created earlier simply doesn't have them - the key is then missing from the response too.

FieldWhat it does
toolIdsAn array of ids of webhook tools from GET /v1/tools that the agent may call during a call. At most 10. A tool belongs to the account, so more than one agent can have it turned on.
transferToA number in E.164 that the agent transfers the caller to when they ask for a human. An empty string turns handoff off. The account's own volai number can't be used here (transfer_loop).
transferConditionWhen to transfer, written as an instruction to the model (up to 500 characters). If you don't send it, we substitute a default sentence.
dataFieldsWhat the agent should capture from the call: an array of {key, type, description, enumValues} objects, at most 10. key is lowercase letters, digits and underscores; type is string, number, or boolean; enumValues can only be set for a text field. Filled-in values then arrive in the data field on the call.
recordCallsWhether to record this agent's calls. On by default; when false, no recording is made and GET /v1/calls/{id}/recording returns no_recording. Informing the caller about recording is your responsibility.

Handoff: what the API actually does

The transfer runs over SIP REFER. The agent tells the caller it's transferring them, then leaves the call - the person on the other end hears the caller directly. We can't pass along a call summary, there's no such thing as a "warm transfer" here.

The second leg is a regular outbound call, and it's billed as one: 0.92 CZK/min (~EUR 0.04) with no agent surcharge. You can spot it in the call list by kind: "transfer".

Voices

The voice catalog for the voiceId field on POST /v1/agents and PATCH /v1/agents/{id}. It currently holds a single entry - the default voice every new agent inherits from the template; more will be added over time. You can send voiceId either the id from the GET /v1/voices response below, or a raw ElevenLabs ID (for backward compatibility with agents created before the catalog existed). An unrecognized value returns 400 validation and lists the valid values. The same catalog, with a description of each voice, is on the Voice agent.

GET/v1/voices

The catalog of voices available for an agent's voiceId.

Request

bash
curl https://volai.cz/v1/voices \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "voices": [
    {
      "id": "anet",
      "name": "Anet",
      "gender": "female",
      "description": "Young female voice, professional delivery - the voice the template currently uses, which every new agent inherits without its own voiceId setting.",
      "isDefault": true
    }
  ]
}

The gender field is missing from an entry when the voice's gender isn't known (as with the default entry above) - it will appear once it's certain; we don't guess the value.

Agent tools

A tool is the address of your API that the agent calls in the middle of a call (verify an order, log a booking) and uses the response in speech. A tool belongs to the account and is assigned to an agent via toolIds - creating a tool by itself doesn't change any agent.

Limits: at most 10 tools per account, 10 parameters and 5 headers per tool, timeoutSecs 5 to 30 (20 by default). The address must be https and public - we reject internal networks and loopback (private_address), since otherwise our server could be used to probe someone else's infrastructure.

GET/v1/tools

The account's webhook tools. Header values are always masked.

Request

bash
curl https://volai.cz/v1/tools \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "tools": [
    {
      "id": "tl_5c2a91f4",
      "name": "verify_order",
      "label": "Verify order",
      "description": "Looks up order status by number. Use when the caller asks where their order is.",
      "url": "https://api.yourapp.com/orders",
      "method": "POST",
      "headers": { "Authorization": "Bear***" },
      "params": [
        {
          "name": "order_number",
          "type": "string",
          "source": "llm",
          "description": "The order number the caller dictated.",
          "required": true
        },
        { "name": "phone", "type": "string", "source": "caller_number" }
      ],
      "timeoutSecs": 20,
      "createdAt": 1756111000000,
      "updatedAt": 1756111000000
    }
  ]
}

POST/v1/tools

Creates a tool. label is the readable name; the name the model sees is derived from it automatically (Verify order -> verify_order) and returned in the name field. description is the only thing the model bases its decision of WHEN to use the tool on - write it as an instruction ("Use when the caller asks where their order is."), 10 to 1000 characters.

Request

bash
curl -X POST https://volai.cz/v1/tools \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Verify order",
    "description": "Looks up order status by number. Use when the caller asks where their order is.",
    "url": "https://api.yourapp.com/orders",
    "method": "POST",
    "headers": { "Authorization": "Bearer your_key" },
    "params": [
      {
        "name": "order_number",
        "type": "string",
        "source": "llm",
        "description": "The order number the caller dictated.",
        "required": true
      },
      { "name": "phone", "type": "string", "source": "caller_number" },
      { "name": "source", "type": "string", "source": "constant", "constantValue": "phone" }
    ],
    "timeoutSecs": 20
  }'

Response

json
{
  "tool": {
    "id": "tl_5c2a91f4",
    "name": "verify_order",
    "label": "Verify order",
    "url": "https://api.yourapp.com/orders",
    "method": "POST",
    "headers": { "Authorization": "Bear***" },
    "timeoutSecs": 20,
    "createdAt": 1756111000000,
    "updatedAt": 1756111000000
  }
}

Error codes

  • 400tool_limitThe account already has 10 tools.
  • 400invalid_tool_namelabel must be 1 to 60 characters.
  • 400invalid_tool_urlurl is not a valid address, or isn't https.
  • 400private_addressurl points into an internal network or loopback.
  • 400invalid_tool_paramsA parameter has an invalid name, type, source, or there are more than 10 of them.
  • 400invalid_tool_headersA header is forbidden (host, content-length, x-conversation-id, x-caller-id), duplicated, empty, or there are more than 5 of them.
  • 400validationdescription is outside 10 to 1000 characters, timeoutSecs is outside 5 to 30, or method is something other than GET/POST.
  • 502eleven_labs_errorThe tool couldn't be created with the voice platform - we didn't save anything locally, please try again.

GET/v1/tools/{id}

The detail of a single tool - the same fields as in the list.

Request

bash
curl https://volai.cz/v1/tools/tl_5c2a91f4 \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "tool": {
    "id": "tl_5c2a91f4",
    "name": "verify_order",
    "label": "Verify order",
    "url": "https://api.yourapp.com/orders",
    "method": "POST",
    "headers": { "Authorization": "Bear***" },
    "timeoutSecs": 20,
    "createdAt": 1756111000000,
    "updatedAt": 1756111000000
  }
}

Error codes

  • 404tool_not_foundThe tool doesn't exist or doesn't belong to your account.

PATCH/v1/tools/{id}

Updates a tool - all fields optional, only the ones sent are changed. Two things to watch for: params and headers both replace the entire existing list, and changing label also rewrites the name the model knows the tool by - if you reference it in systemPrompt, update that at the same time.

Headers only ever come back masked ("Bear***"). If you send that exact masked value back, we treat it as "keep the original" - so the usual "load the tool, change one field, send the whole object back" flow won't destroy your credentials. Send the real value in full to actually change it.

Request

bash
curl -X PATCH https://volai.cz/v1/tools/tl_5c2a91f4 \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"timeoutSecs": 10}'

Response

json
{
  "tool": {
    "id": "tl_5c2a91f4",
    "name": "verify_order",
    "label": "Verify order",
    "url": "https://api.yourapp.com/orders",
    "method": "POST",
    "headers": { "Authorization": "Bear***" },
    "timeoutSecs": 20,
    "createdAt": 1756111000000,
    "updatedAt": 1756111000000
  }
}

Error codes

  • 400invalid_tool_urlurl is not a valid address, or isn't https.
  • 400private_addressurl points into an internal network or loopback.
  • 400invalid_tool_paramsA parameter has an invalid name, type, or source.
  • 400invalid_tool_headersA header is forbidden, duplicated, or empty.
  • 404tool_not_foundThe tool doesn't exist or doesn't belong to your account.
  • 502eleven_labs_mismatchThe change wasn't saved exactly as specified with the voice platform - we didn't overwrite anything locally.

DELETE/v1/tools/{id}

Deletes a tool and returns the agents it stops working for (just id and name; fetch the full agent via GET /v1/agents/{id}). Unlike releasing a number, this is a recoverable loss - the same tool can be created again, just with a new id that then needs to be assigned to the agents again.

Request

bash
curl -X DELETE https://volai.cz/v1/tools/tl_5c2a91f4 \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "deleted": true,
  "agents": [
    { "id": "ag_kx91fa2b", "name": "Front desk" }
  ]
}

Error codes

  • 404tool_not_foundThe tool doesn't exist or doesn't belong to your account.
  • 502eleven_labs_errorThe tool couldn't be deleted from the voice platform - please try again.

A tool's parameters: the source field

Every parameter has a name, a type (string, number, boolean), and a source - where its value comes from. For GET, parameters go into the query string; for POST, into the JSON body.

sourceWhere the value comes fromWhat else to fill in
llmThe model pulls it from the call.description (exactly what to put there) and optionally required.
caller_numberThe caller's number in E.164, filled in automatically.Nothing. The model never sees this field.
called_numberYour called number in E.164, filled in automatically.Nothing. The model never sees this field.
constantA fixed value that you provide.constantValue.

Test call

"Have your agent call you" - a real outbound call through makeAgentCall, just with its own daily cap of 3 calls per account (across every agent), so a test call can't be used as a back door around the normal calling limit.

POST/v1/agents/{id}/test-call

Calls you back from your own agent - the same price and the same rules as POST /v1/calls, no discount and no special rate.

Request

bash
curl -X POST https://volai.cz/v1/agents/ag_kx91fa2b/test-call \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "+420777123456"}'

Response

json
{
  "id": "c_9d4e2b7f",
  "status": "initiated"
}

Error codes

  • 404agent_not_foundagentId doesn't exist or doesn't belong to your account.
  • 404agent_no_numberThe agent has no phone number assigned.
  • 402insufficient_creditYour credit doesn't cover the minimum to start a call.
  • 503capacity_busyAll outbound lines are busy right now, try again in a minute.
  • 429rate_limitedThe daily cap of 3 test calls per account is used up - a regular call through POST /v1/calls doesn't have this limit.

Relay - bring your own agent

A one-off rental of a SIP name from the relay pool, for an outbound call from your own voice agent (ElevenLabs or any other platform) - with no agent surcharge (that's normally 2.50 CZK/min (~EUR 0.10), but here you pay for it on your own platform). The full setup guide for ElevenLabs is on the Bring your own agent.

POST/v1/relay

Creates a lease - to is the destination number, from is your own volai number the call should appear to come from. The returned sipName is the BARE name for ElevenLabs's to_number (not sipUri - ElevenLabs rejects a full SIP URI).

ttlSecs (optional, default 120) - how many seconds the lease waits for the first call before an unused one expires. The allowed range is 15 to 300 seconds - outside it, this returns 400 validation.

Request

bash
curl -X POST https://volai.cz/v1/relay \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "+420777123456", "from": "+420601234567"}'

Response

json
{
  "id": "rl_4f2a91cd",
  "sipName": "volai_relay_2",
  "sipUri": "sip:volai_relay_2@sip.volai.cz",
  "expiresAt": 1756111760000,
  "callId": "c_9d4e2b7f"
}

Error codes

  • 400invalid_numberto or from is not a valid phone number.
  • 400validationttlSecs is outside the allowed range of 15 to 300 seconds.
  • 404from_number_not_ownedfrom doesn't belong to your account.
  • 400on_dncto is on your do-not-call list.
  • 400relay_lease_limitYou already have an active lease on this number, or two across the whole account.
  • 402insufficient_creditYour credit doesn't cover the minimum to start a call.
  • 409destination_busyAnother call is already running to that number.
  • 503capacity_busyThe relay pool is full right now, try again shortly - we don't charge you for this.

GET/v1/relay

The list of your relay leases - pending is waiting for its first call, active means a call is running right now.

Request

bash
curl https://volai.cz/v1/relay \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "leases": [
    {
      "id": "rl_4f2a91cd",
      "sipName": "volai_relay_2",
      "sipUri": "sip:volai_relay_2@sip.volai.cz",
      "from": "+420601234567",
      "to": "+420777123456",
      "callId": "c_9d4e2b7f",
      "status": "active",
      "createdAt": 1756111640000,
      "expiresAt": 1756111760000
    }
  ]
}

DELETE/v1/relay/{id}

Releases the lease and returns the slot to the pool, as long as it's still waiting for its first call (pending). It does not end a call in progress - no API can do that today (not the phone network, not ElevenLabs) - a lease like that (active) just runs its course; this only stops that same lease from being used again.

Request

bash
curl -X DELETE https://volai.cz/v1/relay/rl_4f2a91cd \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "cancelled": true
}

Error codes

  • 404lease_not_foundThe lease doesn't exist, has expired, or doesn't belong to your account.

Do-not-call list (DNC)

Numbers your agent (your own or the built-in one) must not call - whether through POST /v1/calls, a test call, or relay. DNC only applies to calls - sending an SMS to a listed number is not restricted.

Besides the manual list, volai automatically (and temporarily) blocks a destination on its own - after 3 failed attempts within 24 hours (busy, unanswered, rejected) it stops calling that number for 30 days. The block can't be lifted manually; it expires on its own.

GET/v1/dnc

The numbers on the do-not-call list.

Request

bash
curl https://volai.cz/v1/dnc \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "numbers": ["+420777998877"]
}

POST/v1/dnc

Adds a number to the do-not-call list.

Request

bash
curl -X POST https://volai.cz/v1/dnc \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"e164": "+420777998877"}'

Response

json
{
  "added": true,
  "e164": "+420777998877"
}

Error codes

  • 400validatione164 is not a valid phone number.

DELETE/v1/dnc/{e164}

Removes a number from the do-not-call list.

Request

bash
curl -X DELETE "https://volai.cz/v1/dnc/+420777998877" \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "removed": true,
  "e164": "+420777998877"
}

Error codes

  • 400validatione164 in the URL is not a valid phone number.

Webhook

GET/v1/webhook

The current outbound webhook settings - without the signing secret, which only comes back from PUT.

Request

bash
curl https://volai.cz/v1/webhook \
  -H "Authorization: Bearer vk_YOUR_KEY"

Response

json
{
  "url": "https://tvoje-appka.cz/webhooks/volai",
  "events": ["call.completed", "call.failed", "message.sent"]
}

PUT/v1/webhook

Sets (or replaces) the target URL and the events you subscribe to. The response also includes the signing secret - save it right away, GET won't return it again later. An empty events: [] means subscribe to every event, not none - including ones added later. If you only want some events, list them. Conversely, an empty url: "" cancels the webhook - volai then sends nothing, and the response returns url: null. How to verify the signature is covered on the Webhooks.

Request

bash
curl -X PUT https://volai.cz/v1/webhook \
  -H "Authorization: Bearer vk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://tvoje-appka.cz/webhooks/volai", "events": ["call.completed", "call.failed", "message.sent"]}'

Response

json
{
  "url": "https://tvoje-appka.cz/webhooks/volai",
  "secret": "whsec_9f2b7a1c4e6d8f0a",
  "events": ["call.completed", "call.failed", "message.sent"]
}

Error codes

  • 400invalid_urlThe URL must start with https:// (http:// is only allowed for localhost).
  • 400invalid_eventsOne of the events isn't recognized (call.completed, call.failed, call.missed, call.no_answer, message.sent).