Place calls from your own assistant or code
Your Zvani workspace is reachable as an MCP server and as a REST API. An AI assistant such as Claude can ring a number with one of your agents, wait for the conversation to end, and read back what was said and decided. A script or a CRM can do the same over plain HTTP.
Quick start
- Get a key. A workspace owner or admin opens the Zvani web app, goes to Members → API keys and creates a key with the
readandplace_callsscopes. It is shown once and looks likezv_…. - Connect your assistant. In Claude Code:
shellOther clients are below.
claude mcp add --transport http zvani https://zvani.lv/api/v1/mcp \ --header "Authorization: Bearer zv_..."
- Ask for a call. For example: “Using Zvani, call +371 2000 0000 with the purchasing agent and ask whether part 123456789 is in stock. Tell me what they said.” The assistant looks up the agent, places the call, waits for it and reports the outcome.
API keys
A key belongs to one workspace and acts on that workspace only. It is stored hashed, can be revoked at any time in the same screen, and is shown in full only when created. Treat it like a password: keep it out of source code and chat logs.
| Scope | Allows |
|---|---|
read | Reading calls, agents, campaigns, a caller's history and usage. Also needed to follow a placed call with calls.wait. |
place_calls | Starting outbound calls with the workspace's agents. Every call spends the workspace's call minutes. |
callbacks | Ringing back somebody who has called the workspace before, and nobody else. Spends call minutes, but cannot start cold calls. |
custom_tools | Adding, changing, testing and removing an agent's own tools, such as an order lookup it calls mid-call. Not the agent's prompt, voice or number. Tool endpoints must be public addresses. |
knowledge | Keeping pages in the workspace's knowledge base through PUT /knowledge/pages/{externalId}, which is what the shop plugins do. Reads nothing else. |
A workspace holds at most 20 live keys; revoke the ones you no longer use. A key belongs to the workspace, not to the person who made it: it keeps working when that person leaves, because the integration it serves is the workspace's. Revoke it from the same screen if that is not what you want. A key of a suspended workspace answers 403 workspace_suspended until the workspace is active again.
Send the key in either header:
Authorization: Bearer zv_... x-api-key: zv_...
Connect over MCP
The server speaks Streamable HTTP and is stateless: there is no session to keep, and a client can reconnect at any time. The tools it offers follow the key's scopes: a read key sees the read tools, a place_calls key also sees calls_place.
Claude Code
claude mcp add --transport http zvani https://zvani.lv/api/v1/mcp \ --header "Authorization: Bearer zv_..."
Clients configured with JSON
Clients that accept a remote server URL with headers (Cursor, Windsurf, VS Code and others) take an entry like this in their MCP configuration file:
{
"mcpServers": {
"zvani": {
"url": "https://zvani.lv/api/v1/mcp",
"headers": { "Authorization": "Bearer zv_..." }
}
}
}Clients that only run local servers
Claude Desktop's configuration file and other clients that start a local process can reach the server through the mcp-remote bridge (needs Node.js):
{
"mcpServers": {
"zvani": {
"command": "npx",
"args": [
"mcp-remote", "https://zvani.lv/api/v1/mcp",
"--header", "Authorization:${ZVANI_AUTH}"
],
"env": { "ZVANI_AUTH": "Bearer zv_..." }
}
}
}Check the connection
Ask the assistant to call me_get. It answers with the workspace the key belongs to and its scopes. If calls_place is missing from the tool list, the key has no place_calls scope.
Placing a call
An agent's prompt can contain {placeholders} — facts it needs for this particular call, such as a part number or a company name. The caller fills them when placing the call.
agents_list— find the agent to call with. Outbound agents havedirection: "outbound".agents_get— read itsplaceholders: these are the keysvariablesshould carry. ItsspokenLanguageis the language it opens the call in.calls_place— ring the number. It answers at once with aplacementId; the call itself is only starting.calls_wait— pass theplacementId. Each request waits up totimeoutSecondsfor the call to end and be analysed; call it again untilstatusisdone,not_reachedorfailed.
calls_place arguments
| Field | What |
|---|---|
toNumber | Required. The number to ring, in international format: 6–15 digits with an optional leading +, e.g. +37120000000. |
agentId | Which agent calls. Required when the workspace has more than one enabled agent; with only one it may be left out. |
variables | Values for the agent's placeholders, all strings: {"part_number": "123456789"}. A placeholder left empty reads as “unknown” to the agent and is listed back in unfilled. |
name | Who is being called; the agent can greet them by name. |
background | Anything else the agent should know about this call, in plain prose. |
language | The language to open the call in, e.g. "Lithuanian", when it should differ from the agent's own. |
clientId | Your own id for this request. A repeat with the same id within a day answers the earlier placement instead of ringing the person again — send one whenever a retry is possible. Over REST an Idempotency-Key header does the same. The same id with another toNumber, agentId or variables is refused with 409 idempotency_conflict: a new call needs a new id. |
The answer's status is calling when the dial went out, or queued when the schedule is holding it. The finer states below come from calls_wait.
Placement status
| Status | Meaning |
|---|---|
| queued | Accepted, the dial has not gone out yet. |
| ringing | The number is being dialled. |
| in_call | Someone answered; the conversation is on. |
| analysing | The call has ended; the summary and outcome are being written. Usually under a minute. |
| done | Finished. call carries the outcome and the summary. |
| not_reached | The carrier took the call but nobody answered, or the line was busy. lastError says which. |
| failed | The call could not be placed at all. lastError says why. |
Waiting: timeoutSeconds defaults to 30 and goes up to 120. Most MCP clients give up on a request after 60 seconds, so over MCP keep it at 45 or less and call again. Send 0 for an instant status check.
Reading the result
At done, calls_wait returns the call. The fields you will usually want:
| Field | What |
|---|---|
outcome | The structured result the agent recorded with its outcome tool — the fields depend on the agent (for a purchasing agent: availability, price, delivery time and so on). |
outcomeSource | agent if the agent recorded the outcome during the call; transcript if it did not and the outcome was filled from the transcript afterwards. Treat the latter with a little more care. |
summary | A short written account of the conversation, in the agent's summary language. |
status | completed_action, incomplete_call, transferred, failed or in_progress. |
keyFacts, nextSteps | Lists drawn from the conversation. |
durationSeconds, startedAt | When and how long. |
id | The call's id; calls_get reads it again later. |
Transcripts and recordings are not part of the API. They are in the Zvani web app.
Tools
Every tool is also a REST endpoint; tool names are operation ids with _ in place of ..
| Tool | REST | Scope | What |
|---|---|---|---|
me_get | GET /me | any | The workspace and scopes this key carries. |
agents_list | GET /agents | read | The workspace's agents. |
agents_get | GET /agents/{id} | read | One agent, with its placeholders and languages. |
calls_place | POST /calls/place | place_calls | Ring a number with an agent. |
calls_callback | POST /calls/callback | callbacks | Ring back a number that has called before, with the reason. |
agentTools_list | GET /agents/{id}/custom-tools | custom_tools | An agent's own tools; header values are never shown. |
agentTools_put | PUT /agents/{id}/custom-tools/{name} | custom_tools | Add or replace a tool, optionally calling an HTTP endpoint. |
agentTools_test | POST /agents/{id}/custom-tools/{name}/test | custom_tools | Call a tool's endpoint once. |
agentTools_delete | DELETE /agents/{id}/custom-tools/{name} | custom_tools | Remove a tool. |
calls_wait | POST /calls/wait | read | Wait for a placed call and read its outcome. |
calls_list | GET /calls | read | All calls, newest first, paginated. |
calls_get | GET /calls/{id} | read | One call. |
calls_dailyStats | GET /calls/daily-stats | read | Per-day counts, transfers, score and talk time. |
contacts_get | GET /contacts/{number} | read | Everything the workspace knows about one number. |
campaigns_list, campaigns_get | GET /campaigns | read | Outbound campaigns. |
usage_get | GET /usage | read | Metered usage over a recent window. |
The same over REST
All paths are under https://zvani.lv/api/v1, bodies and answers are JSON.
1. Read the agent
curl https://zvani.lv/api/v1/agents \ -H "Authorization: Bearer $ZVANI_KEY"
2. Place the call
curl -X POST https://zvani.lv/api/v1/calls/place \
-H "Authorization: Bearer $ZVANI_KEY" \
-H "Content-Type: application/json" \
-d '{
"toNumber": "+37120000000",
"agentId": "ag_...",
"variables": { "part_number": "123456789" },
"name": "Jonas"
}'Answers 202 (status is calling or queued):
{
"placementId": "camp_...",
"status": "calling",
"agentId": "ag_...",
"toNumber": "+37120000000",
"unfilled": []
}3. Wait for the result
curl -X POST https://zvani.lv/api/v1/calls/wait \
-H "Authorization: Bearer $ZVANI_KEY" \
-H "Content-Type: application/json" \
-d '{ "placementId": "camp_...", "timeoutSeconds": 60 }'{
"placementId": "camp_...",
"status": "done",
"call": {
"id": "lead_...",
"status": "completed_action",
"durationSeconds": 74,
"summary": "...",
"outcome": { /* fields of the agent's outcome tool */ },
"outcomeSource": "agent"
}
}Repeat step 3 while status is not done, not_reached or failed.
Errors
Every error has the same shape: a stable code, a hint written to be read by a person or an AI, and sometimes details.
{
"error": "agent_required",
"hint": "this workspace has several agents; name one with agentId",
"details": { "agents": [ /* id, name, direction */ ] }
}| HTTP | Code | What to do |
|---|---|---|
| 401 | not_authenticated | No key, or the key is unknown or revoked. |
| 403 | forbidden | The key lacks the scope this needs — usually place_calls. |
| 403 | workspace_suspended | The key is valid but its workspace is suspended; nothing to do on your side until it is active again. |
| 403 | outbound_disabled | The workspace has outbound calls turned off; an owner turns them on in billing. |
| 400 | validation_failed | An argument is missing or malformed; details names it. |
| 400 | bad_number | toNumber is not 6–15 digits with an optional +. |
| 400 | agent_required | Several agents and none named; details.agents lists them. |
| 400 | agent_has_no_number | The agent has no phone number to call from; set one in the web app. |
| 404 | agent_not_found | No enabled agent with that id in this workspace. |
| 404 | not_found | No placement or call with that id in this workspace. |
| 404 | not_a_caller | calls_callback only: nobody from this number has called the workspace. Use calls_place with the place_calls scope. |
| 402 | quota_exhausted | The workspace has used up its plan; details.reasons says which limit. |
| 402 | plan_limit | The plan allows no more of what you are creating (agents, numbers, members…); details says the limit. |
| 409 | idempotency_conflict | The clientId or Idempotency-Key was used within the day for a call with another number, agent or variables. Use a new id. |
| 409 | too_many | The workspace already holds as many webhook subscriptions or API keys as it may; remove one first. |
| 422 | rate_above_limit | The workspace checks numbers before calling and a minute to this one costs more than its limit, or cannot be priced; details carries eurPerMinute and maxEurPerMinute. |
| 422 | invalid_url | A webhook or tool URL is not a public https address; details.reason says why. |
| 429 | rate_limited | Too many requests; wait Retry-After seconds. |
| 502 | not_placed | The carrier did not accept the call; details.lastError says why. |
| 503 | telephony_not_configured | Calling is not set up for this installation; contact Zvani. |
Limits and cost
- 120 requests per minute per key. Above that the answer is
429withRetry-After. - Calls cost minutes. Each placed call is billed to the workspace like any other call. A key with
place_callscan spend them; give it only to programs you trust. - One attempt per placement. A call that is not answered is not retried; place it again if you want another try.
- Don't poll for new calls. To react to calls as they finish, subscribe a webhook instead.
Webhooks
To hear about calls as they finish, subscribe a URL. Every finished call that raises one of the events you chose is POSTed to it once its summary is ready. Subscriptions are what the Zapier, Make and n8n apps use; a script of your own can use them the same way with a key that has the read scope.
curl -X POST https://zvani.lv/api/v1/webhooks \
-H "Authorization: Bearer $ZVANI_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/zvani", "events": ["call.completed", "call.needs_callback"] }'The answer carries the subscription's id and its secret, shown only this once. Subscribing the same URL again with the same key answers the same subscription (with the events you name now) rather than a second one, so a workflow switched off and on does not double its deliveries. The events:
call.completed— every call, once its summary, score and outcome are ready (at once, where the workspace has no post-call analysis);call.needs_callback— the caller asked to be called back;call.transferred— the call was handed to a person;campaign.finished— an outbound campaign has no contacts left to ring; the delivery carriescampaigninstead ofcall;lead.updated,booking.created,order_request.created,sms.sent— a finished call's record changed afterwards (marked reviewed, its callback done), the agent booked an appointment, wrote down an order request, or sent the caller a text. Delivered as they happen: a booking or a text during the call, an order request when it is taken, and a call's record each time it changes.
Each delivery is {"event", "deliveredAt", "call"}, where call has the shape of GET /calls/{id} (campaign.* carries campaign, the shape of GET /campaigns/{id}). It comes with X-Zvani-Event, X-Zvani-Timestamp — milliseconds since the epoch — and X-Zvani-Signature: the hex HMAC-SHA256 of timestamp + "." + body with the secret. Check it before you trust the body, and refuse a timestamp more than a few minutes old: a captured delivery cannot then be replayed later. X-Zvani-Event-Id names the event (the same across retries and across your subscriptions) and X-Zvani-Delivery-Id this one delivery; keep the event ids you have handled and drop a repeat, because a delivery is made at least once, not exactly once.
import { createHmac, timingSafeEqual } from 'node:crypto';
function fromZvani(rawBody, headers, secret) {
const expected = createHmac('sha256', secret)
.update(`${headers['x-zvani-timestamp']}.${rawBody}`)
.digest('hex');
const got = Buffer.from(String(headers['x-zvani-signature'] ?? ''));
const fresh = Math.abs(Date.now() - Number(headers['x-zvani-timestamp'])) < 5 * 60 * 1000;
return fresh && got.length === expected.length && timingSafeEqual(got, Buffer.from(expected));
}Answer 2xx quickly and do the work afterwards. A timeout, a 408, a 429, a 5xx or no answer is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then given up; any other 3xx or 4xx is final — a redirect is not followed. Answer 410 and the subscription is removed. A subscription whose deliveries have all failed for a day is switched off (disabledAt in GET /webhooks); subscribing the URL again switches it back on. GET /webhooks lists the subscriptions this key made (an owner or admin signed in to the web app sees the whole workspace's) and DELETE /webhooks/{id} removes one. URLs must be public https.
The webhook module in the web app is a different, older thing: one URL typed into the panel, which receives a flat JSON object per finished call — event, leadId, callerPhone, direction, status, durationSeconds, summary, score and so on — signed the same way, with three in-process attempts. New integrations should subscribe through the API.
Shop and website plugins
Ready plugins give the agent a shop's orders, products and delivery prices during the call, over MCP, and keep the pages you choose in the knowledge base through PUT /knowledge/pages/{externalId} with a key of the knowledge scope: WordPress and WooCommerce and OpenCart. Every integration is listed on zvani.lv/integrations.
A whole website as a source
Without a plugin, give the site's address once and Zvani finds its pages itself: through robots.txt and the sitemaps it names (or /sitemap.xml), or, when there is none, by following links from the start page three deep within the site. Every page with enough text becomes a document the agent answers from; the first sync starts at once, and the site is read again daily or weekly. A later sync reads only pages whose sitemap date or text changed, and takes out pages the site no longer lists. Disallow rules in robots.txt are honoured, requests are paced to two a second, and a page's title and address are kept with its text. Same key scope, knowledge; up to three sites per workspace.
POST /api/v1/knowledge/sites
{
"url": "https://www.yourshop.lv", // https only, a public address
"include": "/help\n/delivery", // optional: keep only paths starting with these, one per line
"exclude": "/blog", // optional: leave these out
"frequency": "weekly" // or "daily"; weekly when omitted
}
201
{
"id": "kbs_...", "url": "https://www.yourshop.lv/", "include": "/help\n/delivery", "exclude": "/blog",
"frequency": "weekly", "maxPages": 100, "status": "idle", "pages": 0, "createdAt": "..."
}Once a sync has run, the same object also carries lastStartedAt, lastFinishedAt, nextRunAt and lastResult — { found, added, updated, unchanged, removed, skipped, failed, error? } — and pages says how many of its pages are in the knowledge base now. status is idle, running or failed; a failed sync keeps its counts and puts the reason in lastResult.error, and is tried again within the hour.
| Operation | REST | What |
|---|---|---|
knowledge.sites.list | GET /knowledge/sites | Every site of the workspace, as above, in items. |
knowledge.sites.add | POST /knowledge/sites | Add a site; 201 with the site, and the first sync starts. |
knowledge.sites.update | PATCH /knowledge/sites/{id} | Change url, include, exclude or frequency; only the fields sent change, and they apply at the next sync. |
knowledge.sites.run | POST /knowledge/sites/{id}/run | Read the site again now; 202 with the site marked running. Refused for ten minutes after the last sync started. |
knowledge.sites.remove | DELETE /knowledge/sites/{id} | Remove the site and every page it put in; { "deleted": true }. |
maxPages is how many pages a site may keep on the workspace's plan: 20 on Free, 100 on Starter, 300 on Business, 1000 on Corporate. Pages found beyond that are counted as skipped. The errors these operations add to the common ones:
| HTTP | Code | What to do |
|---|---|---|
| 400 | not_https | The address is not an https:// web address. |
| 422 | private_url | The address points at a private or unresolvable host; the site must be reachable from the internet. |
| 409 | already_added | This address is a source of the workspace already; run it again instead. |
| 409 | site_limit | The workspace has three sites; remove one first. |
| 429 | too_soon | The last sync started less than ten minutes ago; details.retryAfterSeconds says how long to wait. The schedule runs it anyway. |
n8n, Make, Zapier
The n8n community node is ready for self-hosted n8n; the Zapier and Make apps are coming. They give you, without writing code: a trigger when a call finishes (or needs a callback, or went to a person), actions to place a call and request a callback, and searches for a call or a phone number's history. Until the Zapier and Make apps are listed, you can use the pieces they are built from:
- n8n — the MCP Client Tool node in an AI Agent workflow: endpoint
https://zvani.lv/api/v1/mcp, transport HTTP Streamable, authentication Bearer Auth with your key; every tool on this page appears in the agent's toolbox. For a trigger, a Webhook node whose production URL you subscribe. - Make and Zapier — their HTTP modules with the same key for the REST operations, and a custom webhook (Make) or Catch Hook (Zapier) whose URL you subscribe.
Reference
- OpenAPI 3.1 document — every public operation with its full input and output schemas. Opens in Swagger Editor, Postman or Insomnia, and works as a tool definition for AI frameworks.
- Discovery — a JSON list of the operations and how to authenticate, readable without a key.
Questions: info@zvani.lv