API documentation
The Voza API uses HTTPS, JSON and the usual HTTP verbs. Message, call and webhook bodies follow the format of the official WhatsApp API (Meta), so the same code works with both kinds of Voza number.
Base URL:
https://www.vozaapi.com/v1
| QR code number | Official number (Meta) | |
|---|---|---|
| How it connects | Scanning a QR code with the phone, like on WhatsApp Web | With the credentials of your Meta app (WhatsApp Cloud API) |
| WhatsApp account | Regular WhatsApp or WhatsApp Business; the phone keeps working | WhatsApp Business Platform, in your Meta account |
| Messages | Text and media | Text, media, templates, interactive messages, stickers, location, contacts and reactions |
| 24-hour window | None | Outside it, only templates approved by Meta |
| Calls | Yes, several at the same time on the same number | Yes, when Meta enables calls for the number |
| Conversation charges | None besides the plan | Meta charges your Meta account |
| Risk | WhatsApp may block numbers that send spam | Follows Meta's policies and quality ratings |
Quick start
- Create an account and, in Dashboard › API keys, a key.
- Create a number:
curl https://www.vozaapi.com/v1/numbers \ -H "Authorization: Bearer vz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{"name": "Downtown Store", "webhook_url": "https://your-system.com/webhooks/whatsapp"}'
- Open the response's
qr_urland scan the QR code with WhatsApp on the phone (Linked devices › Link a device). Or connect an official number (see Official numbers). - Send the first message:
curl https://www.vozaapi.com/v1/numbers/NUMBER_ID/messages \ -H "Authorization: Bearer vz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{"to": "5511987654321", "type": "text", "text": {"body": "Hello! 👋"}}'
- The contact's replies reach your webhook, in Meta's format. Done: the rest of this page is detail.
Authentication
Create a key in Dashboard › API keys and send it in every request, in the Authorization header:
Authorization: Bearer vz_SUA_CHAVE
- Use the key only on your server. Never put it in the browser or in your customer's app.
- The key appears only once, when it is created. If you lose it, revoke it and create another.
- A revoked key stops working immediately.
- The web phone uses another token, short-lived and for a single number (
wp_…): see Web phone.
Errors
All errors have the same body. The code is stable and lets your system decide what to do; the message is for people and may change.
The message comes in the language asked for in the Accept-Language header or the ?lang= parameter: pt-BR, en or es (any other language gets English). Without one, it comes in the language of whoever created the account.
{
"error": {
"code": "invalid_request",
"message": "The to field is required.",
"details": { "to": ["The to field is required."] }
}
}
When the failure comes from Meta, on an official number, the error also has meta: Meta's error as Meta gave it, with code (131047 outside the 24-hour window, 190 invalid token, 1380xx for calls…), subcode, type, details and fbtrace_id. When it comes from the engine of QR code numbers, meta.reason says why: not_on_whatsapp, lines_busy, lines_starting, restarting, not_linked, not_connected, timeout…
{
"error": {
"code": "whatsapp_refused",
"message": "Meta did not accept the request: Message failed to send because more than 24 hours have passed.",
"meta": {
"code": 131047,
"subcode": null,
"type": "OAuthException",
"details": "Message failed to send because more than 24 hours have passed since the customer last replied to this number.",
"fbtrace_id": "AbCdEf123"
}
}
}
| Status | Code | When it happens |
|---|---|---|
| 401 | invalid_api_key | The key is missing, invalid or revoked. |
| 401 | invalid_webphone_token | The web phone token is missing, invalid or expired. |
| 403 | trial_expired | The free trial has ended: subscribe to a plan in the dashboard. |
| 403 | subscription_inactive | The subscription ended (canceled or unpaid): subscribe again in the dashboard. |
| 403 | account_suspended | The account is suspended. |
| 403 | number_limit_reached | The account already has all of the plan's numbers. |
| 404 | not_found | The address or the resource does not exist (or belongs to another account). |
| 404 | call_not_found | The call does not exist or has already ended. |
| 405 | method_not_allowed | The address exists, but not with this HTTP method. The Allow header lists the ones that work. |
| 409 | number_not_connected | The QR code number is not connected: scan the QR code before sending or calling. |
| 409 | meta_token_invalid | Meta refused the official number's token (it expired, was revoked or lacks the permissions). Send a new one to PUT /v1/numbers/{id}/meta. |
| 409 | not_a_qr_number | The request only applies to QR code numbers. |
| 409 | not_an_official_number | The request only applies to official numbers. |
| 409 | send_in_progress | Another send with the same Idempotency-Key is still in progress. |
| 409 | action_in_progress | Another call action with the same Idempotency-Key is still in progress. |
| 409 | call_answered_elsewhere | Another webphone, the voice agent or your system has already answered this call. |
| 409 | call_not_ready | The call is still being prepared; try again in a moment. |
| 413 | media_too_large | The media received by a QR code number was over 64 MB and was not stored. |
| 422 | invalid_request | The data sent is invalid. The details field lists each field's errors. |
| 422 | unsupported_type | The message type only exists for official numbers (template, interactive…). |
| 422 | meta_refused | Meta refused the credentials when connecting an official number; the message says why. |
| 422 | whatsapp_refused | Meta refused an official number's message (template that does not exist, invalid parameter…). |
| 422 | not_on_whatsapp | The number called is not on WhatsApp. |
| 422 | call_failed | Meta did not accept the call action of an official number (no permission from the contact, for example). |
| 429 | rate_limited | The account's request limit (wait for the seconds in Retry-After) or Meta's sending limit on an official number (send more slowly). |
| 429 | channel_limit_reached | The account is already at the plan's limit of simultaneous calls. |
| 429 | lines_busy | All of the number's lines are on a call. |
| 4xx | http_error | Another HTTP error, such as a body that is too large (413). The status says which. |
| 500 | server_error | Internal error. Try again; if the error continues, contact us. |
| 502 | whatsapp_refused | WhatsApp did not accept the message from a QR code number (the destination is not on WhatsApp, for example). |
| 502 | call_failed | WhatsApp did not accept the call action of a QR code number. |
| 502 | media_unavailable | WhatsApp did not deliver the file of media received by a QR code number. |
| 503 | number_reconnecting | The QR code number is reconnecting to WhatsApp. Try again in a moment. |
| 503 | engine_unavailable | The service that keeps the QR code numbers is unavailable. Try again in a moment. |
| 503 | meta_unavailable | The request did not reach Meta (no connection) or Meta answered that it is unavailable: nothing was done. Try again in a moment. |
| 503 | lines_starting | The QR code number's call lines are starting. Try again in a few seconds. |
| 504 | send_timeout | The send took too long. Repeat it with the same Idempotency-Key. |
| 504 | meta_timeout | Meta did not answer in time after receiving the request (a send or a call action): it may or may not have been carried out. Repeat it with the same Idempotency-Key. |
| 504 | call_timeout | The service that keeps the QR code numbers took too long to answer a call action. |
A 503 usually clears in moments: try again, waiting a little longer each time. To find out whether Voza is down, see Voza status.
Limits
- Each account can make up to 600 requests per minute, across all of its keys, or the limit agreed with Voza for the account:
GET /v1/accountshows yours inlimits.requests_per_minute. - Responses include
X-RateLimit-LimitandX-RateLimit-Remaining. Above the limit, the API responds429withRetry-After. - Each web phone token can make up to 240 requests per minute.
- Each account has up to 20 API keys.
- The plan sets how many numbers the account connects and how many calls happen at the same time. Messages and minutes are unlimited at Voza.
- Official numbers are also subject to the sending limits and the quality rating Meta gives each number.
Idempotency
When sending a message, send the Idempotency-Key header (up to 128 letters, digits and _ . : -). Repeating the send with the same key returns the first response instead of sending again. Use it whenever your system retries a send that failed or got no response.
Idempotency-Key: order-4521-delivery-notice
Voza keeps the first response for 24 hours, for both kinds of number. A repeated send while the first is still in progress waits for it for up to 30 seconds; after that, the response is 409 send_in_progress, and you just try again with the same key.
Call actions (POST /v1/numbers/{id}/calls) take the same header: repeating with the same key returns the first answer instead of repeating the action (a connect does not call twice). One repeated while the first is still in progress gets 409 action_in_progress.
On an official number, 503 meta_unavailable means the request did not reach Meta, and 504 meta_timeout that it did, but Meta did not answer in time: the message may have been sent (or the action carried out). In both cases, repeat it with the same Idempotency-Key: an error is not kept, so the repetition goes to Meta; Meta has no idempotency of its own, and after a meta_timeout the contact may get the message twice.
QR code numbers
A QR code number is a regular WhatsApp or WhatsApp Business connected as a device, like on WhatsApp Web. The phone keeps working normally.
The number object
{
"id": "01jbk3m8x2c9q4r7t6v5w0y1z3",
"name": "Downtown Store",
"external_ref": "canal-981",
"type": "qr",
"status": "connected",
"status_reason": null,
"phone": "5511987654321",
"qr_url": "https://www.vozaapi.com/qr/…",
"webhook_url": "https://your-system.com/webhooks/whatsapp",
"agent_mode": "off",
"connected_at": "2026-10-22T15:04:05Z",
"created_at": "2026-10-22T15:01:10Z"
}
| Field | Description |
|---|---|
external_ref | Your own reference for the number (up to 100 characters), such as the channel's id in your system. It comes back in the webhooks, in metadata.external_ref, so you find the channel without storing Voza's id. |
type | qr (QR code) or meta (official). |
status_reason | Why the number is in its current status (see Status); empty when there is nothing to explain. |
phone | The number's digits, with the country code, once connected. |
qr_url | A public page with the QR code, to send to whoever has the phone. It follows the connection on its own. |
agent_mode | When the number's voice agent answers: off, always or fallback (see Voice agents). |
connected_at | When the number connected, in UTC (ISO 8601). |
Status
| Status | Meaning |
|---|---|
starting | The connection is starting; the QR code comes next. |
qr | Waiting for the QR code to be scanned. |
connected | Connected: the number sends and receives messages and calls. |
failed | Refused: by WhatsApp (a blocked number, for example; use relink) or, for an official number, by Meta (see meta.error). |
If someone disconnects Voza on the phone (Linked devices), the number goes back to starting and then to qr, with a new QR code. Each change reaches the webhook as number.status.
status_reason says why the number is in its current status, and a change of the reason alone also reaches the webhook:
status_reason | Meaning |
|---|---|
logged_out | The phone removed the device (or the number was relinked): the QR code must be scanned again. |
restarting | The engine is reconnecting the number, which is still linked to the phone: wait, without doing anything. |
failed | WhatsApp refused the device. |
meta_refused | Meta refused the official number's token or the number (see meta.error). |
In the other cases (qr, connected, a new number) the reason is empty.
Create a number
{
"name": "Downtown Store",
"external_ref": "canal-981",
"webhook": { "url": "https://your-system.com/webhooks/whatsapp" }
}
Responds 201 with the number in starting. external_ref and webhook are optional (webhook_url is also accepted, instead of webhook.url). With a webhook in the request, the response also carries the secret that signs the events, so you can check the very first one:
{
"id": "01jbk3m8x2c9q4r7t6v5w0y1z3",
"status": "starting",
…
"webhook": { "url": "https://your-system.com/webhooks/whatsapp", "secret": "whsec_…" }
}
The webhook address may name the number: {id} becomes the number's id at Voza and {external_ref} your reference (URL-encoded; the request must have external_ref). The webhook is set before the number connects, so no event is lost, with no PUT afterwards.
{ "name": "Downtown Store", "external_ref": "canal-981", "webhook": { "url": "https://your-system.com/webhooks/voza/{external_ref}?numero={id}" } }
Above the plan's limit, it responds 403 with number_limit_reached.
Show the QR code
{
"status": "qr",
"qr": "data:image/png;base64,iVBORw0KGgo…",
"qr_url": "https://www.vozaapi.com/qr/…"
}
qr is a PNG image ready for an <img src>. The QR code changes every 20 seconds: check again every 3 to 5 seconds until the status is connected (then qr comes empty). Or send the qr_url to whoever has the phone.
List, get and delete
All of the account's numbers, in {"data": [...]}. With ?external_ref=canal-981, only those with that reference.
The number, with its status read on the spot.
Changes the number's name or external_ref ("external_ref": null removes it) and responds with the number.
Disconnects the phone and deletes the number. Responds 204.
Connect another phone
Disconnects the current phone and starts again, with a new QR code. Use it to change phones or to recover a refused number.
Official numbers
An official number is a WhatsApp Cloud API number, in your Meta account. Voza uses your app's credentials: messages and calls go through the Meta API, and reach your webhook in the same format as QR code numbers.
Before connecting, at Meta
- Have a Meta app with the WhatsApp product and a phone number in the WhatsApp Business account (WABA).
- In WhatsApp › Configuration, subscribe to the
messagesandcallsfields of the app's webhook (with any webhook address: Voza points only this number at itself, with a per-number override, and your app's other events stay where they are). - Create a permanent token for a system user, with the
whatsapp_business_messagingandwhatsapp_business_managementpermissions. - Write down the phone number ID and the WhatsApp Business account ID (WhatsApp › API Setup), and the app secret (App settings › Basic).
Connect
{
"name": "Official support",
"type": "meta",
"webhook_url": "https://your-system.com/webhooks/whatsapp",
"meta": {
"phone_number_id": "109876543210987",
"waba_id": "102030405060708",
"access_token": "EAAG…",
"app_secret": "0123456789abcdef0123456789abcdef"
},
"calling": { "call_icon_visibility": "DISABLE_ALL" }
}
Voza checks the number and the token at Meta, subscribes your app to the account, points the number's messages and calls webhooks at Voza and turns calls on. If everything works, it responds 201 with the number already connected:
{
"id": "01jbk4…",
"type": "meta",
"status": "connected",
"phone": "5511912345678",
"qr_url": null,
"meta": {
"phone_number_id": "109876543210987",
"waba_id": "102030405060708",
"display_phone_number": "+55 11 91234-5678",
"verified_name": "Your Company",
"quality_rating": "GREEN",
"calling": "enabled",
"error": null
}
}
- If Meta refuses the credentials, the response is
422 meta_refusedwith the reason, and nothing is stored. calling(optional) holds the calling settings the number starts with (see Calling settings); whatever is missing takes Voza's default: calling on, the call button visible and the call-back request on."calling": "keep"registers the number without touching its calling settings at Meta (for a number that already takes calls the way you set it up). Voza only reads them, to showmeta.calling.- In the answer,
meta.callingisenabled,disabled(turned off in the calling settings) orunavailable(Meta does not allow calls on the number; messages work just the same). - The token and the app secret are stored encrypted and never appear in responses.
- Voza checks the number at Meta at most once a minute when you get it, and checks every official number every hour. A refused token (Meta's code 190) or a number Meta no longer has, in that check, in a message send or in a call action, takes the number to
failedwithstatus_reason: meta_refused(the reason inmeta.error), and the webhook getsnumber.status. When Meta takes the number again, it goes back toconnected.
Change the token
{ "access_token": "EAAG…new", "app_secret": "(only if it changed)" }
Stores the new credentials and connects the number again. POST /v1/numbers/{id}/relink only checks again, with the current credentials. DELETE gives the number's webhooks back to your app and takes it out of Voza (the number stays in your Meta account).
Neither a new token nor a relink touches the calling settings: Voza only sets them when the number is created.
Calling settings
The calling object of the number's settings at Meta, read and changed as at Meta. With PUT, only the fields sent change, and the answer is {"success": true}.
{
"calling": {
"status": "ENABLED",
"call_icon_visibility": "DEFAULT",
"callback_permission_status": "ENABLED",
"call_hours": {
"status": "ENABLED",
"timezone_id": "America/Sao_Paulo",
"weekly_operating_hours": [{ "day_of_week": "MONDAY", "open_time": "0900", "close_time": "1800" }]
}
}
}
| Field | Description |
|---|---|
status | ENABLED or DISABLED: the number's calls. |
call_icon_visibility | DEFAULT or DISABLE_ALL: the call button in the chat. |
callback_permission_status | ENABLED or DISABLED: ask a contact who called for permission to call back. |
call_hours, call_icons | The business hours and the countries that see the button, as at Meta, which checks them. |
sip is left out: calls go through Voza. On a QR code number, the answer is 409 not_an_official_number.
Sending messages
The body is the same as in the official WhatsApp API. The number must be connected.
curl https://www.vozaapi.com/v1/numbers/NUMBER_ID/messages \ -H "Authorization: Bearer vz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-4521" \ -d '{ "messaging_product": "whatsapp", "to": "5511987654321", "type": "text", "text": { "body": "Your order is out for delivery!" } }'
{
"messaging_product": "whatsapp",
"contacts": [{ "input": "5511987654321", "wa_id": "5511987654321" }],
"messages": [{ "id": "3EB0C431D5A1B2C3" }]
}
Message types
| Type | Body | Numbers |
|---|---|---|
text | {"text": {"body": "…"}} (up to 4096 characters) | all |
image, video | {"image": {"link": "https://…", "caption": "…"}} | all |
audio | {"audio": {"link": "https://…"}}; with "voice": true, a voice note (use OGG/Opus) | all |
document | {"document": {"link": "https://…", "filename": "invoice.pdf"}} | all |
template | An approved template: {"template": {"name": "…", "language": {"code": "pt_BR"}, "components": […]}} | official |
interactive | Buttons, lists, links and the call permission request, as at Meta | official |
sticker, location, contacts, reaction | As in the Meta API | official |
- Links must be public
httpsaddresses: the file is downloaded at send time. textalso takespreview_url, as in Meta, and media takemime_type, the file's type.- To reply to a message, on an official number, send
"context": {"message_id": "wamid.…"}. - To send to a group, use
"recipient_type": "group"and the group's id into. On a number linked by QR code, the number must be in the group and the id is WhatsApp's (120363…, with or without@g.us); the answer comes withoutcontacts. On an official number, Meta's groups API applies, with Meta's group id. - The response's
idcomes back in the webhook's delivery statuses (sent,delivered,readorfailed). - For official numbers, Meta checks the body of the official-only types and says what is wrong (whatsapp_refused).
The 24-hour window (official numbers)
On an official number, Meta only delivers free-form messages to people who wrote to the number in the last 24 hours. Outside that window, start the conversation with a template approved in WhatsApp Manager:
{
"to": "5511987654321",
"type": "template",
"template": {
"name": "pedido_enviado",
"language": { "code": "pt_BR" },
"components": [{ "type": "body", "parameters": [{ "type": "text", "text": "4521" }] }]
}
}
A free-form message outside the window is accepted by the API, but reaches the webhook as a failed status, with Meta's error.
Message templates
The templates of an official number's WhatsApp Business account, read at Meta, one page at a time, in Meta's response. Each template carries id, name, language, category, status and components, or the fields asked for in fields.
{
"data": [{
"id": "1234567890",
"name": "pedido_enviado",
"language": "pt_BR",
"category": "UTILITY",
"status": "APPROVED",
"components": [{ "type": "BODY", "text": "Seu pedido {{1}} saiu para entrega." }]
}],
"paging": {
"cursors": { "before": "MAZDZD", "after": "MjQZD" },
"next": "https://www.vozaapi.com/v1/numbers/NUMBER_ID/templates?after=MjQZD"
}
}
limit(1 to 100, 25 by default),afterorbefore(the cursors inpaging.cursors) and Meta's filters:name,status(APPROVED…),categoryandlanguage.paging.nextandpaging.previouspoint at Voza and only appear when there is another page.- On a QR code number, the response is
409 not_an_official_number: only official numbers have templates.
Buttons
{
"to": "5511987654321",
"type": "interactive",
"interactive": {
"type": "button",
"body": { "text": "Can you confirm delivery tomorrow at 10 a.m.?" },
"action": { "buttons": [
{ "type": "reply", "reply": { "id": "confirm", "title": "Yes" } },
{ "type": "reply", "reply": { "id": "reschedule", "title": "Reschedule" } }
] }
}
}
The contact's choice reaches the webhook as a message of type interactive, with the button's id.
Media
Received messages with an image, video, audio, document or sticker carry the file's id (in image.id, audio.id…). Download it with:
- The response is the file's bytes, with its
Content-Type. - QR code numbers: media is available for 24 hours; files above 64 MB are not stored (
media_too_large), and a file WhatsApp did not deliver answersmedia_unavailable. - Official numbers: Voza fetches the file from Meta, which keeps it for 30 days.
Webhooks
Each number has its own webhook. Voza sends it the messages received, the delivery statuses of the messages sent, the call events and the number's status changes.
curl -X PUT https://www.vozaapi.com/v1/numbers/NUMBER_ID/webhook \ -H "Authorization: Bearer vz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-system.com/webhooks/whatsapp"}'
{ "url": "https://your-system.com/webhooks/whatsapp", "secret": "whsec_…" }
The address must be public https. Send "url": null to remove the webhook and, together with the url, "rotate_secret": true to change the secret. GET on the same address shows the current setup.
Messages and delivery statuses
They come in the same envelope as the official API, so the code that reads Meta's webhooks reads Voza's:
{
"object": "whatsapp_business_account",
"entry": [{
"id": "42",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "5511987654321", "phone_number_id": "NUMBER_ID", "external_ref": "canal-981" },
"contacts": [{ "wa_id": "5521999990000", "profile": { "name": "Bruno" } }],
"messages": [{
"from": "5521999990000",
"id": "ABCD1234",
"timestamp": "1761150000",
"type": "text",
"text": { "body": "Hi, how are you?" }
}]
}
}]
}]
}
A delivery status comes in the same envelope, in statuses:
"statuses": [{ "id": "3EB0C431D5A1B2C3", "status": "delivered", "timestamp": "1761150030", "recipient_id": "5521999990000" }]
phone_number_idis the number'sidat Voza, for both kinds of number, andexternal_refis your reference for it, when the number has one.- Delivery statuses come in
statuses(sent,delivered,read,failed), with theidof the message sent. For official numbers they also carry Meta'spricingandconversation, and theerrorsof a failure. - The
errorsMeta sends in a change (a payment problem of the account, for instance) arrive invalue.errors, as at Meta, even when the change has only errors. This holds formessagesandcalls. - Received message types:
text,image,video,audio(withvoice: truefor a voice note),document,sticker,location,contactsandunsupported; for official numbers, alsointeractive,buttonandreaction.
Number status
{
"object": "voza",
"event": "number.status",
"number": { "id": "NUMBER_ID", "name": "Downtown Store", "external_ref": "canal-981", "status": "starting", "status_reason": "logged_out", "phone": null },
"timestamp": 1761150000
}
The test event, sent from the dashboard, has the same shape. status_reason says why the number changed (see Status).
Headers and signature
| Header | Content |
|---|---|
X-Voza-Signature | sha256= and the HMAC-SHA256 of the body, with the webhook's secret. The same value goes in X-Hub-Signature-256, as at Meta. |
X-Voza-Delivery | The delivery's id, the same in every attempt: use it to ignore repeats. |
X-Voza-Event | messages, calls, number.status or test |
User-Agent | Voza-Webhooks/1.0 |
Verify the signature with the raw body, before reading the JSON:
// PHP $body = file_get_contents('php://input'); $expected = 'sha256=' . hash_hmac('sha256', $body, getenv('VOZA_WEBHOOK_SECRET')); if (! hash_equals($expected, $_SERVER['HTTP_X_VOZA_SIGNATURE'] ?? '')) { http_response_code(401); exit; } // Node.js (body is the request's raw Buffer) const expected = 'sha256=' + crypto.createHmac('sha256', process.env.VOZA_WEBHOOK_SECRET).update(body).digest('hex'); const given = req.headers['x-voza-signature'] || ''; const valid = given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected)); # Python (body is the request's raw bytes) expected = 'sha256=' + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() valid = hmac.compare_digest(expected, request.headers.get('X-Voza-Signature', ''))
Responses and retries
- Respond with any
2xxstatus within 10 seconds. Process the event later, in a queue, if it takes long. - Without a 2xx, Voza tries again after 1, 5, 15 and 30 minutes, 1, 2 and 4 hours, and every 8 hours until 24 hours have passed.
- An account may have a longer period, agreed with Voza, of up to 7 days:
GET /v1/accountshows yours inwebhook_retry_hours. - Call events have a queue of their own, ahead of messages. Without a 2xx, Voza tries again after 5, 15 and 30 seconds. Those of a ringing call (the connect and the RINGING status) give up after 2 minutes, since a call does not ring longer than that; the end of a call (terminate and the other statuses) goes on being tried after that, as a message event is.
- Voza does not follow redirects: a
3xxcounts as a failure. Set the final address. - A number without a webhook does not keep its events: as with Meta, they are lost. Set the webhook before connecting the number.
- The order of arrival is not guaranteed: use each message's timestamp.
- The event's content stays at Voza, encrypted, only until the delivery finishes. The history in the dashboard keeps 7 days of deliveries, without the content.
Web phone
The simplest way to answer and make calls: a JavaScript SDK, with no dependencies, that runs in your agents' browser. The API key stays on your server; the browser only gets a short-lived token for one number. It works with both kinds of number.
1. On your server, ask for a token for the agent:
curl https://www.vozaapi.com/v1/numbers/NUMBER_ID/webphone-tokens \ -H "Authorization: Bearer vz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{"label": "Ana", "minutes": 480}'
{ "token": "wp_…", "expires_at": "2026-10-23T01:00:00Z", "sdk_url": "https://www.vozaapi.com/sdk/webphone.js" }
minutes goes from 5 to 720 (8 hours by default). Use one token per browser tab.
2. On the agent's page, the ready-made phone:
<script src="https://www.vozaapi.com/sdk/webphone.js"></script> <voza-phone token="wp_…" lang="en"></voza-phone>
- The element speaks Portuguese, English and Spanish: the language of its
langattribute or, without one, the page's or the browser's. Other languages get English. - The element follows the page's light or dark theme; the CSS variables
--voza-phone-background,--voza-phone-colorand--voza-phone-radiuschange the colors. - The SDK talks to Voza at the address it was loaded from. If you serve
webphone.jsfrom elsewhere (your own domain or a bundle), tell it where Voza is:base-url="https://www.vozaapi.com"on the element orbaseUrlinnew VozaWebphone().
Or your own interface, with the SDK's API:
const phone = new VozaWebphone({ token: 'wp_…' });
phone.on('incoming', (call) => {
// call.contact, call.contactName; call.accept() or call.reject()
});
phone.on('call', (call) => console.log(call.direction, call.state));
phone.on('error', (error) => console.warn(error.code, error.message));
phone.on('disconnected', () => { /* the token expired: ask for another */ });
await phone.connect();
const call = await phone.call('5511987654321');
call.mute(true);
call.hangup();
phone.disconnect(); // stops following calls and hangs up the current ones
| Event | When it happens |
|---|---|
ready | The phone is ready. It receives the session, with the number (see Web phone API). |
state | The phone changed state: connecting, ready or closed. |
incoming | An incoming call started ringing. |
call | Any change in a call: a new state or mute. |
ended | A call ended; call.reason says why. |
monitor | A supervision opened with phone.monitor() changed state (see Supervising an AI call). |
error | An error, with error.code: microphone_denied, microphone_unavailable, insecure_context, unsupported_browser, connection_failed, network_error or an API code, such as not_on_whatsapp. |
disconnected | The token expired or was refused: get another one and create a new phone. |
- A call has
id,direction(incomingoroutgoing),contact,contactName,state,reason,answeredBy(who answered an incoming call:webphone,agentorapi),mutedandduration(seconds since it was answered). - States of an incoming call:
ringing,connecting,active,ended; when the voice agent answered,ringing,agent(with the AI: the element shows “Answered by AI”) andended. Of an outgoing call:connecting,dialing,ringing,active,ended.call.reasonsays why it ended:completed,rejected,missed,no_answer,hangup,answered_elsewhere,answered_by_agent(the call with the AI ended),failedor an error code. - The page needs https (or localhost) and permission for the microphone.
- When several agents receive the same call, the first one to answer keeps it; for the others it disappears.
- A call the AI answered stays on screen until it ends, but this phone neither answers nor hangs it up:
call.hangup()does nothing, and ending it is your server’sterminateaction.
Supervising an AI call
A supervisor listens, in the browser, to a call the voice agent answered, and may take it over and give it back to the AI. Joining needs only a short token; taking over, giving back and whispering need the API key, so the SDK calls functions of your page, and they ask your server.
1. On your server, ask for a supervision token with POST /v1/numbers/{id}/calls/{call_id}/monitor and pass {token, ice_servers} to the browser. Each token joins once, within 5 minutes.
2. In the browser, with the SDK’s API:
// your function that calls your server const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const monitor = await phone.monitor(callId, { // or VozaWebphone.monitor(), without a webphone token: 'mon_…', iceServers, // the token’s ice_servers takeover: ({ callId, monitorId }) => post('/supervise/takeover', { callId, monitorId }), release: ({ callId, monitorId }) => post('/supervise/release', { callId, monitorId }), whisper: ({ callId, text }) => post('/supervise/whisper', { callId, text }), }); monitor.on('state', (m) => console.log(m.state)); // listening, takeover, ended await monitor.takeover(); // asks for the microphone and speaks to the caller await monitor.whisper('Offer Friday at 9:30.'); await monitor.release(); // the AI speaks again monitor.leave();
- On your server, each function becomes a call to Voza with the API key:
takeovertoPOST …/calls/{call_id}/takeover{"monitor": monitorId},releasetoPOST …/calls/{call_id}/releaseandwhispertoPOST …/calls/{call_id}/whisper{"text"}(see Supervision). - The functions receive
{callId, monitorId, monitor}(andtext, when whispering) and may return thefetchResponse: an error response rejects the promise with theerror.codeyour server passes along (for examplenot_in_control). Without the function, the method rejects withtakeover_unavailable,release_unavailableorwhisper_unavailable. - Listening does not ask for the microphone: it is asked for only when taking over (or when joining with
mode: "takeover").monitor.mute()silences the supervisor while they have the call. - States:
connecting,listening,takeover,ended.monitor.reasonsays why it ended:left(leave(), which first gives the call back to the AI if the supervisor has it),completed(the call ended) or an error code, such asinvalid_monitor_token,token_usedorcall_not_with_agent. Events:state,errorandended. monitor.leave()tells Voza at once (DELETE /v1/monitor/sessionwith the same token, which still lets the supervisor leave after it expired), and the SDK does the same when the page closes, so the AI gets the call back without waiting for the connection to drop.- A token joins once: to listen again after leaving, ask for another. A call in the
agentstate also hascall.monitor({token, …}).
3. Or in the ready-made phone: with the functions in supervisor, a call the AI answered shows “Answered by AI” and the Listen button; the supervision has Take over, Give back to AI, Mute, Leave and a field for an instruction to the AI.
const element = document.querySelector('voza-phone'); element.supervisor = { token: (call) => post('/supervise/token', { callId: call.id }), // your server returns {token, ice_servers} takeover: ({ callId, monitorId }) => post('/supervise/takeover', { callId, monitorId }), release: ({ callId, monitorId }) => post('/supervise/release', { callId, monitorId }), whisper: ({ callId, text }) => post('/supervise/whisper', { callId, text }), }; // or, for a call you picked: element.monitor(callId, { token: 'mon_…', iceServers });
- Without the
whisperfunction, the instruction field does not show; withouttakeoverorrelease, neither do their buttons. - Without the
tokenattribute, the element only supervises: it shows the supervision opened withmonitor().
Calls through the API
For those who prefer their own WebRTC client: the same actions and events as Meta's calling API. Voza does not record calls.
| Action | Body |
|---|---|
connect | Calls to with the browser's SDP offer in session (sdp_type: offer). Responds {"calls": [{"id": "…"}]}; the SDP answer comes in the connect event. |
pre_accept | For an incoming call: the browser's SDP answer (sdp_type: answer), so the audio connects right away. |
accept | Answers the call on WhatsApp. For official numbers, send the same SDP answer again in session. |
reject, terminate | Declines or ends the call call_id. |
{
"action": "pre_accept",
"call_id": "wacid.ABGG…",
"session": { "sdp_type": "answer", "sdp": "v=0\r\no=- …" }
}
- The SDP has no trickle ICE: send the complete SDP, after gathering the candidates.
- QR code numbers: the audio goes to Voza's servers, which sit behind NAT and can only be reached through TURN. Use the servers in
ice_serversfromGET /v1/webphone/session(see Web phone API) and ask for them again before each call: the credentials expire. - Official numbers: to call a contact, Meta requires their permission. Ask for it with an
interactivemessage of typecall_permission_request; the answer reaches the webhook. - Each plan has a number of simultaneous calls. An incoming call above the limit is declined immediately: the webhook gets its end (
terminate, withstatus: REJECTEDandvoza_reason: channel_limit), without the SDP offer, and it stays in the log withend_reason: channel_limit. Webphones never see it ring. - A call the voice agent declined (see Who answers the call) ends with
voza_reason: agent_unavailableand the reason invoza_detail. - As in Meta, the actions take
biz_opaque_callback_data(up to 512 characters).
Permission to call
Whether the number may call the contact now. On an official number, it is the answer of Meta's call_permissions lookup; on a QR code number, always {"permission": {"status": "granted"}}, since there is no permission to ask for.
{
"messaging_product": "whatsapp",
"permission": { "status": "temporary", "expiration_time": 1761236400 },
"actions": [{ "action_name": "start_call", "can_perform_action": true, "limits": [ … ] }]
}
status is granted, temporary (expires at expiration_time) or no_permission. wa_id is the contact's number, in digits only.
Events
Events reach the number's webhook with field: calls, in Meta's envelope: calls[] with event: connect (the SDP offer of an incoming call, or the answer to an outgoing call) and event: terminate (with duration when it was answered), and statuses[] of an outgoing call (RINGING, ACCEPTED, REJECTED). direction is USER_INITIATED on an incoming call and BUSINESS_INITIATED on an outgoing one.
"value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "5511987654321", "phone_number_id": "NUMBER_ID" }, "contacts": [{ "wa_id": "5521999990000", "profile": { "name": "Bruno" } }], "calls": [{ "id": "wacid.ABGG…", "from": "5521999990000", "to": "5511987654321", "event": "connect", "direction": "USER_INITIATED", "timestamp": "1761150000", "session": { "sdp_type": "offer", "sdp": "v=0\r\no=- …" } }] }
When the call ends:
"calls": [{ "id": "wacid.ABGG…", "event": "terminate", "direction": "USER_INITIATED", "status": "COMPLETED", "duration": 126, … }]
Call log
The number's calls, newest first: ?limit= from 1 to 200, 50 by default.
{
"data": [{
"id": 981,
"call_id": "wacid.ABGG…",
"direction": "incoming",
"contact": "5521999990000",
"contact_name": "Bruno",
"status": "completed",
"end_reason": null,
"end_detail": null,
"started_at": "2026-10-22T15:20:00Z",
"answered_at": "2026-10-22T15:20:04Z",
"ended_at": "2026-10-22T15:22:10Z",
"duration": 126,
"answered_by": "agent",
"agent": { "agent_ref": "agent_42", "session_id": "…", "seconds": 124 }
}]
}
| Field | Description |
|---|---|
call_id | The call's id on WhatsApp, the same as in the events and the actions. |
status | dialing, ringing, active, completed, missed, rejected, no_answer or failed. |
end_reason | channel_limit: declined for going over the simultaneous calls limit. agent_unavailable: declined because the voice agent could not answer it. lost: had no end for over 6 hours and was closed as failed. Empty for the others. |
end_detail | With agent_unavailable, why the agent declined the call (see Who answers the call). Empty for the others. |
answered_at | When it was answered; empty for an unanswered call. Times are in UTC. |
duration | The seconds of conversation; empty for an unanswered call. |
answered_by | Who answered: webphone, agent (the voice agent) or api (your system); on an outgoing call, who placed it. Empty on a call nobody answered. |
agent | For a voice agent's call: agent_ref, the session in the runtime and the AI's seconds (without the time a supervisor had the call). |
Web phone API
What the SDK uses underneath, for those who build their own phone in the browser. These addresses take the web phone token, not the API key, and answer pages on any domain (CORS). The token goes in Authorization: Bearer wp_… or, in the event stream, in ?token=, because EventSource does not send headers.
{
"session_id": "01jbk5…",
"label": "Ana",
"number": { "id": "01jbk3…", "name": "Downtown Store", "phone": "5511987654321", "status": "connected" },
"ice_servers": [{ "urls": ["turn:turn.cloudflare.com:3478?transport=udp", …], "username": "…", "credential": "…" }],
"expires_at": "2026-10-23T01:00:00Z"
}
ice_servers goes straight into new RTCPeerConnection({ iceServers }). Ask for the session again before each call: the TURN credentials expire.
The number's calls, as Server-Sent Events. The calls event carries the same value as the calls webhook (contacts, calls and statuses, with the SDP). The claimed event says someone answered a call, with call_id, session_id and by (webphone, agent or api); if the session_id is not yours, it was answered elsewhere.
const events = new EventSource('https://www.vozaapi.com/v1/webphone/events?token=wp_…'); events.addEventListener('calls', (event) => { const { calls, statuses } = JSON.parse(event.data); }); events.addEventListener('claimed', (event) => { const { call_id, session_id, by } = JSON.parse(event.data); });
- Each event has an
id. On reconnecting,EventSourcesendsLast-Event-IDand the stream continues from the next event (withoutEventSource, use?after=). A new connection starts 20 seconds back, to show a call that is already ringing. - The stream closes every 300 seconds, and
EventSourcereconnects by itself; when the token expires, it closes for good. Every 15 seconds a comment arrives (: ping), so the connection does not drop.
The call actions on the token's number, with the same body as POST /v1/numbers/{id}/calls. When several phones get the same call, the first pre_accept or accept keeps it, and the others get 409 call_answered_elsewhere. The same goes against the voice agent and your system with the API key (see Voice agents).
Voice agents
A voice agent answers a number's calls with AI: it listens, thinks and speaks. It works on numbers linked by QR code and on official ones, with the same API. The logic is your product's: Voza keeps no prompt and no configuration, only which agent of your product answers the number and where to ask. At the start of each call, Voza's agents runtime asks your product for the configuration, and the tools (calendar, knowledge base, messages) are also calls to your product. A person can always listen, guide the AI and take over the call.
Voza keeps no content: the transcript and the summary go to your product in the events, and stay at Voza only until they are delivered. The agents' runtime keeps only the audio of the agent's fixed phrases (the greeting, the filler phrases and the handoff messages), to play them without waiting for the synthetic voice; a phrase not played for 30 days is removed, and deleting the account deletes its phrases. The AI providers' keys are your account's own.
A number's agent
{
"mode": "always",
"fallback_after_seconds": 15,
"agent_ref": "agent_42",
"callback_url": "https://your-system.com/voza/agents",
"events_url": "https://your-system.com/voza/events"
}
| Field | Description |
|---|---|
mode | always: the agent answers every incoming call. fallback: the webphones ring first and, if nobody answers within fallback_after_seconds (5 to 120, 15 by default), the agent answers. off: the agent only answers the calls your product hands to it. |
agent_ref | The agent in your product (up to 255 characters). Voza repeats it in every request and event. |
callback_url | Where the runtime asks for the configuration (agent.config) and calls the tools (tool.call). https and public. |
events_url | Optional: where the agent's events go. Without it, they go to the number's webhook. |
The first time, the answer carries agent.secret: it signs the runtime's requests to your product and the events sent to events_url. Keep it: GET on the same address shows the configuration without it. POST /v1/numbers/{id}/agent/secret creates a new one (the previous one stops working at once) and DELETE switches the agent off and forgets the secret.
{ "agent": { "mode": "always", "fallback_after_seconds": 15, "agent_ref": "agent_42", "callback_url": "https://…/voza/agents", "events_url": "https://…/voza/events", "secret": "agsec_…" } }
Who answers the call
An incoming call stays with whoever answers first: a webphone, the agent or your system with the API key (pre_accept or accept in POST /v1/numbers/{id}/calls). The others get 409 call_answered_elsewhere, and the webphones get the claimed event with by: webphone, agent or api. The call log shows who answered in answered_by.
With the agent in always, as soon as the call arrives the agent takes it and the runtime answers the SDP offer; the webphones see it ring and then the claimed. The number's webhook gets the calls events as always. If the runtime cannot answer (your product did not send the configuration and none was stored, an AI key is missing…), the call is declined and stays in the log with end_reason: agent_unavailable. In fallback, it keeps ringing on the webphones.
Your product may also refuse a call: if it answers agent.config with 409 or 410 and {"error": {"code": "agent_unavailable"}}, the runtime does not use the cached configuration and does not answer. In always, the call is rejected (end_reason: agent_unavailable); in fallback, it keeps ringing on the webphones; handed over through the API, the answer is 409 agent_unavailable.
A blocked account (trial over, subscription ended or suspended) has no agent, as it has no API and no webphone: the call reaches the number's webhook and rings until the caller gives up.
When the agent cannot take a ringing call, your product gets the call.agent.declined event, with the reason in reason, the refusal's code in detail, the number's mode in mode and, in outcome, what became of the call: rejected (declined, in always) or ringing (it goes on ringing on the webphones, in fallback). A declined call stays in the log with end_reason: agent_unavailable and the reason in end_detail. A call handed over through the API gets no event: your product gets the error.
reason | When it happens |
|---|---|
product_refused | Your product refused the call (it answered agent.config with 409 or 410 and agent_unavailable). |
config_unavailable | Your product did not send a valid configuration in time, and the runtime had none kept. |
missing_provider_key | A key of an AI provider the agent uses is missing. |
invalid_offer | The runtime could not answer the SDP offer. |
at_capacity, draining | The agents' runtime is at its call limit, or restarting (Voza asks again once, within the same deadline). |
account_limit | The account already has as many simultaneous agent sessions as it may (limits.agent_sessions in GET /v1/account). |
runtime_unavailable, runtime_error | The agents' runtime did not answer in time, or failed. |
whatsapp_refused | Meta or the QR engine refused the agent's answer (pre_accept). |
error | Another failure at Voza. |
Each account has a limit of agent sessions at the same time, counting the calls the agents answer and the test calls, and a lower limit for test calls only: limits.agent_sessions and limits.agent_tests in GET /v1/account (10 and 2 by default; Voza sets a partner account's). Past it, an always call is declined (account_limit), a fallback one goes on ringing on the webphones, and the API answers 429 agent_limit_reached.
Hands an incoming call that is still ringing to the agent: your product decides call by call. {"agent_ref": "…"} is optional (another agent of your product). It answers {"success": true, "call": {…}}, or 409 call_answered_elsewhere if someone already answered and 409 call_not_ringing if it has ended. call_id is the call's id at WhatsApp; its id at Voza works too.
What the runtime asks your product
POST requests to the callback_url, with X-Voza-Event (agent.config or tool.call), X-Voza-Timestamp (Unix seconds) and X-Voza-Signature: sha256=…, the HMAC-SHA256 of {timestamp}.{body} (the timestamp, a dot and the body as it arrived) with the agent's secret. Check the signature and refuse an X-Voza-Timestamp more than 5 minutes away from your clock: that way a captured request cannot be replayed later.
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);
$valid = hash_equals($expected, $signature) && abs(time() - (int) $timestamp) <= 300;
At the start of each call, the configuration (answer within 2 seconds):
{ "type": "agent.config", "agent_ref": "agent_42", "call": { "id": "01jbk5…", "direction": "incoming", "test": false }, "number": { "id": "NUMBER_ID", "phone": "5511987654321", "type": "qr" }, "contact": { "wa_id": "5521999990000", "name": "Bruno" } }
{
"version": "42.3",
"language": "pt-BR",
"greeting": { "text": "Hello, this is the virtual assistant of Smile Clinic. How can I help?", "interruptible": false },
"instructions": "Book, reschedule and cancel appointments. Confirm the date out loud.",
"guardrails": ["Never give medical advice."],
"voice": { "provider": "elevenlabs", "voice_id": "…", "speed": 1.0 },
"stt": { "provider": "deepgram", "language": "multi" },
"llm": { "provider": "anthropic", "model": "claude-haiku-5-5" },
"turn": { "interruption": "medium", "silence_ms": 600 },
"tools": [{
"name": "find_slots",
"description": "Free slots in the calendar",
"parameters": { "type": "object", "properties": { "day": { "type": "string" } }, "required": ["day"] },
"filler": "One moment, let me check the calendar."
}],
"handoff": {
"on_request": true,
"message": "I'll pass you to the team, one moment.",
"timeout_seconds": 45,
"timeout_message": "Nobody from the team can take the call right now. May I take a message?",
"timeout_action": "resume"
},
"error_message": "Sorry, I am having a technical problem. I will pass you to the team.",
"max_duration_seconds": 900
}
The runtime keeps the last configuration of each agent by its version: if your product does not answer in time, it uses the stored one. Each tool is called with {"type": "tool.call", "call_id", "agent_ref", "tool", "arguments"} and has 8 seconds to answer {"result": …}, with optional say (a phrase to say), end_call: true or handoff: true. The knowledge base is a tool like any other.
stt.language: the caller's language (pt-BR…) ormulti, when they may speak more than one, even in the same sentence.multionly works with the Deepgram models that transcribe several languages (nova-3, the default, ornova-2, which only mixes English and Spanish); with anotherstt.model, the configuration is refused.languageis still the language the agent speaks.handoff: after handing the call to the team, the agent waitstimeout_seconds(10 to 600, 45 by default) for a supervisor to take it over. If nobody does, thecall.agent.stateevent sayshandoff_timeout, the agent saystimeout_messageand, withtimeout_action: resume(the default), goes back to the call and offers to take a message (without a message, the AI tells the caller in its own words); withend, it says goodbye with the message (required) and ends the call (reason: handoff_timeoutincall.agent.ended). The caller is never left in silence untilmax_duration_seconds.max_duration_secondscounts the AI's time only: while a supervisor has the call, the clock stops, and a supervisor who gives the call back past that time ends it (max_duration). Every call ends at 4 hours, whoever is on it (max_call_duration).error_message(optional): what the agent says when a provider fails in the middle of the call (see When a provider fails).
When a provider fails
If speech-to-text, the AI or the voice fails in the middle of a call (the provider refused the account's key, keeps failing, or the AI does not answer a turn within 20 seconds), the caller is not left in silence: your product gets call.agent.error, the agent says error_message (or, without it, the handoff message; when the voice itself failed, only a phrase already kept, or else a tone) and hands the call to the team, when the agent has handoff.on_request, or ends it (reason: provider_error). The AI does not answer again in that call: if the team gives it back, or nobody takes it, it ends.
{ "service": "llm", "provider": "anthropic", "status": 401, "category": "authentication", "action": "handoff" }
service is stt, llm or tts; status, the provider's HTTP status, when there is one; category, the kind of failure (authentication, rate_limit, quota, server, timeout…); action, handoff or end.
The agent's events
They go to the agent's events_url, signed with the agent's secret, or to the number's webhook, signed with the webhook's secret. They have object: voza and the event's name in event and in X-Voza-Event. They go out on a queue of their own, and a call's events go out in order, one after the other: an event that failed holds back the next ones of the same call until its retry. Retries come after 5, 15 and 30 seconds, for up to 2 minutes; call.agent.ended (with the whole conversation), call.agent.error and call.agent.declined go on after that, for as long as message events.
{
"object": "voza",
"event": "call.transcript",
"event_id": "5f2c…",
"number": { "id": "NUMBER_ID", "name": "Downtown Store", "external_ref": null, "status": "connected", "status_reason": null, "phone": "5511987654321" },
"call": { "id": "01jbk5…", "call_id": "wacid.ABGG…", "direction": "incoming", "contact": "5521999990000", "contact_name": "Bruno", "agent_ref": "agent_42", "agent_session_id": "…" },
"test": false,
"data": { "speaker": "caller", "text": "I'd like to reschedule Friday's appointment.", "start": 4.2, "end": 6.9 },
"timestamp": 1761150012,
"timestamp_ms": 1761150012345
}
| Event | When | In data |
|---|---|---|
call.agent.started | The agent answered. | call, number, contact, agent_ref and the configuration's version (config_version). |
call.agent.state | On every change. | state: listening, thinking, speaking, handoff, handoff_timeout (nobody took the call over in time) or supervised. |
call.transcript | On every complete utterance. | speaker (caller, agent or supervisor), text, start and end in seconds since the answer. |
call.agent.tool_call | On every tool. | name, duration_ms and result (ok, timeout, error); without the arguments. |
call.metrics | On every turn. | The latencies in milliseconds: stt_ms, llm_ms (to the first word), tts_ms and end_to_end_ms. |
call.supervision | A supervisor joins, changes mode, whispers or leaves. | action, monitor_id and mode. |
call.agent.error | A provider failed in the middle of the call. | service, provider, status, category and action (see When a provider fails). |
call.agent.declined | The agent could not take a call that was ringing. | reason, detail, mode and outcome (see Who answers the call). |
call.agent.ended | The end. | reason, duration_seconds, the AI's seconds (ai_seconds) and a supervisor's with the call (supervised_seconds), the whole transcript (transcript), the tools used (tools) and the usage (usage). |
timestamp is the moment of the event in seconds, and timestamp_ms the same moment in milliseconds: use it to order events of the same second.
event_id is the same on every attempt: use it so you never process an event twice. The calls the agent answered and the AI's seconds count in the usage (agent_calls and agent_seconds in GET /v1/account/usage).
The reason of call.agent.ended: remote_ended (the caller hung up), agent or tool (the agent ended it), handoff_timeout, max_duration, max_call_duration, idle, media_closed, connect_timeout, accept_failed, provider_error (a provider failed), agent_removed, number_removed and account_blocked (the agent or the number was removed, or the account blocked: the call ends at once), runtime_lost (the agents' runtime went down in the middle of the call: Voza ends the call and sends this event, without the transcript) or shutdown.
Supervision
Your product decides who may supervise; Voza only checks the API key. Ending the call is still the terminate action.
A short token (mon_…, 5 minutes, once) for the supervisor's browser, with the ice_servers. The browser sends its SDP offer to POST /v1/monitor/session with Authorization: Bearer mon_… and gets the runtime's answer and the monitor_id. mode is listen (listens; the default) or takeover (joins speaking). This address accepts pages from any domain (CORS).
{ "session": { "sdp_type": "offer", "sdp": "v=0\r\no=- …" }, "mode": "listen" }
{ "monitor_id": "mon_1", "call_id": "wacid.ABGG…", "session": { "sdp_type": "answer", "sdp": "v=0\r\no=- …" } }
| Address | Body | What it does |
|---|---|---|
POST …/calls/{call_id}/whisper | {"text": "Offer Friday at 9:30."} | The text goes into the AI's context; the caller does not hear it. |
POST …/calls/{call_id}/takeover | {"monitor": "mon_1"} | The AI stops speaking and that supervisor's microphone goes to the call. |
POST …/calls/{call_id}/release | {} | Gives the call back to the AI. |
DELETE …/calls/{call_id}/monitor/{monitor_id} | Removes a supervisor from the call; if they had it, the AI speaks again. |
In the browser, the webphone SDK does the supervisor’s part: see Supervising an AI call.
Test call
Talk to an agent from the browser, without WhatsApp: send {"agent_ref", "callback_url"} and get {id, token, ice_servers, expires_at}. The browser sends the SDP offer to POST /v1/agents/test-sessions/connect with Authorization: Bearer agt_… (once) and gets the runtime's answer. With number_id, the runtime signs with that number's agent secret, and the events go out with "test": true wherever the number's go; without it, the answer carries a secret for this call only, and the events are not sent. DELETE /v1/agents/test-sessions/{id} ends it.
A test call lasts at most 10 minutes and counts in the account's agent sessions: past limits.agent_tests test calls at the same time, connect answers 429 test_session_limit_reached (and 429 agent_limit_reached past limits.agent_sessions). The token can be used again after such a refusal.
Voice and AI keys
Stores the account's AI providers' keys, encrypted: anthropic, deepgram, elevenlabs and cartesia. Only the providers sent change, and null removes a key. Only an API key created by one of the account's owners can change them (otherwise, 403 owner_only). They are never given back: the answer, like GET, only says which exist.
Before saving, Voza checks each new key at the provider itself, at an address that costs nothing (the list of models, projects or voices). If the provider refuses a key (401), no key of the request is saved, and the answer is 422 invalid_provider_key, with the providers in meta.providers. A provider that is down, slow, or takes the key without the permission for that list does not stop the key.
{ "anthropic": "sk-ant-…", "deepgram": "…" }
{ "anthropic": true, "deepgram": true, "elevenlabs": false, "cartesia": false }
{"provider", "voice_id", "text", "speed"} → the audio (audio/mpeg, or audio/wav with "format": "wav"), with the provider's key stored in the account. Use it to hear the greeting before publishing it. The text is not kept; a fixed phrase an agent's call already kept (the same voice and text) comes back without waiting (X-Voza-Cache: hit).
The account's voices at a provider (elevenlabs or cartesia), read with the key stored in the account, so its cloned voices come too. Each voice has id (the voice_id of the configuration and of /v1/tts), name, languages, gender and accent when the provider says, and preview_url. The list is kept for an hour, per account and provider.
{ "provider": "elevenlabs", "data": [{ "id": "21m00Tcm4TlvDq8ikWAM", "name": "Rachel", "languages": ["en", "pt-BR"], "gender": "female", "accent": "american", "preview_url": "https://…/preview.mp3" }] }
Agent errors
| Status | Code | When it happens |
|---|---|---|
| 401 | invalid_monitor_token, invalid_test_token | The supervision or test call token is missing, invalid or expired. |
| 403 | owner_only | Only an API key created by an owner can store the AI keys. |
| 404 | monitor_not_found | There is no supervisor with this id on the call. |
| 409 | agent_not_configured | The number has no agent. |
| 409 | agent_unavailable | Your product refused the call (it answered agent.config with 409 or 410 and agent_unavailable). |
| 409 | call_not_ringing | The call is no longer ringing. |
| 409 | call_not_with_agent | The call is not with the agent: someone else answered it, it ended, or it has not been answered yet. |
| 409 | token_used | The supervision or test call token has already been used. |
| 409 | too_many_monitors, not_in_control | The call already has all the supervisors it takes, or another supervisor has taken it over. |
| 422 | missing_provider_key | The key of a provider the agent uses is missing: store it with PUT /v1/account/ai-keys. |
| 422 | invalid_offer | The runtime could not answer the SDP offer. |
| 422 | invalid_provider_key | A provider refused the key sent in PUT /v1/account/ai-keys. |
| 429 | agent_limit_reached, test_session_limit_reached | The account already has as many simultaneous agent sessions, or test calls, as it may. |
| 502 | agent_config_unavailable | Your product did not send the configuration in time, and the runtime had none stored. |
| 502 | tts_failed | The voice provider did not produce the audio. |
| 502 | voices_failed | The voice provider did not return the list of voices (a refused key, for instance). |
| 503 | agents_unavailable, agents_busy | The agents' runtime did not answer or is at its call limit. meta.reason carries the reason the runtime gave. |
Account, plan and usage
{
"id": 42,
"name": "Your Company",
"status": "active",
"trial_ends_at": null,
"partner": false,
"plan": "pro",
"subscription": { "status": "active", "currency": "brl", "renews_at": "2026-11-23T14:30:00Z", "ends_at": null },
"limits": { "numbers": 5, "channels": 10, "requests_per_minute": 600, "agent_sessions": 10, "agent_tests": 2 },
"webhook_retry_hours": 24
}
| Field | Description |
|---|---|
status | trial (free trial), active (active), suspended (suspended) or canceled (subscription ended). |
plan | start, pro, scale, or empty without a subscription. |
subscription | The subscription at Stripe: status (active, past_due when a payment failed, canceled…), the currency and the dates. Empty without a subscription. |
limits | How many numbers the account connects, how many simultaneous calls it can have, how many API requests it makes per minute and how many voice-agent sessions it has at the same time (agent_sessions, of which up to agent_tests test calls). |
webhook_retry_hours | For how many hours a failed message event is tried again: 24, or the period agreed with Voza, up to 168 (7 days). |
partner | A partner account, of another product, with limits agreed with Voza: its calls are not declined over the simultaneous calls limit, because the product limits its customers' calls. |
Subscribing is done in the dashboard (Plan and usage), by card, through Stripe. When the subscription ends, the API responds subscription_inactive until the account subscribes again.
{
"month": "2026-10",
"totals": { "messages_sent": 1840, "messages_received": 2311, "calls": 96, "call_seconds": 20412, "agent_calls": 40, "agent_seconds": 7310 },
"numbers": [
{ "number_id": "01jbk3…", "name": "Downtown Store", "messages_sent": 1200, "messages_received": 1500, "calls": 60, "call_seconds": 14000, "agent_calls": 40, "agent_seconds": 7310 }
]
}
The month's usage in UTC, in total and per number (number_id empty for a number already deleted). Without month, the current month. agent_calls and agent_seconds are the calls a voice agent answered and the AI's seconds in them (without the time a supervisor had the call).
OpenAPI and Postman
The whole API is described in OpenAPI 3.1: https://www.vozaapi.com/docs/openapi.json?lang=en. Import it into Postman or Insomnia, or generate a client in your language.
The spec's texts come in the language of ?lang=: pt-BR, en or es.
Voza status
Voza's live status (site and API, database, webhooks and emails, retries, the numbers linked by QR code and the voice agents) is at https://www.vozaapi.com/status. For an uptime monitor, the same as JSON: https://www.vozaapi.com/status.json, which answers 503 when a service is down or late.
{
"status": "operational",
"checked_at": "2026-10-27T15:00:00Z",
"components": [{ "key": "engine", "name": "QR code numbers", "status": "operational", "message": null }]
}
status is operational, degraded or down. Official numbers also depend on Meta's API, which this page does not track.
What's new
- 29/10/2026: the agents' events go out on a queue of their own, in order per call, and never delay the call events;
call.agent.declinedandend_detailsay why the agent declined a call, and itsterminatehasvoza_reason: agent_unavailableandvoza_detail;call.agent.erroranderror_messagewhen a provider fails in the middle of the call (provider_error);runtime_lostwhen the agents' runtime goes down;max_duration_secondscounts the AI's time only, andcall.agent.endedhasai_secondsandsupervised_seconds; per-account limits of agent sessions and test calls (limits.agent_sessionsandlimits.agent_tests,429 agent_limit_reached); removing the agent or the number, or blocking the account, ends the agent's calls in progress; AI keys are checked at the provider; the end of a call is tried after the 2 minutes; a token refused in a send, a call action or the hourly check takes the official number tometa_refused. - 29/10/2026:
POST /v1/ttsdoes not keep the text (nor does a tool'ssay); the agent does not answer a blocked account's calls; the agent accepts the call up to 4 s afterpre_accepteven before the media connects, and the greeting waits for the media. - 28/10/2026:
"calling": "keep"when connecting an official number;{id}and{external_ref}in a new number's webhook;meta_timeout, apart frommeta_unavailable, andIdempotency-Keyon call actions;timestamp_msin the agent's events;X-Voza-Timestampin the signature of the runtime's requests; the product may refuse a call (agent_unavailable); thehandofftimeout;stt.language: multi;GET /v1/voices. - 10/28/2026: the webphone supervises calls the AI answered:
monitor(callId, {token})listens, and taking over, giving back and whispering go through your server; theagentstate (“Answered by AI”),answeredByand theanswered_by_agentreason on the SDK’s calls; the Listen button and the supervision in<voza-phone>. - 28/10/2026: voice agents:
PUT /v1/numbers/{id}/agent(a number's agent, inalwaysorfallback), handing a call to the agent, supervision (listen, whisper, take over and give back), test calls,POST /v1/tts,PUT /v1/account/ai-keys, thecall.agent.*,call.transcript,call.metricsandcall.supervisionevents,answered_byin the call log andagent_callsandagent_secondsin the usage. A call answered with the API key also stays with whoever answered first. - October 28, 2026:
GET /v1/numbers/{id}/templates(an official number's templates);external_refon numbers, in webhooks (metadata.external_ref) and inPATCH /v1/numbers/{id}; the webhook secret in the response ofPOST /v1/numbers; idempotency kept by Voza for QR code numbers too;limits.requests_per_minuteandwebhook_retry_hoursinGET /v1/account. - October 28, 2026: Meta's error in
error.meta(and the engine's reason inerror.meta.reason); Meta'serrorsin webhooks; call events on a queue of their own, retried within seconds; a call declined over the limit reaches the webhook;GETandPUT /v1/numbers/{id}/calling;GET /v1/numbers/{id}/call-permissions/{wa_id};status_reasonon numbers. - October 23, 2026: the API in Portuguese, English and Spanish: error messages follow
Accept-Languageor?lang=(without them, the language of whoever created the account), the OpenAPI spec comes in the language asked for, and<voza-phone>gained thelangattribute. - October 23, 2026: official Meta numbers through the same API (templates, interactive messages, calls);
GET /v1/account/usage; plan and subscription inGET /v1/account; OpenAPI spec. - October 22, 2026: first version: QR code numbers, messages, media, webhooks, calls and web phone.
Changes that may break your integration are announced in advance and go into a new version of the address (/v2); /v1 only gains new fields and events.