The API works on the same agents, calls and Credits as the web dashboard. Anything you build in the dashboard can be driven from your own systems, and the other way round.
| What | Endpoint |
|---|---|
| List agents | GET /api/v1/voice/agents |
| Create an agent | POST /api/v1/voice/agents |
| Get / update an agent | GET · PATCH /api/v1/voice/agents/{id} |
| Place an outbound call | POST /api/v1/voice/calls |
| List calls | GET /api/v1/voice/calls |
| Get a call (transcript) | GET /api/v1/voice/calls/{id} |
| Delete a call | DELETE /api/v1/voice/calls/{id} |
| Delete a caller's calls | POST /api/v1/voice/calls/delete-by-caller |
| Campaigns | GET · POST /api/v1/voice/campaigns |
| Get a campaign | GET /api/v1/voice/campaigns/{id} |
| Start / pause / cancel | POST /api/v1/voice/campaigns/{id}/{action} |
| Cloned voices | GET · POST /api/v1/voice/voices |
| Delete a cloned voice | DELETE /api/v1/voice/voices/{id} |
| Do-not-call list | GET · POST /api/v1/voice/do-not-call |
| Remove from the list | DELETE /api/v1/voice/do-not-call/{phone} |
| Languages, voices, templates | GET /api/v1/voice/catalog |
| An agent's tests | GET · POST /api/v1/voice/agents/{id}/tests |
| Run tests / read a run | POST /api/v1/voice/agents/{id}/test-runs · GET /api/v1/voice/test-runs/{id} |
| Route a number to an agent | PATCH /api/v1/voice/phone-numbers/{id} |
| Word lists | GET · POST /api/v1/voice/vocabularies |
Create a key under Integrations → API keys in the dashboard (or with one click on the overview) and send it as a Bearer token:
Authorization: Bearer sk-your-key
A key acts in the workspace its owner works in, with that person's role: a read-only member's key can read but not change anything.
Create an agent from a template, optionally setting its fields in the same request. Without template it starts blank; GET /api/v1/voice/catalog lists the templates with the languages and voices an agent takes. Templates are written in English and Chinese, so for another language send system_prompt and first_message in it. Either the whole request is saved or no agent is made, so a refused request can be sent again once corrected. The answer's dashboard_url is the agent's page in the dashboard, where you can talk to the draft in your browser. Every agent runs on EvaSay ASR, EvaSay LLM and EvaSay TTS, chosen by EvaSay for each call's language and load and included in the call price; there are no other providers' accounts to set.
curl https://evasay.com/api/v1/voice/agents \
-X POST \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{
"name": "Front desk",
"language": "en",
"first_message": "Hi, thanks for calling Sunrise Salon. How can I help?",
"system_prompt": "You are the front desk of Sunrise Salon..."
}'
Update any of these fields with PATCH /api/v1/voice/agents/{id}:
| Field | Notes |
|---|---|
name, first_message, system_prompt | Text |
status | published publishes the draft as a new version; draft stops taking calls |
language | One of 27 languages, e.g. en, zh, es, de, or multi to follow the caller |
temperature | 0-100 |
max_duration_sec, silence_timeout_sec | Call limits |
interruptible, end_call_enabled | Booleans |
confirm_details | Boolean; say numbers and names back and have them confirmed |
ai_disclosure | Boolean, default true; before its first message the agent says in the call's language that the caller is speaking with an AI (left out when the first message already says so) |
voicemail_detection | Boolean; outbound calls listen first and end on a mailbox |
voicemail_message | Left on a mailbox, may use {{variables}}; none to hang up |
transfer_number | Number to transfer to, international format |
transfer_destinations | Up to 9 more numbers, [{name, number, when}]; the agent picks one by when |
transfer_briefing | Boolean; the person who answers first hears the agent's summary of the call |
pronunciations | Up to 100 [{word, say, case_sensitive}]; the voice says say for word |
idle_messages | Up to 3 reminders said in turn to a silent caller before the call ends |
noise_reduction | Boolean; the caller's background noise is filtered before they are heard |
recording_enabled | Boolean; calls are recorded in two channels (caller left, agent right) |
recording_consent | announce, ask (kept only if the caller agrees) or none |
recording_notice | The notice or question said first; empty for the built-in one in the call language |
handoffs | Up to 5 [{agent_id, when}]: other agents this one may hand a call to |
knowledge_base_id | One of your knowledge bases |
vocabulary_id | One of your vocabularies; callers saying its terms are heard more reliably |
voice_id | A built-in EvaSay voice id, or one of your cloned voices (clone:…) |
Built-in voices depend on the language: Chinese has ming-designed-female-clear, ming-designed-female-warm (female), ming-designed-male-clear and ming-designed-male-friendly (male), the other languages F1–F5 (female) and M1–M5 (male). A built-in voice that does not speak the agent's language becomes the voice of the same gender that does. Any other value, or a cloned voice that is not ready yet, is refused. The older field name tts_voice_id is still accepted.
analysis_config sets what is worked out from each call once it ends: summary (default true), summaryLanguage, successCriteria (what a successful call achieves), fields (up to 20 {name, type, description} to extract, type being string, number or boolean) and scorecard (up to 10 {name, description, weight}, weight 1–5). The results are in the call's analysis and the call.analyzed webhook.
Changing an agent's fields changes its draft. Calls keep using the published version until you publish the draft with POST /api/v1/voice/agents/{id}/publish (optionally with a note), or with status: "published" in the same PATCH. Each publish keeps a numbered version; published_version in the agent says which one calls use. A call stays on the version that was live when it started, so publishing during a call does not change it. Test calls from the dashboard use the draft.
GET /agents/{id}/versions lists the versions, and POST /agents/{id}/versions/{version}/restore rolls back: new calls use that version and the draft becomes it. The agent's name, deployment, widget look and notifications are not versioned and change at once, as do the documents of its knowledge base.
A test plays one call in text: a simulated caller acts out scenario, the agent answers from its draft (instructions, first message and knowledge), and a judge checks the conversation against success_criteria. Write tests for the calls that matter, run them after each change, and publish when they pass.
curl https://evasay.com/api/v1/voice/agents/AGENT_ID/tests \
-X POST \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{
"name": "Move a booking",
"scenario": "A regular customer wants to move Friday 3 pm to Saturday morning.",
"success_criteria": "Offers Saturday times and confirms the new time and the customer'"'"'s name.",
"max_turns": 8
}'
POST /agents/{id}/test-runs runs all of the agent's tests, or those in test_ids, and answers with one run each (status: "running"). Poll GET /test-runs/{id} until status is passed, failed or error; the run then has the transcript and the judge's reason (in English, or Chinese with reason_language: "zh"). Each run costs Credits, shown with the tests in the dashboard; runs stop at the first the balance cannot pay for, and not_started lists the rest. A run that fails to play is refunded. Tests check what the agent says, not its voice or timing, and do not call its functions: preview those with POST /tools/{id}/test. An agent has up to 30 tests; PATCH and DELETE /agents/{id}/tests/{test_id} change or remove one.
Besides whole calls (type: "simulation", the default), a test can check one reply. type: "next_reply" takes chat_history, the conversation before it ending with the caller ([{role, text}], role being caller or agent), and success_criteria for the agent's next reply. type: "tool_call" takes chat_history and expected_tool {name, arguments, match}: the agent should call name (one of its functions, or end_call, transfer_call, handoff_to_agent) with arguments, compared exactly (text trimmed, case ignored) or, with match: "judge", by meaning; arguments it passes besides these are ignored. A run's transcript shows the functions the agent called in tool_calls.
POST /agents/{id}/tests/generate drafts tests from the agent's draft: its instructions, knowledge and functions. It takes count (1–10, default 5), types, focus (what to cover) and language (en or zh, for names and criteria), costs Credits like one run, and answers with drafts to review and send to POST /agents/{id}/tests; with save: true it saves them.
hooks does something at a moment of the call, up to 10 rules: on: "silence" (the caller has said nothing for seconds, 3–600, since the agent finished; once until they speak again), on: "time_elapsed" (seconds into the call, 10–3600) or on: "call_ending". Each does up to 3 of {type: "say", text}, {type: "transfer", destination?}, {type: "end_call", text?} and {type: "tool", name, args?}; a rule for the call's end only runs functions. A call a rule ends has ended_reason rule_ended.
On phone calls the keys a caller presses reach the agent as their answer: an entry ends with # or a two-second pause. On outbound calls the agent can also press keys to get through a company's phone menu.
transfer_number is the agent's main number for handing a caller to a person, and the number rung when the workspace is out of credits. transfer_destinations adds named numbers, each with a note on when to use it ("Billing: questions about invoices and refunds"), and the agent chooses. With transfer_briefing on, the agent writes a short summary as it transfers; the person who picks up hears it, in an EvaSay voice and in the agent's summary language, before the caller is put through. A call's ended_reason is then transferred.
With handoffs, an agent can pass a call to another published agent of your workspace, such as a front desk passing a booking to a booking agent. The agent chooses by when. The other agent takes over the same call with everything said so far, and speaks with its own instructions, voice, knowledge, tools and handoffs. The call stays listed, analyzed and billed under the first agent; its transcript notes each handoff.
Numbers are connected in the dashboard with your own carrier account. GET /api/v1/voice/phone-numbers lists them, and PATCH /api/v1/voice/phone-numbers/{id} with { "agent_id": "AGENT_ID" } sends the number's calls to an agent (its published version), or with "agent_id": null to none; label renames it.
A word list holds names and terms callers say that a recognizer would otherwise mishear, such as product, drug or street names. POST /api/v1/voice/vocabularies with { "name": "Products", "terms": ["Lumora", "Vantix Pro"] } (up to 1,000 terms of 50 characters, or "preset": "medical" or "legal" to start from), then set the agent's vocabulary_id. GET, PATCH (name, terms) and DELETE /vocabularies/{id}; a list an agent uses cannot be deleted.
Each number has call rules on the Phone numbers page, applied before the agent answers: blocked numbers or number starts (+1555), and callers who hide their number, are refused unanswered and cost nothing; listed callers can be put straight through to another number; and when the agent cannot answer (not published, no worker, out of credits without a transfer number) the call can ring a number or hear your own message. A start lookup (run_at_start) can also turn a caller away by answering {"reject": true, "message": "..."}: the agent says the message, if any, and hangs up, with ended_reason rejected.
Place one call from one of your workspace's phone numbers. Every request needs a unique Idempotency-Key; sending the same key again returns the same call instead of dialing twice.
curl https://evasay.com/api/v1/voice/calls \
-X POST \
-H "Authorization: Bearer sk-your-key" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "AGENT_ID",
"to": "+14155550100",
"variables": { "customer_name": "Alex", "order_id": "1234" }
}'
from is required when the workspace has more than one phone number. variables fill {{customer_name}}-style placeholders in the agent's first message and prompt.
GET /api/v1/voice/calls/{id} returns the call's status, duration, Credits charged, summary, analysis and transcript. List calls with GET /api/v1/voice/calls?agent_id=&status=&channel=&user_id=&page= (user_id: your own id for the person, from a web call token). Its turns are the call's turn-by-turn debug log: where the time went from the caller finishing to hearing the answer, in milliseconds — waitMs (end of speech), sttMs (recognition), llmMs (model first word, one more per round of functions), ttsMs (voice first audio), firstAudioMs (heard the answer) — with the functions called, interruptions and errors. The call page shows it too.
With recording_enabled, each call is recorded as a two-channel WAV: the caller on the left, the agent on the right, at the call's sample rate. With recording_consent: "announce" the agent first says the call is recorded; with "ask" it asks, and the recording is kept only if the caller agrees (no answer counts as no); with "none" nothing is said. Check what the law requires where you and your callers are.
Recordings are kept in EvaSay's storage, or in your own S3-compatible bucket (AWS S3, Cloudflare R2, Google Cloud Storage, Supabase, MinIO) set under Integrations → Webhooks & notifications → Recording storage. The worker running the call uploads the file straight to the bucket. GET /api/v1/voice/calls/{id} returns a recordingUrl valid for an hour once the recording is stored, and recording.status (recording, stored, declined or failed).
DELETE /api/v1/voice/calls/{id} deletes an ended call with its transcript, recording (in EvaSay's storage or your own bucket), analysis and webhook delivery records. The Credits it used stay in your credit history. A call that cannot be deleted yet answers 409 with code call_not_deletable_yet: it is still under way or ended moments ago, it is still being billed, its recording may still be uploading, or it owes Credits your balance could not pay (add Credits first).
When a person asks you to delete their data, POST /api/v1/voice/calls/delete-by-caller with { "phone_number": "+14155550123" } deletes every call with that number (the caller's number on calls in, the number called on calls out), or with { "user_id": "..." } every web call made with that user_id in its token. It answers { "deleted": 12, "kept_back": { "settling": 1 }, "more": false }: at most 200 calls a request, so ask again while more is true, and again later for the calls in kept_back. The number stays on your do-not-call list if it is there. The dashboard does the same under Calls → Delete a caller's data.
Send POST /api/v1/voice/calls/{id}/control while a call is in progress. The worker running the call picks the command up within about 2 seconds; the API answers 202. If the worker running the call cannot be reached, the API answers 503 (code call_unreachable) and the command is not carried out.
type | What happens |
|---|---|
say | The agent says text after its current sentence, or at once with "interrupt": true |
note | The agent is told text (not spoken) and takes it into account from its next reply; the transcript notes it |
end | The agent says text if given, then hangs up; a phone call without text is hung up at once |
The call's ended_reason is then api_ended. The call page on the dashboard has the same controls.
POST /api/v1/voice/calls/{id}/listen (optionally { "channels": 2 }) returns a listen_url you can open within two minutes. It is a WebSocket: first a JSON message {"type":"start","sampleRate":8000,"channels":1,"encoding":"pcm_s16le"}, then binary frames of 16-bit little-endian PCM with what the caller hears (both voices mixed; with channels: 2, the caller on the left and the agent on the right). It closes when the call ends. The audio comes straight from the worker running the call, not through EvaSay's control plane; the Listen button on the dashboard's call page uses the same address.
Send and receive a call's audio from your own program, for example your own phone system or app. Start the call:
curl https://evasay.com/api/v1/voice/calls/websocket \
-X POST \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{ "agent_id": "AGENT_ID", "encoding": "pcm_s16le", "sample_rate": 16000 }'
The websocket_url in the answer carries a one-time token and can be opened within 15 minutes. encoding is pcm_s16le (16-bit little-endian PCM at a sample_rate of 8000, 16000 or 24000) or mulaw (8 kHz μ-law). Audio goes both ways as binary frames in that format, ideally 20 ms each. Text frames are JSON:
| Direction | Messages |
|---|---|
| You → EvaSay | {"type":"dtmf","digits":"12#"} key presses; {"type":"end"} hangs up; {"type":"text","text":"…"} words the caller typed ("input":"button" for a tapped reply); {"type":"context","text":"…","key":"cart"} what your app reports (the agent does not answer it); {"type":"tool_result","id":"…","result":{…}} (or "error") a page function's answer |
| EvaSay → you | start (with callId); clear (the caller interrupted: drop agent audio not played yet); transcript (role, text, final, and input for typed words); user_started_speaking, user_stopped_speaking, agent_started_speaking, agent_stopped_speaking; cards (cards, quick_replies); tool (id, name, arguments: run one of your page functions); notice (code: too_fast or limit for typed words); ended (with reason, then the socket closes) |
The agent greets as soon as you connect. user_id and page_tools (the page functions your app answers, see below) may be added to the request. Calls are billed per minute like any other, with the same call records, analysis and webhooks.
On a web call (the widget, the hosted page, your app) the caller sees a chat beside the voice, so the agent can do more than speak. Phone callers get none of this.
Turn on Agent → Tools → Send to the caller (send_to_caller in the agent API) and the agent can put up to five cards at a time on the caller's screen: a title, a line under it, a description, a picture, a link, and up to two reply buttons each; it can also offer up to three quick replies under a question. Links are never read out.
The agent may only send links and pictures it was given in that call: its instructions and greeting, the knowledge base (a document read from a web page carries its address), your functions' answers and the ready-made cards. A link must also be on one of allowed_hosts (a ready-made card's own site always is). Links the agent makes up, or that the caller reads out, are not sent. Links on those websites in the agent's words are clickable in the chat.
"send_to_caller": {
"enabled": true,
"allowed_hosts": ["shop.example.com", "*.cdn.example.com"],
"presets": [
{ "name": "hours", "when": "asked when we open", "title": "Opening hours",
"description": "Mon–Sat 9:00–18:00", "url": "https://shop.example.com/stores", "image_url": "" }
]
}
Each send is sent to you as the call.message_sent webhook (channel, items with title and url, quickReplies, blocked). A call sends at most 10 times.
During the call the caller can also type (an email address, an order number) or tap a reply button; the agent answers it like speech, stopping first if it was talking. Up to 500 characters a message, one a second, 100 a call. In the call record these turns are marked as typed or tapped.
The widget gives your page an evasay object. Everything is optional, and the call frame only listens to your page when it is one of the widget's allowed websites (Agent → Deploy):
<script src="https://evasay.com/voice-widget.js" data-agent="AGENT_SLUG" async></script>
<script>
window.evasay = window.evasay || [];
evasay.push(function (agent) {
agent.setVariables({ plan: 'pro', page: location.pathname }); // for the next call
agent.update('Cart: 2 items, $120', { key: 'cart' }); // during it
agent.registerTool('add_to_cart', async function (args) {
// your code, in the visitor's browser
return { ok: true, checkout: 'https://shop.example.com/checkout' };
});
agent.on('cards', function (cards) {}); // also 'live', 'ended'
});
</script>
setVariables values fill {{name}} in the instructions and greeting. They come from the visitor's browser, so the agent is told they are not verified, and they never reach your functions or the start lookup.update tells the agent what happens on the page without making it answer; a newer update with the same key replaces the older one."kind": "page", no url) to the agent, and register a function of the same name on your page. The agent may call it only on calls from a page that registered it; it waits up to timeout_sec (1–10, default 5) for the answer unless wait_for_result is false. It runs signed in as the visitor, like a click of theirs: ask for confirmation in your own page before anything that costs money or cannot be undone. Links in its answer may be sent as cards.Set Who can start a call to Only with a token, and have your server get a one-time token for the signed-in user:
curl https://evasay.com/api/v1/voice/web-calls \
-X POST \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{ "agent_id": "AGENT_ID", "user_id": "u_42", "variables": { "name": "Ann" }, "expires_in": 600 }'
The answer has token (one call, within expires_in seconds, 30–3600, default 600), agent (the slug) and web_call_url. Give the token to the page with data-token-url="/your/endpoint" on the widget's script (it POSTs there with the visitor's cookies and reads { "token": "…" }) or agent.setToken(async () => token). The token's variables are verified: the instructions, the start lookup and your functions' templates may use them, as may user_id, which the call keeps (userId in the call, user_id to filter calls). Keep your API key on your server.
In your own app, POST { "token": "…", "pageTools": ["add_to_cart"] } to https://evasay.com + web_call_url. The answer is the call's connection (a WebRTC offer address, or a WebSocket address, with its call token), for an RTVI client such as Pipecat's client SDKs for the web, iOS, Android and React Native. Cards, page functions and notices arrive as RTVI server-messages (evasay.cards, evasay.tool, evasay.notice); send typed words with send-text and page reports and answers as client-messages evasay.context and evasay.tool_result. Or carry the audio yourself with Audio over a WebSocket, passing user_id and page_tools.
Under Phone numbers → SIP your own PBX, SIP phones or SIP trunk can call your agents directly; your SIP provider bills the calls:
sip:ev…@<EvaSay's SIP domain>, calls in only, for PBXs (3CX, FreePBX, Asterisk…) and SIP phones.from); transfers to a person are handed to the trunk by REFER. When the trunk asks for credentials, EvaSay answers with the trunk username and password you entered.Calls in sign in by digest with the username and password EvaSay gives you (the realm is the SIP domain); you can also limit the source IPs, or accept by IP alone when your provider cannot sign in (the IPs are then required). Each SIP address or trunk has a limit of calls at once; beyond it calls get 486. Passwords are shown once, when created or reset. UDP and G.711 (PCMU, PCMA) only; key presses by RFC 4733 or SIP INFO; turn registration (REGISTER) off on your PBX's trunk.
A campaign calls a list of contacts with one agent, within calling hours and a concurrency limit (max_concurrency, up to 100 calls at once, and never more than your workspace's limit on calls at once), and retries people it could not reach.
curl https://evasay.com/api/v1/voice/campaigns \
-X POST \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{
"name": "Appointment reminders",
"agent_id": "AGENT_ID",
"phone_number_id": "PHONE_NUMBER_ID",
"timezone": "America/New_York",
"window_start": "09:00",
"window_end": "18:00",
"max_concurrency": 2,
"max_attempts": 2,
"retry_delay_min": 60,
"consent_confirmed": true,
"contacts": [
{ "phone": "+14155550100", "name": "Alex", "appointment": "Tuesday 3 pm" }
]
}'
Contacts may also be CSV text with the phone number in the first column. consent_confirmed: true is required: with it you confirm that everyone on the list agreed to be called, including by an AI voice, and EvaSay keeps that confirmation with the campaign (consent_confirmed_at). The campaign is created as a draft; start it with POST /api/v1/voice/campaigns/{id}/start, and use pause or cancel the same way. The agent must be published before a campaign starts.
With voicemail_detection on (the default), an outbound call listens for the other side to speak before the agent greets them; if they stay silent, the agent greets them after two or three seconds. When the agent hears a mailbox or a phone company's recording, it waits for the greeting and its beep to end, leaves voicemail_message, or hangs up when there is none. Such calls end with ended_reason voicemail, are charged for the time they lasted, and are not analyzed or notified.
In a campaign, a number that reached a mailbox is called again like one that did not answer, and the message is left only on its last attempt, so nobody gets the same message twice.
Every outbound call, from a campaign or from POST /api/v1/voice/calls, first checks the workspace's do-not-call list. A listed number is never dialed: the API answers 409 with code do_not_call, and a campaign marks the contact canceled with outcome do_not_call and goes on to the next one.
When the person on a call the agent placed asks not to be called again, the agent puts their number on the list itself, tells them so, and the call.do_not_call webhook is sent. Numbers can also be added and removed under Campaigns → Do-not-call list or with the API:
curl https://evasay.com/api/v1/voice/do-not-call \
-X POST \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{ "phones": ["+14155550100"], "note": "Asked by email" }'
Numbers are in international format; up to 1,000 at once. GET /api/v1/voice/do-not-call lists them, newest first (search, limit, page), each with its source (call, dashboard or api). DELETE /api/v1/voice/do-not-call/%2B14155550100 takes one off again; do that only when the person has agreed to be called.
POST /api/v1/voice/voices creates a voice from a WAV recording of one speaker (multipart audio, name, language, consent=true). It answers 202 with status running; poll GET /api/v1/voice/voices until it is ready, then set an agent's voice_id to its voice_id. language is the language spoken in the recording, any of the agent languages above; the voice speaks all of them but Chinese, where a built-in voice of the same gender answers, and sounds most like the speaker in the recording's language. Each cloned voice costs Credits, shown in the dashboard before you create one.
Add an https address under Integrations → Webhooks & notifications to receive call events:
| Event | When |
|---|---|
call.started | The agent picked up |
call.ended | The call ended, with its duration and credits |
call.analyzed | The summary, outcome, extracted fields and transcript are ready |
call.transcript | During the call, the new lines of the transcript every few seconds |
call.tool_called | The agent called one of your functions: name, arguments, status, durationMs |
call.handed_off | The call was handed to another agent: fromAgentId, toAgentId |
call.transferred | The call was transferred to a person: destination |
call.voicemail | An outbound call reached a mailbox |
call.do_not_call | The person on an outbound call asked not to be called again: phone |
call.recording_ready | The two-channel recording is stored: recordingUrl (valid 24 hours), durationMs |
call.message_sent | The agent showed cards on a web or app caller's screen: items, quickReplies |
alert.triggered | One of your alerts (Webhooks & notifications → Alerts) crossed its threshold |
alert.resolved | That alert is back to normal |
Events sent during a call carry data.call (id, agentId, channel, direction, numbers). Each request is signed:
EvaSay-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
EvaSay-Delivery: <delivery id>
Verify the signature with the webhook secret shown when you created the webhook, and answer with a 2xx status. Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours with the same body and EvaSay-Delivery id, so you can ignore repeats. A webhook's latest deliveries, with the exact body sent and the status your endpoint answered, are listed by GET /webhooks/{id}/deliveries and on the dashboard; POST /webhooks/{id}/deliveries/{delivery_id} sends one again.
Besides email and the alert.triggered / alert.resolved webhooks, alerts can go to a Slack channel (incoming webhook), a WeCom group robot or Telegram under Webhooks & notifications → Alerts → Also send to.
Errors use one shape:
{
"error": {
"type": "invalid_request_error",
"code": "invalid_agent_field",
"message": "status must be draft or published",
"param": "status"
}
}
| Status | Meaning |
|---|---|
| 400 | Invalid input (code and param say which) |
| 401 | Missing or invalid API key |
| 402 | Not enough Credits to start the call |
| 403 | The key is read-only, or the agent is unavailable |
| 404 | No such agent, call or campaign in this workspace |
| 409 | Wrong state (e.g. starting a running campaign), a provider account is not connected, or the number is on the do-not-call list (do_not_call) |
| 429 | Too many requests; slow down and retry |
The workspace's Voice API requests of the last 30 days (time, endpoint, status, duration, key and error code; no bodies) are listed under API keys → API request log.
Reads are limited to 120 requests a minute per key, writes and calls to 30.
Calls are billed per minute from the workspace's Credits, the same balance the dashboard uses. The current rates are on the pricing page.
Use the OpenAPI contract to generate an integration client. The public API, dashboard and MCP management endpoint operate on the same workspace resources and permissions.
| Capability | Endpoint |
|---|---|
| Reusable tools | GET / POST /api/v1/voice/tools |
| Read / update / delete a tool | GET / PATCH / DELETE /api/v1/voice/tools/{id} |
| Preview or test a tool | POST /api/v1/voice/tools/{id}/test |
| Bind tools to an Agent | PUT /api/v1/voice/agents/{id}/tools |
| Knowledge bases | GET / POST /api/v1/voice/knowledge-bases |
| Read / update / delete a knowledge base | GET / PATCH / DELETE /api/v1/voice/knowledge-bases/{id} |
| Add / list documents | POST / GET /api/v1/voice/knowledge-bases/{id}/documents |
| Read / delete a document | GET / DELETE /api/v1/voice/knowledge-bases/{id}/documents/{doc_id} |
| Call-event subscriptions | GET / POST /api/v1/voice/webhooks |
| Read / update / delete a subscription | GET / PATCH / DELETE /api/v1/voice/webhooks/{id} |
| Send a signed test event | POST /api/v1/voice/webhooks/{id}/test |
| Discover remote MCP tools | POST /api/v1/voice/mcp/discover |
| MCP management endpoint | /api/v1/voice/mcp |
Register an HTTP tool that queries your CRM adapter:
{
"name": "lookup_customer",
"description": "Find a customer after confirming their customer ID.",
"kind": "http",
"url": "https://crm.example.com/customers/{{customer_id}}",
"method": "GET",
"read_only": true,
"credential_ref": "crm",
"input_schema": {
"type": "object",
"properties": { "customer_id": { "type": "string" } },
"required": ["customer_id"],
"additionalProperties": false
},
"response_path": "data"
}
POST /tools returns { "data": { "id": "TOOL_ID", ... } }. Bind its definition to an Agent with PUT /agents/AGENT_ID/tools and { "tool_ids": ["TOOL_ID"] }. This replaces the entire tool list with snapshots; rebind when a library tool changes. Deleting a library definition keeps existing snapshots. You can also read and replace inline definitions with the Agent API's tools field. Use analysis_config and widget_config to configure analysis and the widget through the Agent API.
A tool can use GET, POST, PUT, PATCH or DELETE. query and request_body accept nested JSON templates such as { "customer": { "id": "{{customer_id}}" } }. An entire placeholder preserves the original JSON type. URL placeholders are encoded and may only appear after a fixed public HTTPS host. Without request_body, non-GET tools receive { "arguments": {...}, "call": { "callId": "...", "agentId": "...", "toolCallId": "...", "idempotencyKey": "..." } }. response_path selects a field using dot notation. Simple parameters are still accepted; input_schema additionally supports nested objects, arrays, enums and numeric bounds. External schema references and regular expressions are not accepted.
static_args: values sent with every call whatever the model writes, such as a store id; they also fill {{name}} in the address and body.extract: [{name, path}] keeps fields of a function's answer (data.id) for later functions' {{name}}, so a lookup's customer_id can go into the order that follows without the model repeating it. A start lookup's fields are kept the same way.reject_when: up to 5 rules {on: caller|agent, pattern, negate}; while all hold of the last words, the function is not run and the agent gets reject_message.model_says: with each call the model writes one sentence that is said while the function runs, instead of the fixed speak_before.Connect an app under Integrations → Webhooks & notifications → Connected apps, then use "Add a ready-made function" on the agent's Knowledge & tools tab; no endpoint of your own is needed:
check_availability (free times on a date) and book_appointment (books the time the caller chose). Google Calendar times follow the time zone, open days, hours and appointment length you set; Cal.com uses the event type's own availability, and a fallback email you set when the caller gives none.save_to_sheet, which appends a row with the columns you list, plus the call's ID and time.notify_team, which posts a message the agent writes to a Slack channel (incoming webhook).The EvaSay control plane runs these functions with the account you connected; neither the worker nor the agent definition holds the app's credentials. Reading an agent through the API shows them as kind: "app" (with app, action, connection_id, app_config); send them back unchanged when replacing tools to keep them. Apps are connected on the dashboard only.
Set run_at_start: true on one of an agent's tools and it is called as each call starts, before the agent greets or answers. Parameters named caller_number, called_number, direction, channel or call_id are filled in for it. The fields it returns fill {{variables}} in first_message, system_prompt and idle_messages (fields one object down as {{customer.name}}), and the whole answer is given to the agent as background. A greeting with variables waits up to 3 seconds for the answer; anything not found is left blank. One tool per agent can run at the start.
Credentials are configured in the server environment for your workspace and destination; ask support@evasay.com to provision a credential_ref. Do not put API keys in Agent definitions, headers, prompts or tool parameters. Your adapter can keep CRM credentials in its own environment.
Preview a tool before making requests to the business system:
{
"arguments": { "customer_id": "customer-123" },
"dry_run": true,
"mock_result": { "name": "Alex", "order_status": "shipped" }
}
A preview validates parameters and shows the mapped request; it does not run a conversation or contact the business system. Set dry_run=false for a real execution. A live write test also requires confirmed=true and idempotency_key (8–128 letters, numbers, dots, underscores, colons or hyphens). Test responses include ok, status, duration_ms and a bounded result. They do not return credentials. Read and write tests require workspace write permission.
During a call, business requests carry a stable Idempotency-Key. Your business endpoint must honor it. Identical writes within a call are deduplicated, including operations with unknown outcomes after a timeout. A different intentional operation needs a distinct business argument, such as operation_id. EvaSay does not automatically replay writes. Tool execution events include status and duration; the conversation test feature remains a text simulation, not a tool-execution test.
Configure a Streamable HTTP MCP connection with:
URL: https://evasay.com/api/v1/voice/mcp
Authorization: Bearer <your EvaSay API key>
In Claude Code, for example:
claude mcp add --transport http evasay https://evasay.com/api/v1/voice/mcp --header "Authorization: Bearer $EVASAY_API_KEY"
The connection tells the tool how to build an agent: read the catalog, create a draft, add knowledge, functions and word lists, write tests and run them, fix the draft until they pass, give you the agent's dashboard_url to try it, and publish and route a number only once you approve. Tools that read docs can start from https://evasay.com/llms.txt, which links this page, the OpenAPI contract and the MCP address.
Use the client's secure token or environment-variable setting. The endpoint uses API-key authentication, not an OAuth login flow. It exposes the catalog, Agent creation and configuration, tests, library tools, knowledge, word lists, number routing, webhooks and call records. Publishing and placing real calls are write actions; outbound calls consume Credits and carrier fees. Every tool call rechecks workspace permissions. Enable your client's confirmation controls for write actions. The endpoint uses stateless JSON responses; it does not offer a background notification stream.
An example instruction for the coding tool is: “Build an EvaSay agent that answers my salon's phone in English and Spanish and books haircuts with my booking API. Write tests for booking, moving and cancelling, run them until they pass, then show me the agent's page to try it.” Review and explicitly authorize publication and real calls.
POST /mcp/discover accepts { "url": "https://crm.example.com/mcp", "credential_ref": "crm" } and returns the server's tools and schemas. Register only the selected tool:
{
"name": "lookup_customer",
"description": "Find a CRM customer by confirmed ID.",
"kind": "mcp",
"url": "https://crm.example.com/mcp",
"mcp_tool": "get_customer",
"credential_ref": "crm",
"read_only": true,
"input_schema": {
"type": "object",
"properties": { "customer_id": { "type": "string" } },
"required": ["customer_id"]
}
}
Only registered tools become available to the Agent. Remote connections support Streamable HTTP with JSON or SSE responses to requests, public HTTPS destinations, and preconfigured credentials. Legacy SSE endpoints, OAuth negotiation, server-initiated sampling/elicitation and binary attachments are not supported. Discovery does not automatically grant access to all server tools. The remote tool schema must be inline and use the supported JSON Schema subset.
Create a webhook through POST /webhooks with { "url": "https://crm.example.com/evasay-events", "events": ["call.analyzed"] }. The response contains the subscription and a generated signing secret, returned only at creation. Verify EvaSay-Signature, deduplicate EvaSay-Delivery, and update your CRM. Event webhooks are asynchronous notifications; in-call tools return results directly to the Agent.