This is the full developer documentation for MoreVoice # Build voice AI that works with your people > AI voice agents, a cloud phone system with human agents, and a real-time copilot, on one platform and one API. Hebrew first, with Israel's calling rules built in. ## Start here [Quickstart](/get-started/quickstart/)Your first AI phone call in test mode, in five minutes. [What is MoreVoice](/get-started/what-is-morevoice/)The building blocks, and the life of a call from first ring to summary. [Authentication and API keys](/get-started/authentication/)Secret, restricted and publishable keys, scopes and API versions. [Test mode](/get-started/test-mode/)Simulated calls, test numbers and no billing, for building and CI. ## Build [Assistants](/guides/assistants/)The AI on the call: instructions, voice, speech recognition and Hebrew tips. [Flows](/guides/flows/)Guide a call step by step: AI flows, IVR menus and agent scripts. [Phone numbers and SIP](/guides/phone-numbers-and-sip/)Connect your lines as SIP trunks or registration accounts. [Inbound routing](/guides/inbound-calls/)Send each call to an assistant, a flow, a queue or a person. [Outbound calls](/guides/outbound-calls/)Place calls with variables, metadata and idempotency. [Compliance](/guides/compliance/)§30A consent, the do-not-call list and registry, disclosure, and Shabbat calling hours. [Human agents and queues](/guides/human-agents/)Invite agents, build queues, watch the floor live and act on agents' calls. [Copilot](/guides/copilot/)Real-time help for human agents: objection answers, knowledge, checklists and disclosures. [QA](/guides/qa/)A scorecard for every call, your rubric, and insights into what wins. [Web widget](/guides/web-widget/)A talk button for any web page, or your own call UI in the browser. [Real-time events](/guides/realtime/)Follow calls as they happen: live webhook events and the event log. ## Reference [API reference](/api/)Every endpoint, generated from the OpenAPI document, with a test-mode playground. [Webhooks](/webhooks/setup/)Signed events about calls, campaigns and more, with retries and replay. [SDKs](/sdk/node/)The Node.js and Python clients and their helpers. [Build with AI](/get-started/build-with-ai/)llms.txt, a Markdown version of every page, and the docs MCP server. # Authentication, API keys and test mode > How requests are authenticated: secret, restricted and publishable keys, scopes, rolling and revoking keys, API versions, and what test mode does differently from live mode. Every request to the API carries an API key in the `Authorization` header: ```http Authorization: Bearer mv_live_sk_… ``` The key says which organisation the request acts for, what it may do (its **scopes**) and whether it works on real calls (**live mode**) or simulated ones (**test mode**). Requests go over HTTPS only. The API doesn’t accept the dashboard’s session cookies: a request without a key answers `401` with the code [`api_key_missing`](/guides/errors-and-limits/#api-key-missing). * cURL ```sh # Check the key: which organisation, which mode, which scopes. curl https://api.morevoice.ai/v1/whoami \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js check-key.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Check the key: which organisation, which mode, which scopes. const me = await mv.whoami.retrieve(); console.log(`${me.org_id} · ${me.livemode ? "live" : "test"} mode · API version ${me.api_version}`); ``` * Python check_key.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Check the key: which organisation, which mode, which scopes. response = requests.get(f"{API}/whoami", headers=HEADERS, timeout=30) response.raise_for_status() me = response.json() mode = "live" if me["livemode"] else "test" print(f"{me['org_id']} · {mode} mode · API version {me['api_version']}") ``` `GET /v1/whoami` is the quickest check of a key: it answers with the organisation, the key’s ID, its mode, its scopes and the API version. ## Key types | Type | Looks like | For | Can do | | --------------- | ------------------------------- | ------------------------------ | -------------------------------------------------------------------------- | | **Secret** | `mv_live_sk_…` / `mv_test_sk_…` | Your own backend | Everything (scope `*`). | | **Restricted** | `mv_live_sk_…` / `mv_test_sk_…` | Integrations and third parties | Only the scopes you pick. | | **Publishable** | `mv_live_pk_…` / `mv_test_pk_…` | A web page | Only start web calls (`web_calls:write`), only from the websites you list. | After the prefix comes a 32-character random body (letters and digits). The format is fixed, so you can look for leaked keys by pattern: `mv_(live|test)_(sk|pk)_[0-9A-Za-z]{32}`. Secret and restricted keys belong on a server Never put one in a web page, a mobile app or a repository: anyone who sees it can act for your organisation. The SDK refuses a secret key in a browser. If a key leaks, [roll it](#rolling-a-key) at once. Publishable keys come with the web widget Publishable keys can be created now, but the endpoint they open, `POST /v1/web_calls` (talk to an assistant from a web page), arrives with the browser SDK. ## Create a key Keys are created in the dashboard by an administrator or the owner of the organisation: 1. Open DevelopersAPI keys and choose the mode: **Live** or **Test**. 2. Select **Create key** (or **Create test key**), name it after where it will be used (for example “Production backend”), and pick the **Key type**. 3. For a restricted key, pick its **Permissions**: start from a preset and adjust. You can’t grant permissions your own role doesn’t have. 4. Optionally, under **Advanced: IP allow-list and expiry**, limit the IP addresses or ranges (CIDR) the key works from, and set an expiry date (up to 3 years). 5. Copy the key. **It is shown once**: MoreVoice stores only a hash of it. **Copy as .env line** gives you `MOREVOICE_API_KEY=…` ready to paste. Creating and rolling a key asks you to confirm your password. Every new, rolled or revoked key is announced to the organisation’s administrators in the dashboard, and new and rolled keys by email too. The key list shows when each key was last used and from which IP address. ## Scopes A restricted key can do only what its scopes allow. A `:write` scope includes the matching `:read` one, and a secret key holds `*`, every scope. A request without the scope it needs answers `403` with the code [`missing_scope`](/guides/errors-and-limits/#missing-scope), and `details.required_scope` names the scope. | In the dashboard | Scopes | Note | | --------------------- | --------------------------------------------- | --------------------------- | | Assistants | `assistants:read`, `assistants:write` | | | Voices | `voices:read` | | | Custom tools | `tools:write` | | | Flows | `flows:read`, `flows:write` | | | Calls | `calls:read`, `calls:write` | | | Recordings | `recordings:read` | | | Transcripts | `transcripts:read` | | | Web calls | `web_calls:write` | | | SIP connections | `connections:read`, `connections:write` | | | Phone numbers | `phone_numbers:read`, `phone_numbers:write` | | | Inbound routes | `inbound_routes:read`, `inbound_routes:write` | | | Campaigns | `campaigns:read`, `campaigns:write` | | | Contacts | `contacts:read`, `contacts:write` | | | Queues | `queues:read`, `queues:write` | | | Users | `users:read`, `users:write` | | | Teams | `teams:read`, `teams:write` | | | Agent status | `agents:read` | | | Knowledge base | `kb:read`, `kb:write` | | | Copilot profiles | `copilot:read`, `copilot:write` | | | Quality scores | `qa:read`, `qa:write` | | | Insights | `insights:read` | | | Callbacks | `callbacks:read`, `callbacks:write` | | | Conference rooms | `conference:read`, `conference:write` | | | Audio files | `audio_assets:read`, `audio_assets:write` | | | Secrets | `secrets:read`, `secrets:write` | Organisation administration | | Do-not-call list | `dnc:read`, `dnc:write` | | | Consents | `consents:read`, `consents:write` | | | Webhooks | `webhooks:read`, `webhooks:write` | | | Events | `events:read` | | | Usage | `usage:read` | | | Billing | `billing:read`, `billing:write` | Organisation administration | | Organization settings | `org:read`, `org:write` | Organisation administration | | Members | `members:read`, `members:write` | Organisation administration | | API keys | `api_keys:read`, `api_keys:write` | Organisation administration | | Audit log | `audit:read` | Organisation administration | | Security settings | `security:write` | Organisation administration | | Client organizations | `children:read`, `children:write` | Organisation administration | The dashboard offers presets to start from: | Preset | For | Scopes | | --------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Read only | Reads everything except organisation administration. | Every `:read` scope above but organisation administration (27) | | Campaigns integration | Pushes contacts into campaigns, follows their results and keeps consent and the do-not-call list in sync. | `assistants:read`, `calls:read`, `recordings:read`, `transcripts:read`, `campaigns:write`, `contacts:write`, `dnc:write`, `consents:write`, `webhooks:write`, `events:read` | | CRM integration | A CRM that places and logs calls: click-to-call, call history, callbacks and contacts. | `assistants:read`, `calls:write`, `recordings:read`, `transcripts:read`, `contacts:write`, `queues:read`, `users:read`, `callbacks:write`, `webhooks:write`, `events:read` | The scope list is fixed for the life of the API, so a restricted key you create today keeps working as the API grows. ## Rolling a key **Roll key** replaces a key with a new one that has the same name, scopes and allow-lists. Choose how long the old key keeps working, so you can deploy the new one first: **Stop it now**, **1 hour**, **24 hours** or **7 days**. After that the old key answers `401` with the code [`key_expired`](/guides/errors-and-limits/#key-expired). ## Revoking a key **Revoke** stops a key immediately and for good: its requests answer `401` with the code [`key_revoked`](/guides/errors-and-limits/#key-revoked). To change a restricted key’s scopes, create a new key and revoke the old one. ## When a key is refused | HTTP | Code | Why | | ---- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | | 401 | [`api_key_missing`](/guides/errors-and-limits/#api-key-missing) | No `Authorization: Bearer` header. | | 401 | [`invalid_api_key`](/guides/errors-and-limits/#invalid-api-key) | The key is malformed or doesn’t exist. | | 401 | [`key_revoked`](/guides/errors-and-limits/#key-revoked) / [`key_expired`](/guides/errors-and-limits/#key-expired) | The key was revoked, expired, or replaced by a roll. | | 401 | [`ip_not_allowed`](/guides/errors-and-limits/#ip-not-allowed) | The request came from outside the key’s IP allow-list. | | 403 | [`missing_scope`](/guides/errors-and-limits/#missing-scope) | The key lacks the scope this endpoint needs. | | 403 | [`test_mode_disabled`](/guides/errors-and-limits/#test-mode-disabled) | A test key, but test mode isn’t on for the organisation yet. | | 403 | [`livemode_mismatch`](/guides/errors-and-limits/#livemode-mismatch) | A test key used on a live object, or the other way round. | | 403 | [`publishable_key_not_allowed`](/guides/errors-and-limits/#publishable-key-not-allowed) | A publishable key on an endpoint meant for servers. | Every error has the same shape; see [errors, rate limits and concurrency](/guides/errors-and-limits/). ## Test mode and live mode Test keys (`mv_test_…`) run the same API on simulated calls: a virtual carrier instead of the phone network, simulated speech and AI, and no billing. Calls, campaigns, contacts, events and webhook endpoints are separate in each mode (every object has a `livemode` field), while assistants, flows and inbound routes are shared. [Test mode](/get-started/test-mode/) explains what is simulated, the test numbers and the limits. ## API versions The API changes in dated versions, like `2026-11-01`. A key is pinned to the version that was current when you created it (the key’s details show it), so new versions never change the answers your code gets. To try a newer version, send it in the `MoreVoice-Version` header, one request at a time: * cURL ```sh # Answer this request in a given API version, whatever the key is pinned to. curl https://api.morevoice.ai/v1/whoami \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "MoreVoice-Version: 2026-11-01" ``` * Node.js api-version.ts ```ts import MoreVoice from "@morevoice/sdk"; // The SDK sends the API version its types describe on every request. Pass `version` to choose // another one, or `version: null` to use the version the key is pinned to. const mv = new MoreVoice({ version: "2026-11-01" }); // Or for one request only: const me = await mv.whoami.retrieve({ headers: { "MoreVoice-Version": "2026-11-01" } }); console.log(me.api_version); ``` * Python api_version.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Answer this request in a given API version, whatever the key is pinned to. response = requests.get( f"{API}/whoami", headers={**HEADERS, "MoreVoice-Version": "2026-11-01"}, timeout=30, ) response.raise_for_status() print(response.json()["api_version"]) ``` Every response carries the `MoreVoice-Version` it was answered in. A date that isn’t a version answers `400` with the code [`unknown_version`](/guides/errors-and-limits/#unknown-version). Changes that don’t break your code (new endpoints, new optional fields, new event types, new values of an enumeration) come in every version; see the [versioning policy](/changelog/versioning/). ## Request IDs Every response has an `X-Request-Id` header (`req_…`), and every error repeats it as `request_id`. Log it with your own request: it is the fastest way for us to find a request when you contact support. # Build with AI assistants > Give Claude, Cursor, ChatGPT or any AI assistant the MoreVoice docs: llms.txt, a Markdown version of every page, the OpenAPI description, and a docs MCP server. Many developers now start an integration by asking an AI assistant. These docs are built for that: every page has a plain Markdown version, the whole site is available as one file, and a small MCP server lets an assistant search and read the docs while it helps you write code. ## Copy a page Every page has a **Copy page** button next to its title. It copies the page as Markdown, ready to paste into a chat. The menu beside it opens the Markdown in your browser, or starts a new chat in Claude or ChatGPT that reads the page for you. The Markdown version of any page is its address without the trailing slash, plus `.md`: | Page | Markdown | | -------------------------------------------------------- | ---------------------------------------------------------- | | `https://docs.morevoice.ai/get-started/quickstart/` | `https://docs.morevoice.ai/get-started/quickstart.md` | | `https://docs.morevoice.ai/api/operations/calls_create/` | `https://docs.morevoice.ai/api/operations/calls_create.md` | | `https://help.morevoice.ai/he/flows/` | `https://help.morevoice.ai/he/flows.md` | Code samples come in all three languages, one after another, and notes and warnings become quotes, so nothing on the page is lost. ## The whole site in one file The docs publish the [llms.txt](https://llmstxt.org) files assistants look for: | File | What it holds | | ------------------------------------ | ------------------------------------------------------------------------- | | [`/llms.txt`](/llms.txt) | An index: what MoreVoice is, and links to the files below. | | [`/llms-full.txt`](/llms-full.txt) | Every page of the developer docs in one Markdown file. | | [`/llms-small.txt`](/llms-small.txt) | The same, without notes and tips, for smaller context windows. | | [`/openapi.json`](/openapi.json) | The API’s OpenAPI 3.1 description, with code samples for every operation. | The help centre, which explains the web app, has its own: `https://help.morevoice.ai/llms.txt` and `https://help.morevoice.ai/llms-full.txt` (in Hebrew; every article has an English version under `/en/`). ## The docs MCP server `https://docs.morevoice.ai/mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server. Connect it to your assistant, and it can search the developer docs and the help centre, then read the pages it needs, instead of guessing from what it learned in training. It is free, needs no account or key, and only reads public docs. * Claude Code ```sh claude mcp add --transport http morevoice-docs https://docs.morevoice.ai/mcp ``` * Claude In Claude (the web app or Claude Desktop), open **Settings › Connectors**, choose **Add custom connector**, name it `MoreVoice Docs` and enter `https://docs.morevoice.ai/mcp`. * Cursor Add it to `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` for every project): .cursor/mcp.json ```json { "mcpServers": { "morevoice-docs": { "url": "https://docs.morevoice.ai/mcp" } } } ``` * VS Code Add it to `.vscode/mcp.json` in your workspace: .vscode/mcp.json ```json { "servers": { "morevoice-docs": { "type": "http", "url": "https://docs.morevoice.ai/mcp" } } } ``` Any other client that speaks MCP over Streamable HTTP works the same way: the URL is all it needs. ### Tools | Tool | What it does | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `search_docs` | Searches both sites and returns the five best pages with their URLs. Arguments: `query` (English or Hebrew), and optionally `lang` (`en` or `he`; by default the language of the query), `site` (`docs` for the developer docs, `help` for the help centre) and `limit` (1–10). | | `get_page` | Returns one page as Markdown. Argument: `url`, a page URL from `search_docs`. | Then ask as you normally would, for example: “Using the MoreVoice docs, write a Node.js script that calls a test number and prints the transcript.” Docs, not your account The docs MCP server only reads the public documentation. It can’t see or change anything in your MoreVoice organisation, and it never needs an API key. ## Tips * **Point the assistant at a test key.** Ask it to read `MOREVOICE_API_KEY` from the environment and to start with a test key (`mv_test_sk_…`): see [test mode](/get-started/test-mode/). Test calls are simulated and never billed. * **Let it check the API description.** Assistants are good at reading [`/openapi.json`](/openapi.json): field names, required parameters and error codes come straight from it. * **Verify what matters.** Read the code before you run it against live calls, especially anything that dials numbers or changes compliance settings. # Quickstart: your first AI phone call > Create a test key, an assistant and a call to a test number, then read what was said. About five minutes, with no phone line and no AI spend. In about five minutes you will create an AI assistant that speaks Hebrew, have it call a test number, and read the conversation. Everything runs in **test mode**: speech recognition, the language model and the voice are simulated, the call goes to a virtual phone network instead of a real one, and nothing is billed. When it works, a live key runs the same code on real calls. Each step shows the request in cURL, in Node.js with `@morevoice/sdk`, and in Python with `requests`. Pick a tab once and every page follows your choice. The API is in beta The API is switched on per organisation during the beta. Ask us to join; until then, `/v1` answers `404` for your organisation and the dashboard has no API keys. ## Before you start * A MoreVoice account where you are an administrator or the owner: API keys are created in the dashboard. * For Node.js: Node 18 or later and `npm install @morevoice/sdk`. For Python: Python 3.10 or later and `pip install requests`. For cURL: `curl`, plus `jq` for step 4. 1. **Create a test key** In the dashboard, open DevelopersAPI keys, switch **Mode** to **Test** and select **Create test key**. Give the key a name, keep the type **Secret**, and copy the key. You see it only once. It starts with `mv_test_sk_`. Keep it in an environment variable, so it never ends up in your code: ```sh export MOREVOICE_API_KEY=mv_test_sk_… ``` Check that the key works. The answer says which organisation and mode it belongs to: * cURL ```sh # Check the key: which organisation, which mode, which scopes. curl https://api.morevoice.ai/v1/whoami \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js check-key.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Check the key: which organisation, which mode, which scopes. const me = await mv.whoami.retrieve(); console.log(`${me.org_id} · ${me.livemode ? "live" : "test"} mode · API version ${me.api_version}`); ``` * Python check_key.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Check the key: which organisation, which mode, which scopes. response = requests.get(f"{API}/whoami", headers=HEADERS, timeout=30) response.raise_for_status() me = response.json() mode = "live" if me["livemode"] else "test" print(f"{me['org_id']} · {mode} mode · API version {me['api_version']}") ``` Response ```json { "object": "api_key_principal", "org_id": "org_7n42DGM5Tflk9n8mt7Fhc9", "key_id": "key_9i2E2pKO6g3z4nXl57Qb4g", "livemode": false, "scopes": ["*"], "api_version": "2026-11-01" } ``` 2. **Create an assistant** An assistant is the AI on the call: what it says first, what it knows, how it sounds. This one calls patients to confirm an appointment. `{{customer_name}}` is a variable that each call fills in. * cURL ```sh # Create an assistant that calls patients to confirm tomorrow's appointment, in Hebrew. curl https://api.morevoice.ai/v1/assistants \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Appointment reminders", "language": "he", "first_message": "שלום {{customer_name}}, כאן מרפאת השרון. רציתי לאשר את התור שלך מחר בעשר. זה עדיין מתאים?", "system_prompt": "You call patients to confirm tomorrow'"'"'s appointment. Speak Hebrew and keep every answer short. If the time no longer suits them, say that the clinic will call back to find a new one." }' ``` * Node.js create-assistant.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Create an assistant that calls patients to confirm tomorrow's appointment, in Hebrew. const assistant = await mv.assistants.create({ name: "Appointment reminders", language: "he", first_message: "שלום {{customer_name}}, כאן מרפאת השרון. רציתי לאשר את התור שלך מחר בעשר. זה עדיין מתאים?", system_prompt: "You call patients to confirm tomorrow's appointment. Speak Hebrew and keep every answer short. " + "If the time no longer suits them, say that the clinic will call back to find a new one.", }); console.log(assistant.id); // asst_… ``` * Python create_assistant.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Create an assistant that calls patients to confirm tomorrow's appointment, in Hebrew. response = requests.post( f"{API}/assistants", headers=HEADERS, json={ "name": "Appointment reminders", "language": "he", "first_message": "שלום {{customer_name}}, כאן מרפאת השרון. רציתי לאשר את התור שלך מחר בעשר. זה עדיין מתאים?", "system_prompt": ( "You call patients to confirm tomorrow's appointment. Speak Hebrew and keep every answer short. " "If the time no longer suits them, say that the clinic will call back to find a new one." ), }, timeout=30, ) response.raise_for_status() assistant = response.json() print(assistant["id"]) # asst_… ``` The answer is the whole assistant, with defaults for everything you didn’t set (the voice, speech recognition, turn-taking). Keep its ID: ```sh export ASSISTANT_ID=asst_… ``` [Create and configure an assistant](/guides/assistants/) explains every setting, with tips for Hebrew. 3. **Call a test number** `+972500000001` is a test number that answers: a simulated caller talks to your assistant for a few turns, then hangs up. * cURL ```sh # Call the test number that answers. With a test key the call is simulated: no phone rings. curl https://api.morevoice.ai/v1/calls \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "to": "+972500000001", "assistant_id": "'"$ASSISTANT_ID"'", "purpose": "service", "variables": { "customer_name": "דנה" } }' ``` * Node.js call-test-number.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Call the test number that answers. With a test key the call is simulated: no phone rings. // The SDK sends an Idempotency-Key with every POST, so a retry never places a second call. const call = await mv.calls.create({ to: "+972500000001", assistant_id: process.env.ASSISTANT_ID!, purpose: "service", variables: { customer_name: "דנה" }, }); console.log(call.id, call.status); // call_… queued ``` * Python call_test_number.py ```python import os import uuid import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Call the test number that answers. With a test key the call is simulated: no phone rings. # The Idempotency-Key is required: retrying with the same key never places a second call. response = requests.post( f"{API}/calls", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json={ "to": "+972500000001", "assistant_id": os.environ["ASSISTANT_ID"], "purpose": "service", "variables": {"customer_name": "דנה"}, }, timeout=30, ) response.raise_for_status() call = response.json() print(call["id"], call["status"]) # call_… queued ``` - `purpose` is required on every outbound call. It decides which compliance checks run. It is `service` here, because the call is about an existing appointment. - `Idempotency-Key` is required too. If your request times out, send it again with the same key: MoreVoice returns the first answer and never dials twice. The SDK adds a key for you. The call starts as `queued`. Keep its ID: Response (201 Created) ```json { "id": "call_8tRPaZp5hLMbrGqdJ9AmNa", "object": "call", "type": "ai", "direction": "outbound", "transport": "sip", "status": "queued", "from": null, "to": "+972500000001", "assistant_id": "asst_4Gk2LmN9pQ4rS6tV8wX0yZ", "purpose": "service", "started_at": "2026-11-03T09:14:22.000Z", "answered_at": null, "ended_at": null, "duration_ms": null, "end_reason": null, "summary": null, "cost": null, "variables": { "customer_name": "דנה" }, "metadata": {}, "livemode": false } ``` The call object has more fields than shown here; [call objects](/guides/call-objects/) lists them all. 4. **Follow the call and read the transcript** A call goes from `queued` to `ringing`, `in_progress` and `ended`. Wait for the end, then read what was said: * cURL ```sh # Wait for the call to end (a call.ended webhook tells you without polling). while true; do status=$(curl -s https://api.morevoice.ai/v1/calls/$CALL_ID -H "Authorization: Bearer $MOREVOICE_API_KEY" | jq -r .status) [ "$status" = "ended" ] && break sleep 2 done # Then read what was said. curl https://api.morevoice.ai/v1/calls/$CALL_ID/transcript \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js follow-call.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const callId = process.env.CALL_ID!; // Wait for the call to end (a call.ended webhook tells you without polling). const call = await mv.calls.waitUntilEnded(callId); console.log(`Ended: ${call.end_reason}, ${call.duration_ms} ms`); // Then read what was said. const transcript = await mv.calls.retrieveTranscript(callId); for (const segment of transcript.segments) console.log(`${segment.speaker}: ${segment.text}`); ``` * Python follow_call.py ```python import os import time import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} call_id = os.environ["CALL_ID"] # Wait for the call to end (a call.ended webhook tells you without polling). while True: response = requests.get(f"{API}/calls/{call_id}", headers=HEADERS, timeout=30) response.raise_for_status() call = response.json() if call["status"] == "ended": break time.sleep(2) print(f"Ended: {call['end_reason']}, {call['duration_ms']} ms") # Then read what was said. response = requests.get(f"{API}/calls/{call_id}/transcript", headers=HEADERS, timeout=30) response.raise_for_status() for segment in response.json()["segments"]: print(f"{segment['speaker']}: {segment['text']}") ``` Transcript ```json { "object": "transcript", "call_id": "call_8tRPaZp5hLMbrGqdJ9AmNa", "language": "he", "segments": [ { "speaker": "assistant", "text": "שלום דנה, כאן מרפאת השרון. רציתי לאשר את התור שלך מחר בעשר. זה עדיין מתאים?", "start_ms": 820, "end_ms": 5900, "interrupted": false, "tool": null }, { "speaker": "customer", "text": "כן, מתאים לי", "start_ms": 6400, "end_ms": 7700, "interrupted": false, "tool": null } ] } ``` In test mode the caller’s lines come from a script and the AI’s from a simulated model, so they read like a test, not like a real conversation. The post-call summary is a fixed stand-in too: no AI model runs in test mode. ## Test numbers `+972500000001` answers, as in step 3. Other test numbers are busy, reach an answering machine, are on the do-not-call list, ring with no answer or fail, so you can test every path of your code: see [test mode](/get-started/test-mode/#test-numbers). Test numbers exist only in test mode; a live key dials them like any other number. ## Get told when the call ends Polling is fine for a first test. In production, let MoreVoice tell you: add a webhook endpoint and handle the `call.ended` event, then `call.analyzed` when the summary is ready. Test-mode events go only to test-mode endpoints, so your tests never reach production systems. See [webhooks](/webhooks/setup/). ## Go live 1. Create a live key (`mv_live_sk_…`) the same way, with **Mode** on **Live**. 2. Connect a phone line and choose the number to call from: [phone numbers and SIP](/guides/phone-numbers-and-sip/) shows how to connect a SIP trunk, and [outbound calls](/guides/outbound-calls/) how to set the caller ID with `from_number_id`. 3. Read [compliance](/guides/compliance/) before you call customers: §30A consent for marketing, the do-not-call lists, and calling hours around Shabbat and holidays. ## Next steps * [Authentication and API keys](/get-started/authentication/): key types, scopes and rotation; [test mode](/get-started/test-mode/): what is simulated and what isn’t. * [Outbound calls](/guides/outbound-calls/): variables, metadata, the call purpose and idempotency. * [Campaigns](/guides/campaigns/): call a whole list within calling hours. * [The API reference](/api/), with a [test-mode playground](/api/playground/). # Test mode > Build and test your integration on simulated calls: test keys, test numbers, what is simulated, what test mode shares with live mode, and its limits. A test key (`mv_test_sk_…`) runs the same API on **simulated calls**. Calls go to a virtual carrier instead of the phone network; speech recognition, the language model and the voice are simulated; and nothing is billed. Use test mode to build your integration and to test it, in CI too, without a phone line and without AI spend. When it works, a live key (`mv_live_sk_…`) runs the same code on real calls. Create a test key in DevelopersAPI keys with **Mode** set to **Test** (see [authentication](/get-started/authentication/#create-a-key)). Switched on per organisation during the beta Until test mode is on for your organisation, the dashboard says so and test keys answer `403` with the code [`test_mode_disabled`](/guides/errors-and-limits/#test-mode-disabled). ## What test mode does differently | | Live mode | Test mode | | --------------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Calls | Real calls over your SIP connections | A virtual carrier with [test numbers](#test-numbers); no phone network is ever reached | | Speech recognition, AI model, voice | The real providers | Simulated; the post-call summary and quality score are fixed stand-ins | | Billing | Rated and billed | Never billed | | Calls, campaigns, contacts, events, webhook endpoints | Live objects (`"livemode": true`) | Separate test objects (`"livemode": false`): each mode sees only its own | | Assistants, flows, inbound routes, queues, knowledge base | Shared by both modes | Shared by both modes | | SIP connections, the do-not-call list, consents | Available | Live only. Test keys can read the do-not-call list and consents; anything else answers `403` with the code [`live_only`](/guides/errors-and-limits/#live-only) | | Webhooks | Sent to live endpoints | Sent to test endpoints only | | Limits | Your plan’s | Fewer calls at once and per day, and half the request rate | Every object of the API has a `livemode` field, so you can always tell which mode it belongs to. A test key can’t touch a live object, nor a live key a test one: that answers `403` with the code [`livemode_mismatch`](/guides/errors-and-limits/#livemode-mismatch). ## Test numbers In test mode every call goes to the virtual carrier. These numbers make it behave like the real world, so you can test every path of your code: | Number | What happens | | ---------------- | ------------------------------------------------------------------------------------------------------ | | `+972500000001` | Answers. A simulated caller talks for a few turns, then hangs up. | | `+972500000002` | Busy. The call ends without being answered. | | `+972500000003` | An answering machine answers (`answered_by: "machine"`). | | `+972500000004` | On the do-not-call list: the request is refused before anything is dialled, with a `compliance_error`. | | `+972500000005` | Rings with no answer for 30 seconds. | | `+972500000006` | The carrier fails. | | Any other number | Answers, like `+972500000001`. | The same behaviours answer on `+15005550001` to `+15005550006` if you prefer North American numbers. On `+972500000003`, answering-machine detection runs even when your call or assistant has it off, so `answered_by` is always set; with `amd.on_machine: "leave_message"` the assistant leaves its message after the beep. To test inbound calls, create a sandbox number (`POST /v1/phone_numbers` with a test key, pointing at your assistant) and have a simulated caller dial it with `POST /v1/test_helpers/inbound_calls`. `POST /v1/test_helpers/events` sends a sample event to your test-mode webhook endpoints. The caller’s lines come from a script and the assistant’s from a simulated model, so a test call reads like a test, not like a real conversation. The [quickstart](/get-started/quickstart/) places one. Caution Test numbers exist only in test mode. A live key dials them like any other number. ## Webhooks in test mode Test-mode events (a test call ended, a test campaign finished) go only to webhook endpoints created with a test key, so your tests never reach production systems. Create a separate endpoint for your test environment, and check `livemode` on every event you receive. ## Limits Test mode has its own, smaller limits: fewer calls in progress at once, a cap on test calls per 24 hours, and half the request rate of your plan. The numbers per plan are in [errors, rate limits and concurrency](/guides/errors-and-limits/#limits-per-plan). ## Go live 1. Create a live key (`mv_live_sk_…`) the same way, with **Mode** on **Live**, and keep it on your server only. 2. Connect a phone line: [phone numbers and SIP](/guides/phone-numbers-and-sip/). 3. Read [compliance](/guides/compliance/) before you call customers: §30A consent for marketing, the do-not-call lists, and calling hours around Shabbat and holidays. 4. Recreate the live objects your code expects (webhook endpoints, campaigns): test objects never become live ones. # What is MoreVoice > MoreVoice is a Hebrew-first contact-centre platform with AI built in: AI voice agents, a cloud phone system with human agents in the browser, and a real-time copilot, on one platform and one API. MoreVoice runs a whole contact centre on one platform: **AI voice agents** that answer and make calls, a **cloud phone system** that routes calls to people working in the browser, and a **copilot** that helps those people while they talk. AI and people share the same calls, so a conversation can move between them without dropping the line or losing the context. It is built for Hebrew first: Hebrew speech recognition and voices, a right-to-left interface, and Israel’s calling rules (§30A consent, the do-not-call registry, Shabbat and holidays) enforced on every outbound call. ## The building blocks AI agents An assistant answers or places a call, understands speech, replies with a natural voice, and uses tools: it can look things up on your server, transfer the call, or end it. It stops when the caller interrupts, and it knows what to do with silence. Flows and IVR A visual flow guides a conversation step by step: questions, conditions, API calls, transfers. The same editor builds keypad and voice menus (IVR) for inbound lines, with opening hours, Shabbat and holidays. Phone system Connect your numbers over SIP, route each one by the number dialled and the caller, and call from the browser over WebRTC. Calls can be held, transferred, merged into conferences and recorded. Human agents Queues distribute calls to people working in the browser or from a Chrome extension, with callbacks, conference rooms, and a supervisor who can listen, whisper or join. Copilot While a person is on a call, the copilot follows the conversation and suggests what to say: the next step of the script, an answer from your knowledge base, a response to an objection. After the call Every call gets a transcript, a recording, an AI summary, its cost, and a quality score against your rubric. Campaign and quality events are sent to your systems as signed webhooks. ## The life of a call 1. **A call comes in** on one of your SIP lines, or starts in a browser. 2. **An inbound route decides who answers**: an AI agent, an IVR menu, a queue of people, one person or a conference room. See [inbound routing](/guides/inbound-calls/). 3. **An AI agent talks with the caller.** Speech is turned into text as it is spoken, a language model decides what to say and which tools to use, and the reply is spoken back with a natural voice. If the caller interrupts, the agent stops and listens. 4. **The agent hands the call to a person** when it should: the call stays connected, and the person sees what was said. People can also hand a call back to an AI agent. 5. **The copilot helps the person** with live suggestions, while a supervisor can watch the call, whisper to the agent, or join. 6. **After the call**, MoreVoice stores the transcript and the recording, writes a summary, scores the call, and notifies your systems. Outbound works the same way in reverse: a campaign, the AI or a person places the call, and it passes the [compliance checks](/guides/compliance/) first. ## Where the API stands The public REST API, `/v1`, is in beta and is switched on per organisation. It covers assistants and voices, calls and their transcripts, recordings and summaries, custom tools, SIP connections, phone numbers and inbound routes, campaigns and their contacts, consent and the do-not-call list, and webhooks with their event log. API keys come with a [test mode](/get-started/test-mode/) that needs no phone line, and the [Node.js SDK](/sdk/node/) wraps it all; the [Python SDK](/sdk/python/) is on its way. Coming to the API Flows, queues and agents, users and teams, the knowledge base, copilot and QA, callbacks and conference rooms are in the web app today and come to `/v1` next. Pages and sections marked **Coming soon** describe them. ## Next steps [Quickstart](/get-started/quickstart/)Your first AI phone call in test mode, in five minutes. [Assistants](/guides/assistants/)Instructions, voice, speech recognition, and tips for Hebrew. [Inbound routing](/guides/inbound-calls/)Decide who answers each of your numbers. [Compliance](/guides/compliance/)What MoreVoice checks before every outbound call in Israel. # Page not found > We couldn’t find this page. Try the search above, or start from the docs home page. # Changelog > What changed in the MoreVoice API, by date: additions, versioned changes and deprecations. Also available as an RSS feed. Every change to the public API, newest first. Each entry is tagged **Added**, **Changed**, **Deprecated** or **Fixed**, and names the API version it belongs to. Breaking changes only ever ship in a new dated version: see the [versioning policy](/changelog/versioning/). Follow along with the [RSS feed](/changelog/rss.xml). No entries yet. The first one is published with the public API beta. # Versioning and changelog policy > How the MoreVoice API changes without breaking your integration: dated versions pinned to your API key, what counts as a breaking change, deprecation notice and sunset dates. The policy for the public API The public API (`/v1`) is in development. This policy applies from its first public version; the first version’s date is set when the beta opens. Your integration should keep working while MoreVoice improves. So we version the API by date, pin each API key to the version that was current when you created it, and never make a breaking change inside a version. ## Two layers * **`/v1` in the URL** changes only for a complete redesign of the API. * **A dated version** (for example `2026-11-01`) covers every change in between. A new dated version is released only when a change would break existing code. ## Choosing a version Each API key is **pinned** to the version that was current when the key was created. Requests made with that key get that version’s behaviour, so nothing changes for you until you decide to upgrade. To use another version for one request, for example while you test an upgrade, send the `MoreVoice-Version` header: ```http POST /v1/calls HTTP/1.1 Host: api.morevoice.ai Authorization: Bearer mv_test_sk_… MoreVoice-Version: 2026-11-01 ``` Every response says which version answered it, in the same `MoreVoice-Version` header. A version that doesn’t exist is rejected with `400` and the error code `unknown_version`. Webhook payloads follow the version of the endpoint they’re sent to, so an upgrade never changes the shape of events you already handle. ## What can change without a new version These changes are **not** breaking. They can ship at any time, and your code must accept them: * new endpoints and new resources; * new optional request parameters; * new fields in responses and in webhook payloads; * new event types; * new values in enumerations. Enums are open: treat a value you don’t know as “other” instead of failing; * new error codes, and changes to the wording of error messages (match on `type` and `code`, never on `message`); * the order of fields in JSON, and the length and format of IDs (IDs are opaque strings with a type prefix, such as `call_…`). ## What needs a new version Anything that could break code written for an earlier version only ships in a **new dated version**: * removing or renaming an endpoint, a field or a parameter; * changing a field’s type or meaning; * making an optional parameter required; * changing default behaviour, validation rules or the meaning of a status; * changing the shape of a webhook payload. Older versions keep working: MoreVoice translates responses back to the shape each version expects. ## Deprecation and sunset When we retire a version or an endpoint: 1. **It is announced in the changelog**, with a sunset date at least **12 months** after the deprecation. 2. **Responses carry two headers** so your monitoring can notice: `Deprecation` ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745)) and `Sunset` ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)), with the sunset date. 3. **We email the owners of API keys that still use it**, six months, three months and one month before the sunset date. ## The changelog Every change to the public API is listed in the [changelog](/changelog/), newest first, with an RSS feed. Each entry is dated and tagged: | Tag | Meaning | | ----------------------- | ------------------------------------------------------------------------------------------------------------------ | | **Added** | Something new. Safe to ignore until you need it. | | **Changed (versioned)** | A breaking change, available only in a new dated version. The entry names the version and explains how to upgrade. | | **Deprecated** | Something that will go away, with its sunset date. | ## Upgrading to a new version 1. Read the changelog entries between your pinned version and the new one. 2. Send the new version in the `MoreVoice-Version` header from a test-mode key, and run your tests. 3. When everything passes, create a new key (it is pinned to the current version) and roll it out, then revoke the old one. # Create and configure an assistant > An assistant is the AI on a call: its instructions, first sentence, voice, speech recognition, tools and turn-taking. With tips for assistants that speak Hebrew. An **assistant** is the AI that talks on a call. It holds what the AI should do (the instructions), what it says first, how it sounds, how it hears, which tools it may use and how it takes turns. One assistant can take any number of calls at once: inbound calls routed to it, [outbound calls](/guides/outbound-calls/) and [campaigns](/guides/campaigns/). Assistants are configuration, shared by live and test mode: an assistant you build with a test key answers live calls too. ## Create an assistant Only `name` is required. Everything you leave out takes the default the dashboard uses, and the answer shows the complete assistant. * cURL ```sh curl https://api.morevoice.ai/v1/assistants \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Clinic receptionist", "language": "he", "first_message": "שלום, הגעתם למרפאת השרון. איך אפשר לעזור?", "system_prompt": "You are the receptionist of Sharon Clinic. You book, move and cancel appointments. Speak Hebrew, keep answers to one or two short sentences, and read numbers back digit by digit.", "voice": { "provider": "gemini", "voice_id": "en-us-fola", "style": "warm, calm and unhurried" }, "llm": { "model": "gpt-4.1", "temperature": 0.7 }, "tools": { "end_call": true, "transfer_call": { "destination": "queue:reception" } }, "metadata": { "clinic_id": "sharon-01" } }' ``` * Node.js create-assistant.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const assistant = await mv.assistants.create({ name: "Clinic receptionist", language: "he", first_message: "שלום, הגעתם למרפאת השרון. איך אפשר לעזור?", system_prompt: "You are the receptionist of Sharon Clinic. You book, move and cancel appointments. " + "Speak Hebrew, keep answers to one or two short sentences, and read numbers back digit by digit.", voice: { provider: "gemini", voice_id: "en-us-fola", style: "warm, calm and unhurried" }, llm: { model: "gpt-4.1", temperature: 0.7 }, tools: { end_call: true, transfer_call: { destination: "queue:reception" }, }, metadata: { clinic_id: "sharon-01" }, }); console.log(assistant.id); // asst_… ``` * Python create_assistant.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} response = requests.post( f"{API}/assistants", headers=HEADERS, json={ "name": "Clinic receptionist", "language": "he", "first_message": "שלום, הגעתם למרפאת השרון. איך אפשר לעזור?", "system_prompt": ( "You are the receptionist of Sharon Clinic. You book, move and cancel appointments. " "Speak Hebrew, keep answers to one or two short sentences, and read numbers back digit by digit." ), "voice": {"provider": "gemini", "voice_id": "en-us-fola", "style": "warm, calm and unhurried"}, "llm": {"model": "gpt-4.1", "temperature": 0.7}, "tools": { "end_call": True, "transfer_call": {"destination": "queue:reception"}, }, "metadata": {"clinic_id": "sharon-01"}, }, timeout=30, ) response.raise_for_status() print(response.json()["id"]) # asst_… ``` The fields you will use most: | Field | What it does | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `name` | Your name for the assistant. Callers never hear it. | | `language` | The language of the call, as a language tag: `he`, `en`, `ar`, `ru`… | | `system_prompt` | The instructions the model follows: who it is, what it may and may not do, how to speak. Up to 100,000 characters. | | `first_message` | The first sentence, spoken word for word. | | `first_message_mode` | `assistant_speaks_first` (the default), `assistant_waits_for_user`, or `assistant_generates_first_message`. | | `voice` | How it sounds: `voice_id` from `GET /v1/voices`, and `style`, a direction such as “warm and calm”. | | `transcriber` | How it hears: languages to expect, and words and names to recognise better. | | `llm` | The language model (`model`, `temperature`, `max_output_tokens`). | | `tools` | What it can do besides talking: end the call, transfer it, look up the time, and your own [custom tools](/guides/custom-tools/). | | `flow_id` | A published [voice flow](/guides/flows/) that drives the call step by step instead of the instructions alone. | | `end_call_message`, `end_call_phrases` | What it says before it hangs up, and phrases that end the call when it says them. | | `max_duration_seconds` | Calls end after this long (10 seconds to 12 hours). | | `answering_machine_detection` | On outbound calls: hang up on a machine, or leave a message after the beep. | | `artifacts` | What is kept after the call: the recording, the transcript, an AI summary, the event log. | | `metadata` | Up to 50 key–value strings of your own, for example your CRM’s ID. MoreVoice never reads them. | | `advanced` | Turn-taking, latency and model routing. The defaults suit most assistants. | [The API reference](/api/operations/assistants_create/) lists every field with its limits. ### Variables `system_prompt`, `first_message` and a flow can contain `{{variables}}`, such as `{{customer_name}}`. Each call fills them in from its own `variables` (see [outbound calls](/guides/outbound-calls/#variables)), and each campaign contact from its columns. ## Change an assistant `PATCH /v1/assistants/{id}` changes only the fields you send. Nested objects (`voice`, `advanced`…) are merged with the stored values; lists (`end_call_phrases`, `tools.custom`) are replaced as a whole. The next call uses the new settings. To try a change without touching the assistant in use, copy it first. The copy has the same configuration, tools (with their stored headers) and metadata: * cURL ```sh # Copy an assistant to try a change without touching the one in use. curl https://api.morevoice.ai/v1/assistants/$ASSISTANT_ID/duplicate \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Clinic receptionist (evening)" }' ``` * Node.js duplicate-assistant.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Copy an assistant to try a change without touching the one in use. const copy = await mv.assistants.duplicate(process.env.ASSISTANT_ID!, { name: "Clinic receptionist (evening)" }); console.log(copy.id); ``` * Python duplicate_assistant.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} assistant_id = os.environ["ASSISTANT_ID"] # Copy an assistant to try a change without touching the one in use. response = requests.post( f"{API}/assistants/{assistant_id}/duplicate", headers=HEADERS, json={"name": "Clinic receptionist (evening)"}, timeout=30, ) response.raise_for_status() print(response.json()["id"]) ``` ## Find your assistants `GET /v1/assistants` lists your assistants, newest first, a page at a time; `GET /v1/assistants/{id}` returns one with every setting, the defaults included: * cURL ```sh # Your assistants, newest first, a page at a time. curl "https://api.morevoice.ai/v1/assistants?limit=50" \ -H "Authorization: Bearer $MOREVOICE_API_KEY" # One assistant, with every setting (the defaults included). curl https://api.morevoice.ai/v1/assistants/$ASSISTANT_ID \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js list-assistants.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Your assistants, newest first. The list follows next_cursor for you. for await (const a of mv.assistants.list({ limit: 50 })) { console.log(a.id, a.name, a.language, a.flow_id ?? "no flow"); } // One assistant, with every setting (the defaults included). const assistant = await mv.assistants.retrieve(process.env.ASSISTANT_ID!); console.log(assistant.voice.voice_id, assistant.transcriber.language_hints); ``` * Python list_assistants.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Your assistants, newest first, page by page. params = {"limit": 50} while True: response = requests.get(f"{API}/assistants", headers=HEADERS, params=params, timeout=30) response.raise_for_status() page = response.json() for a in page["data"]: print(a["id"], a["name"], a["language"], a["flow_id"] or "no flow") if not page["has_more"]: break params["starting_after"] = page["next_cursor"] # One assistant, with every setting (the defaults included). response = requests.get(f"{API}/assistants/{os.environ['ASSISTANT_ID']}", headers=HEADERS, timeout=30) response.raise_for_status() assistant = response.json() print(assistant["voice"]["voice_id"], assistant["transcriber"]["language_hints"]) ``` Each assistant carries your `metadata`, so you can match it to your own records. ## Voices Voices come from the text-to-speech provider. List them, then set `voice.voice_id`: * cURL ```sh curl https://api.morevoice.ai/v1/voices \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js list-voices.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY for await (const voice of mv.voices.list()) { console.log(voice.name, voice.gender ?? "", voice.languages.join(", ")); } ``` * Python list_voices.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} response = requests.get(f"{API}/voices", headers=HEADERS, timeout=30) response.raise_for_status() for voice in response.json()["data"]: print(voice["name"], voice.get("gender") or "", ", ".join(voice["languages"])) ``` A voice’s `languages` is what the provider lists, but voices speak many more languages, Hebrew included: listen before you choose (the dashboard plays a sample of each voice). `voice.style` directs the delivery, for example `"warm, calm and unhurried"`. With `style_mode: "dynamic"` (the default) the delivery adapts to the caller from reply to reply, and `style` is the fallback. ## Tips for Hebrew These settings come from how the pipeline handles Hebrew speech today. Start with the defaults, then change one thing at a time and listen to a test call. **Let callers mix in English.** Speech recognition expects Hebrew and English by default (`transcriber.language_hints` is `["he", "en"]`), so product names, email addresses and English words in a Hebrew sentence come out right. Turn on `strict_language_hints` only if callers never switch language. **Teach it your names.** Recognition hears common Hebrew well, but not your brand, your doctors’ family names or your street names. Put them in `transcriber.terms` (up to 500), and describe the business in a sentence or two in `transcriber.context`: Part of an assistant ```json { "language": "he", "transcriber": { "provider": "soniox", "model": "stt-rt-v5", "language_hints": ["he", "en"], "strict_language_hints": false, "terms": [ "מרפאת השרון", "ד״ר לוי", "רעננה", "כפר סבא" ], "context": "A clinic in the Sharon region. Callers book, move and cancel appointments." } } ``` **Give numbers time.** Callers read ID numbers, policy numbers and phone numbers in short groups with pauses in between. After a phrase that ends with a number, the assistant waits `advanced.start_speaking_plan.on_number_seconds` (0.5 s by default) before it answers. Raise it to about 1–1.5 seconds if callers get cut off mid-number, and tell the assistant in its instructions to read numbers back digit by digit and confirm them. **Choose what interrupts it.** By default any caller voice interrupts the assistant (`advanced.stop_speaking_plan.num_words: 0`), and if no real word follows within 0.9 seconds it carries on. On noisy lines (cars, streets) or with callers who say “אהה” and “כן כן” while listening, ask for one or two words instead: * cURL ```sh # Give callers more time after a number (they may still be dictating), and # let a cough or "אהה" no longer cut the assistant off: two words interrupt it. curl -X PATCH https://api.morevoice.ai/v1/assistants/$ASSISTANT_ID \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "advanced": { "start_speaking_plan": { "on_number_seconds": 1.2 }, "stop_speaking_plan": { "num_words": 2 } } }' ``` * Node.js tune-turn-taking.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Give callers more time after a number (they may still be dictating), and // let a cough or "אהה" no longer cut the assistant off: two words interrupt it. const assistant = await mv.assistants.update(process.env.ASSISTANT_ID!, { advanced: { start_speaking_plan: { on_number_seconds: 1.2 }, stop_speaking_plan: { num_words: 2 }, }, }); console.log(assistant.id); ``` * Python tune_turn_taking.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} assistant_id = os.environ["ASSISTANT_ID"] # Give callers more time after a number (they may still be dictating), and # let a cough or "אהה" no longer cut the assistant off: two words interrupt it. response = requests.patch( f"{API}/assistants/{assistant_id}", headers=HEADERS, json={ "advanced": { "start_speaking_plan": {"on_number_seconds": 1.2}, "stop_speaking_plan": {"num_words": 2}, } }, timeout=30, ) response.raise_for_status() print(response.json()["id"]) ``` **Match the grammar to the voice.** Hebrew verbs and adjectives have gender. Tell the assistant in its instructions which gender it speaks in, to match the voice, and to address callers in neutral forms until it knows how they speak about themselves. **Say it’s an AI.** Open with a sentence that says the caller is talking to a digital representative, for example “שלום, כאן נציגה דיגיטלית של מרפאת השרון”. Marketing calls must disclose it; see [compliance](/guides/compliance/). **Write instructions in English or Hebrew, speech in Hebrew.** The model follows instructions in either language. What callers hear word for word (`first_message`, `end_call_message`, the idle messages and fillers under `advanced`) must be written in the language of the call. Tip Keep answers short. On the phone, one or two sentences at a time sound natural; lists and long explanations don’t. Say so in the instructions. ## Tools `tools` decides what the assistant can do besides talking: | Field | What it lets the assistant do | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `end_call` | Hang up when the conversation is over. | | `transfer_call` | Transfer the caller to people: `{ "destination": "queue:" }`, a phone number, or a `sip:` address. `null` turns transfers off. | | `get_current_time` | Look up the current date and time (for “tomorrow” and “next Sunday”). | | `web_search` | Search the web. | | `add_participant` | Add a person to the call, for a three-way conversation. | | `custom` | Call your own server during the call: see [custom tools](/guides/custom-tools/). | ## Test an assistant With a test key, call a [test number](/get-started/test-mode/#test-numbers): the call runs on simulated speech recognition, model and voice, so you can check the flow of the conversation, your tools and your webhooks without a phone line or AI spend. To hear the real voice and the real model, call yourself with a live key, or [make a test call](https://help.morevoice.ai/en/ai-agents/test-call/) from the assistant’s page in the dashboard. ## Delete an assistant `DELETE /v1/assistants/{id}` deletes it. Past calls keep their history, but inbound routes and campaigns that used it need another assistant before their next call. # Call objects: transcript, recording, summary and cost > What a call record holds during and after the call, and how to fetch its transcript, recording, AI summary and cost. Every call is a **call object**, whichever way it came: an outbound call you placed, a campaign call, an inbound call to your number or a call from a web page. It is created when the call starts, follows it live, and keeps everything about it when it ends. Its parts are fetched on their own: the transcript, the recording and the AI summary. * cURL ```sh curl https://api.morevoice.ai/v1/calls/$CALL_ID \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js retrieve-call.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const call = await mv.calls.retrieve(process.env.CALL_ID!); console.log(call.status, call.end_reason, call.duration_ms); if (call.cost) console.log(`Cost: ${call.cost.amount} ${call.cost.currency} (minor units)`); if (call.summary) console.log(call.summary.outcome, "·", call.summary.text); ``` * Python retrieve_call.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} call_id = os.environ["CALL_ID"] response = requests.get(f"{API}/calls/{call_id}", headers=HEADERS, timeout=30) response.raise_for_status() call = response.json() print(call["status"], call["end_reason"], call["duration_ms"]) if call["cost"]: print(f"Cost: {call['cost']['amount']} {call['cost']['currency']} (minor units)") if call["summary"]: print(call["summary"]["outcome"], "·", call["summary"]["text"]) ``` A call that ended ```json { "id": "call_8tRPaZp5hLMbrGqdJ9AmNa", "object": "call", "type": "ai", "direction": "outbound", "transport": "sip", "status": "ended", "from": "+97237654321", "to": "+972501234567", "assistant_id": "asst_4Gk2LmN9pQ4rS6tV8wX0yZ", "flow_id": null, "flow_version": null, "queue_id": null, "agent_id": null, "campaign_id": null, "contact_id": null, "connection_id": "conn_2bF8kQ1nR7sT3vW5xY9zA0", "api_key_id": "key_9i2E2pKO6g3z4nXl57Qb4g", "purpose": "service", "started_at": "2026-11-03T09:14:22.000Z", "answered_at": "2026-11-03T09:14:29.410Z", "ended_at": "2026-11-03T09:16:02.120Z", "duration_ms": 99710, "end_reason": "customer-ended-call", "answered_by": "human", "disposition": null, "has_recording": true, "summary": { "object": "call_summary", "call_id": "call_8tRPaZp5hLMbrGqdJ9AmNa", "text": "The customer confirmed tomorrow's appointment at 10:00.", "intent": "Confirm an appointment", "outcome": "Appointment confirmed", "sentiment": "positive", "key_points": ["Tomorrow at 10:00 suits the customer"], "action_items": [], "generated_at": "2026-11-03T09:16:09.000Z" }, "cost": { "amount": 4, "amount_decimal": "4.2310", "currency": "USD", "estimate": true }, "variables": { "customer_name": "Dana" }, "metadata": { "crm_contact_id": "0031x00000AbCdE" }, "livemode": true } ``` ## The fields | Field | Meaning | | ------------------------------------------------------ | ----------------------------------------------------------------------------------- | | `type` | Who handles the call: `ai`, `human`, `ivr`, `conference` or `voicemail`. | | `direction` | `inbound`, `outbound`, or `browser` for a call from a web page. | | `transport` | `sip` for phone calls, `webrtc` for browser calls and conference rooms. | | `status` | `queued` (placed, not ringing yet), `ringing`, `in_progress` (answered) or `ended`. | | `from`, `to` | The numbers, in E.164 when they are phone numbers. | | `assistant_id`, `flow_id`, `flow_version` | The assistant, and the flow version that ran (`0` is an unpublished draft). | | `queue_id`, `agent_id` | The queue and the person who handled the call, when people did. | | `campaign_id`, `contact_id` | The campaign and contact of a campaign call. | | `connection_id`, `api_key_id` | The SIP connection, and the API key that placed the call. | | `purpose` | `service`, `marketing` or `survey`, for outbound calls. | | `started_at`, `answered_at`, `ended_at`, `duration_ms` | When it started, was answered and ended, in UTC, and how long it lasted. | | `end_reason` | Why it ended. See [below](#why-a-call-ended). | | `answered_by` | `human`, `machine` or `unknown`, when answering-machine detection ran. | | `disposition` | The business outcome the assistant recorded, such as `interested`. | | `has_recording` | Whether there is a recording to fetch. | | `summary` | The AI summary, once written; `null` before. | | `cost` | What the call cost; `null` until it ends. | | `variables`, `metadata` | What the call was created with. | | `livemode` | `false` for test-mode calls. | While a call runs, `status` and `answered_at` come from the live call, so polling `GET /v1/calls/{id}` shows it progress. Add `expand[]=assistant` to get the assistant’s ID and name in an `assistant` field. ### Why a call ended `end_reason` is a short code. The ones you’ll see most: | `end_reason` | Meaning | | --------------------------------------------------------------- | ----------------------------------------------------------------------- | | `customer-ended-call` | The caller or the person called hung up. | | `assistant-ended-call`, `assistant-said-end-call-phrase` | The assistant ended the call. | | `api-ended-call` | You hung up with `POST /v1/calls/{id}/hangup`. | | `exceeded-max-duration`, `silence-timed-out` | The call reached its time limit, or nobody spoke for too long. | | `transferred` | The call was transferred. | | `customer-did-not-answer`, `customer-declined`, `customer-busy` | Nobody answered, the call was rejected, or the line was busy. | | `amd-machine`, `amd-left-message` | An answering machine answered: the assistant hung up or left a message. | | `opted-out` | The person asked not to be called again. | | `invalid-number`, `connect-failed` | The number or the network failed. | The list grows with the product, so treat a code you don’t know as “other”. ## Transcript `GET /v1/calls/{id}/transcript` returns everything said, in order. While the call runs, it returns the transcript so far. * cURL ```sh curl https://api.morevoice.ai/v1/calls/$CALL_ID/transcript \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js transcript.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const transcript = await mv.calls.retrieveTranscript(process.env.CALL_ID!); for (const s of transcript.segments) { const at = new Date(s.start_ms ?? 0).toISOString().slice(14, 19); // mm:ss into the call console.log(`[${at}] ${s.speaker}: ${s.text}${s.interrupted ? " (interrupted)" : ""}`); } ``` * Python transcript.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} call_id = os.environ["CALL_ID"] response = requests.get(f"{API}/calls/{call_id}/transcript", headers=HEADERS, timeout=30) response.raise_for_status() for s in response.json()["segments"]: seconds = (s["start_ms"] or 0) // 1000 interrupted = " (interrupted)" if s["interrupted"] else "" print(f"[{seconds // 60:02}:{seconds % 60:02}] {s['speaker']}: {s['text']}{interrupted}") ``` Response ```json { "object": "transcript", "call_id": "call_8tRPaZp5hLMbrGqdJ9AmNa", "language": "he", "segments": [ { "speaker": "assistant", "text": "שלום, כאן דנה ממוקד השירות. במה אפשר לעזור?", "start_ms": 820, "end_ms": 3900, "interrupted": false, "tool": null }, { "speaker": "customer", "text": "רציתי לבדוק מה עם ההזמנה שלי", "start_ms": 4400, "end_ms": 6100, "interrupted": false, "tool": null } ] } ``` * `speaker` is `customer` (the caller, or the person called), `assistant` (the AI), `agent` (a person on your team), `supervisor`, `participant` (a third party), or `tool` for a [custom tool](/guides/custom-tools/) call, with the tool’s name, arguments and result in `tool`. * `start_ms` and `end_ms` count from the start of the call. * `interrupted` is `true` when the speaker was cut off. ## Recording `GET /v1/calls/{id}/recording` answers `302` with a link to the audio. Follow the redirect to download it: the link needs no API key and works for 15 minutes. With `?format=json` you get the link itself: GET /v1/calls/{id}/recording?format=json ```json { "object": "recording_link", "call_id": "call_8tRPaZp5hLMbrGqdJ9AmNa", "url": "https://api.morevoice.ai/v1/recordings/AQAAAAABAAAAAAAAAAAAAAAAAAABVjFTdEdYUjhfWjVq.kH2pQ7xV9rT4mW1zB6nD8f", "expires_at": "2026-11-03T09:30:00.000Z", "content_type": "audio/ogg", "duration_ms": 99710, "channels": 2 } ``` Recordings are stereo: the customer on the left channel, the assistant or agent on the right. A call without a recording, or one still in progress, answers `404` with `resource_missing`. Recordings are kept as long as your plan and retention settings allow; download the ones you must keep. Caution The link gives anyone who has it the recording until it expires. Don’t log it or send it to a browser you don’t control; ask for a new one when you need it. ## Summary When the assistant’s `artifacts.summary` is on (the default), an AI summary is written after the call and shows up in the call’s `summary`, and the `call.analyzed` event is sent. It holds `text` (two to four sentences in the language of the call), `intent`, `outcome`, `sentiment`, `key_points` and `action_items`. `GET /v1/calls/{id}/summary` returns it on its own. `POST /v1/calls/{id}/summary` writes a new one from the transcript and replaces the stored one; send an `Idempotency-Key` so a retry doesn’t write it twice. A call with no customer speech answers `409` with `summary_unavailable`. In test mode the summary is a fixed stand-in built from the transcript: no AI model runs. ## Cost Once the call ends, `cost` says what it cost: | Field | Meaning | | ---------------- | ------------------------------------------------------------------- | | `amount` | In the currency’s minor unit (agorot, cents), rounded. | | `amount_decimal` | The exact amount in minor units, as a decimal string. | | `currency` | ISO 4217, such as `ILS` or `USD`. | | `estimate` | `true` while it is an estimate from usage, not yet a billed amount. | The `call.cost_finalized` event tells you when the cost is final. Test-mode calls are never billed. ## More about a call | Request | Returns | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `GET /v1/calls/{id}/flow_path` | The flow nodes the call went through, with the edge taken into each. | | `GET /v1/calls/{id}/copilot_events` | What the real-time agent copilot showed during a call handled by a person, and what the agent did with it. | | `DELETE /v1/calls/{id}` | Deletes an ended call with its transcript, events and recording. A call in progress answers `409` with `call_in_progress`. | ## List calls `GET /v1/calls` lists calls newest first, with filters: `status` (`in_progress` or `ended`), `direction`, `type`, `assistant_id`, `campaign_id`, `agent_id`, `from`, `to`, and the start time with `created[gte]`, `created[gt]`, `created[lte]` and `created[lt]`. A test key sees only test calls. Lists come in pages of up to 100 (`limit`, 20 by default): `{ "object": "list", "data": […], "has_more": true, "next_cursor": "…" }`. To get the next page, pass `next_cursor` as `starting_after`. The SDK does it for you: * cURL ```sh # The first page of yesterday's calls. curl "https://api.morevoice.ai/v1/calls?created[gte]=2026-11-03T00:00:00Z&created[lte]=2026-11-03T23:59:59Z&limit=100" \ -H "Authorization: Bearer $MOREVOICE_API_KEY" # While has_more is true, ask for the next page with the next_cursor you got. curl "https://api.morevoice.ai/v1/calls?created[gte]=2026-11-03T00:00:00Z&created[lte]=2026-11-03T23:59:59Z&limit=100&starting_after=$NEXT_CURSOR" \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js list-calls-page-by-page.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Every call of a day. The list follows next_cursor for you, one page at a time. let total = 0; for await (const call of mv.calls.list({ created: { gte: "2026-11-03T00:00:00Z", lte: "2026-11-03T23:59:59Z" }, limit: 100 })) { total += call.cost?.amount ?? 0; } console.log(`Total: ${total} (minor units)`); ``` * Python list_calls_page_by_page.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Every call of a day, page by page: pass next_cursor as starting_after while has_more is true. params = {"created[gte]": "2026-11-03T00:00:00Z", "created[lte]": "2026-11-03T23:59:59Z", "limit": 100} total = 0 while True: response = requests.get(f"{API}/calls", headers=HEADERS, params=params, timeout=30) response.raise_for_status() page = response.json() total += sum((call["cost"] or {}).get("amount", 0) for call in page["data"]) if not page["has_more"]: break params["starting_after"] = page["next_cursor"] print(f"Total: {total} (minor units)") ``` ## Events | Event | When | | ------------------------------------------------------------- | ------------------------------- | | `call.created`, `call.ringing`, `call.answered`, `call.ended` | The call’s life. | | `transcript.ready` | The final transcript is stored. | | `recording.ready` | The recording is stored. | | `call.analyzed` | The summary is ready. | | `call.cost_finalized` | The cost is final. | Each one carries the call object as it was at that moment. See the [event catalogue](/webhooks/events/#calls). # Campaigns > Call a list of contacts with an assistant: create a campaign, add or import contacts, set calling windows and retries, then start, pause and follow it. A **campaign** calls a list of contacts with an assistant. The dialer works through the list at the pace you set, only inside your calling windows, retries the people it didn’t reach, and checks every contact against the compliance rules just before dialling. You add contacts, start the campaign, and follow the results. A campaign moves through these states: | `state` | Meaning | | ----------- | ------------------------------------------------------ | | `draft` | Being set up. Nothing is dialled. | | `scheduled` | Started with a later `start_at`; dialling begins then. | | `running` | Dialling, inside the calling windows. | | `paused` | Not dialling new calls. Calls in progress finish. | | `completed` | Every contact reached a final status. | | `cancelled` | Stopped for good. | ## Create a campaign `name` and `purpose` are required. Everything else takes the defaults the dashboard uses, and the campaign starts as a `draft`: * cURL ```sh # A draft campaign: it dials nobody until you add contacts and start it. curl https://api.morevoice.ai/v1/campaigns \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "November flu-shot reminders", "purpose": "service", "assistant_id": "'"$ASSISTANT_ID"'" }' ``` * Node.js create-campaign.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // A draft campaign: it dials nobody until you add contacts and start it. const campaign = await mv.campaigns.create({ name: "November flu-shot reminders", purpose: "service", assistant_id: process.env.ASSISTANT_ID!, }); console.log(campaign.id); // cmp_… ``` * Python create_campaign.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # A draft campaign: it dials nobody until you add contacts and start it. response = requests.post( f"{API}/campaigns", headers=HEADERS, json={ "name": "November flu-shot reminders", "purpose": "service", "assistant_id": os.environ["ASSISTANT_ID"], }, timeout=30, ) response.raise_for_status() print(response.json()["id"]) # cmp_… ``` `purpose` works as on a [single call](/guides/outbound-calls/#purpose-and-compliance): `marketing` campaigns need a recorded consent for every contact (§30A) and pass the national do-not-call registry, and they always play the AI and recording disclosure. Choose `service` or `survey` only when the call really is one. ## Settings Send any of these when you create the campaign, or change them later with `PATCH /v1/campaigns/{id}` (nested objects are merged with the stored values). A completed or cancelled campaign can’t be changed, and a running one must be paused before its `purpose` changes. | Field | What it does | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `assistant_id` | The assistant that talks. | | `connection_id`, `caller_id` | The SIP connection to dial through and the number to show (by default, the organisation’s default connection and its caller ID). | | `schedule` | When it may dial: see [calling windows](#calling-windows). | | `retry` | Whom to call again, and when: see [retries](#retries). | | `max_concurrent` | Calls this campaign may have in progress at once (1–500). | | `calls_per_second` | New calls per second (0–100; `0` leaves it to the connection’s limit). | | `priority` | 1–10. When campaigns compete for lines, the higher one goes first. | | `ring_timeout_seconds` | Give up on an unanswered call after this long (5–120). | | `amd` | Answering machines: `{ "mode": "off" }`, `"hangup"`, or `"leave_message"` with a `message`. | | `first_message` | Replaces the assistant’s first sentence for this campaign. `{{variables}}` come from each contact. | | `script` | Extra instructions for the assistant in this campaign, for example the offer. `{{variables}}` allowed. | | `dispositions` | The outcomes the assistant can record, such as `interested` or `callback-requested`. | | `disclosure` | The AI and recording disclosure played before the first message. `enabled: null` follows the organisation’s default, which is on for marketing; a marketing campaign can’t turn it off. | | `opt_out_dtmf_key` | The key a contact presses to opt out (by default, the organisation’s). | ### Calling windows `schedule.windows` lists when the campaign may dial, in `schedule.timezone` (or each contact’s own `timezone`). Up to 14 windows, each with weekdays and a start and end time: Part of a campaign ```json { "schedule": { "timezone": "Asia/Jerusalem", "windows": [ { "days": ["sun", "mon", "tue", "wed", "thu"], "start": "09:00", "end": "19:00" }, { "days": ["fri"], "start": "09:00", "end": "13:00" } ], "respect_shabbat": true, "respect_holidays": true, "start_at": null, "end_at": "2026-11-30T18:00:00.000Z" } } ``` The organisation’s own calling hours, Shabbat and holidays apply on top of the campaign’s windows, whatever it says: a campaign can only narrow them, never widen them. `end_at` stops the dialling at that time. [Compliance](/guides/compliance/#calling-hours-shabbat-and-holidays) explains the rules. ### Retries `retry.max_attempts` is the most times one contact is dialled, the first call included (1–20). For each result you choose whether to try again, and after how many minutes: Part of a campaign ```json { "retry": { "max_attempts": 3, "busy": { "enabled": true, "delay_minutes": 15 }, "no_answer": { "enabled": true, "delay_minutes": 60 }, "voicemail": { "enabled": true, "delay_minutes": 120 }, "declined": { "enabled": false, "delay_minutes": 240 }, "failed": { "enabled": true, "delay_minutes": 10 } } } ``` A retry still waits for an open calling window. ## Add contacts Send up to 1,000 contacts per request. Numbers can be in E.164 (`+972…`) or national format (`050-…`) with a `default_country`. Each contact can carry `variables` for the campaign’s `{{variables}}`, and its own `timezone`: * cURL ```sh # Up to 1,000 contacts per request. Numbers may be national (050-…) with a default country. curl https://api.morevoice.ai/v1/campaigns/$CAMPAIGN_ID/contacts \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "default_country": "IL", "contacts": [ { "phone": "+972501234567", "name": "דנה לוי", "variables": { "clinic": "רעננה" } }, { "phone": "052-765-4321", "name": "יוסי כהן", "variables": { "clinic": "כפר סבא" } } ] }' ``` * Node.js add-contacts.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Up to 1,000 contacts per request. Numbers may be national (050-…) with a default country. const batch = await mv.campaigns.contacts.create(process.env.CAMPAIGN_ID!, { default_country: "IL", contacts: [ { phone: "+972501234567", name: "דנה לוי", variables: { clinic: "רעננה" } }, { phone: "052-765-4321", name: "יוסי כהן", variables: { clinic: "כפר סבא" } }, ], }); console.log(`${batch.imported} added, ${batch.rejected} rejected`, batch.rejected_reasons); ``` * Python add_contacts.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} campaign_id = os.environ["CAMPAIGN_ID"] # Up to 1,000 contacts per request. Numbers may be national (050-…) with a default country. response = requests.post( f"{API}/campaigns/{campaign_id}/contacts", headers=HEADERS, json={ "default_country": "IL", "contacts": [ {"phone": "+972501234567", "name": "דנה לוי", "variables": {"clinic": "רעננה"}}, {"phone": "052-765-4321", "name": "יוסי כהן", "variables": {"clinic": "כפר סבא"}}, ], }, timeout=30, ) response.raise_for_status() batch = response.json() print(f"{batch['imported']} added, {batch['rejected']} rejected", batch["rejected_reasons"]) ``` Contacts are checked like a dashboard import: numbers are normalised, duplicates (in the request or already in the campaign) are dropped, and numbers on the do-not-call list are kept with the status `dnc` and never dialled. Send `drop_dnc: true` to leave them out entirely. The answer counts what happened and lists the first 100 problems by position: Response ```json { "object": "contact_batch_result", "campaign_id": "cmp_2Yb7mCq9aPLk", "livemode": true, "received": 2, "imported": 2, "rejected": 0, "rejected_reasons": { "invalid": 0, "duplicate": 0, "dnc": 0 }, "without_consent": 0, "consent_recorded": 0, "errors": [], "warnings": [] } ``` ### Consent for marketing campaigns A marketing call needs evidence that the person agreed to be called: when, where and how. Send it with each contact as `consent`, with `granted_at` (an ISO-8601 time), `source` (for example the form’s name) and `channel` (`web`, `phone`, `sms`, `email`, `whatsapp`, `app`, `written`, `in_person` or `other`). MoreVoice records it as a consent for that number. A contact without it is added, counted in `without_consent`, and skipped by the dialer until a consent is recorded. ## Import a file For larger lists, upload a CSV or XLSX file of up to 10 MB and 50,000 rows. The import runs in the background: * cURL ```sh # Upload a CSV or XLSX file (up to 10 MB). `mapping` says which column holds what; # any other key (here "clinic") becomes a {{variable}}. curl https://api.morevoice.ai/v1/campaigns/$CAMPAIGN_ID/contacts/import \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -F file=@contacts.csv \ -F 'mapping={"phone": "Mobile", "name": "Name", "clinic": "Clinic"}' \ -F default_country=IL # The import runs in the background: poll it until it succeeds or fails. curl https://api.morevoice.ai/v1/imports/$IMPORT_ID \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js import-contacts.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Upload a CSV or XLSX file (up to 10 MB) and wait for the import to finish. `mapping` says which // column holds what; any other key (here "clinic") becomes a {{variable}}. const job = await mv.campaigns.importCsv(process.env.CAMPAIGN_ID!, "contacts.csv", { mapping: { phone: "Mobile", name: "Name", clinic: "Clinic" }, default_country: "IL", }); console.log(`${job.imported} imported, ${job.rejected} rejected`, job.rejected_reasons); ``` * Python import_contacts.py ```python import json import os import time import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} campaign_id = os.environ["CAMPAIGN_ID"] # Upload a CSV or XLSX file (up to 10 MB). `mapping` says which column holds what; # any other key (here "clinic") becomes a {{variable}}. with open("contacts.csv", "rb") as f: response = requests.post( f"{API}/campaigns/{campaign_id}/contacts/import", headers=HEADERS, files={"file": ("contacts.csv", f, "text/csv")}, data={ "mapping": json.dumps({"phone": "Mobile", "name": "Name", "clinic": "Clinic"}), "default_country": "IL", }, timeout=120, ) response.raise_for_status() job = response.json() # The import runs in the background: poll it until it succeeds or fails. while job["status"] not in ("succeeded", "failed"): time.sleep(2) response = requests.get(f"{API}/imports/{job['id']}", headers=HEADERS, timeout=30) response.raise_for_status() job = response.json() print(job["status"], f"{job['imported']} imported, {job['rejected']} rejected", job["rejected_reasons"]) ``` - **`mapping`** says which column holds what: `phone` (required), `name`, `timezone`, and the consent columns `consent`, `consent_at`, `consent_source` and `consent_channel`. Any other key names a `{{variable}}`. Name columns by header or by 0-based index. Leave `mapping` out and the columns are detected the way the dashboard does it. - **`default_country`** reads numbers written without a country code (IL by default). - **`consent_source`** and **`consent_channel`** apply to every consenting row when the file has no such columns. - **`has_header`** and **`drop_dnc`** work as their names say. The upload answers `202 Accepted` with an `import` object. Poll `GET /v1/imports/{id}` until its `status` goes from `pending` and `processing` to `succeeded` or `failed`. The Node.js SDK’s `campaigns.importCsv()` uploads and waits for you. A finished import counts the rows like a batch (`received`, `imported`, `rejected`, `rejected_reasons`, `without_consent`), lists the first 100 rejected rows with their row number in `errors`, and a failed one says why in `error`. The same endpoint also takes JSON, with exactly one of `csv` (the file’s text), `file` (`filename` and `content_base64`) or `url` (a public `https` address to download it from). ## Start the campaign `start` runs the same pre-flight checks as the dashboard’s Start button, then dials: * cURL ```sh # Runs the pre-flight checks and starts dialling; a failed check answers 409 preflight_failed. curl -X POST https://api.morevoice.ai/v1/campaigns/$CAMPAIGN_ID/start \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js start-campaign.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Runs the pre-flight checks and starts dialling; a failed check answers 409 preflight_failed. const campaign = await mv.campaigns.start(process.env.CAMPAIGN_ID!); console.log(campaign.id, campaign.stats); ``` * Python start_campaign.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} campaign_id = os.environ["CAMPAIGN_ID"] # Runs the pre-flight checks and starts dialling; a failed check answers 409 preflight_failed. response = requests.post(f"{API}/campaigns/{campaign_id}/start", headers=HEADERS, timeout=30) if response.status_code == 409: error = response.json()["error"] print(error["code"], error["message"], error.get("details")) else: response.raise_for_status() print(response.json()["id"], "started") ``` If a check fails, nothing starts: the request answers `409` with the code `preflight_failed`, and `details.preflight` lists the `errors` to fix (no assistant, no contacts, no recorded consent, no registry for a marketing campaign…), `warnings` (contacts that will be skipped), how many contacts are `dialable`, and whether a calling window is open now. `GET /v1/campaigns/{id}/preflight` runs the same checks without starting. To start later, send `start_at` with the start and the campaign waits in `scheduled`. Once running, the dialer checks each contact again just before calling it: the do-not-call list, consent, the national registry, calling hours and Shabbat. A marketing campaign that can’t reach the national registry pauses itself and says why in `pause_reason`. ## Pause, resume and cancel * cURL ```sh # Stop dialling new calls; calls in progress finish. curl -X POST https://api.morevoice.ai/v1/campaigns/$CAMPAIGN_ID/pause \ -H "Authorization: Bearer $MOREVOICE_API_KEY" # Later: run the pre-flight checks again and carry on. curl -X POST https://api.morevoice.ai/v1/campaigns/$CAMPAIGN_ID/resume \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js pause-resume.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const campaignId = process.env.CAMPAIGN_ID!; // Stop dialling new calls; calls in progress finish. await mv.campaigns.pause(campaignId); // Later: run the pre-flight checks again and carry on. await mv.campaigns.resume(campaignId); ``` * Python pause_resume.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} campaign_id = os.environ["CAMPAIGN_ID"] # Stop dialling new calls; calls in progress finish. requests.post(f"{API}/campaigns/{campaign_id}/pause", headers=HEADERS, timeout=30).raise_for_status() # Later: run the pre-flight checks again and carry on. requests.post(f"{API}/campaigns/{campaign_id}/resume", headers=HEADERS, timeout=30).raise_for_status() ``` `pause` stops new calls and lets calls in progress finish. `resume` runs the pre-flight checks again and carries on. `cancel` stops the campaign for good: calls not yet answered are hung up, and contacts not yet dialled are cancelled. ## Follow the results The campaign object carries `stats`: contacts per status, how many will not be dialled again (`finished`), and the calls in progress now. List the contacts by status to see where each one stands: * cURL ```sh # Contacts still waiting to be called. curl "https://api.morevoice.ai/v1/campaigns/$CAMPAIGN_ID/contacts?status=pending&limit=50" \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js list-contacts.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Contacts still waiting to be called. for await (const contact of mv.campaigns.contacts.list(process.env.CAMPAIGN_ID!, { status: "pending", limit: 50 })) { console.log(contact.phone, contact.name, contact.attempts); } ``` * Python list_contacts.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} campaign_id = os.environ["CAMPAIGN_ID"] # Contacts still waiting to be called. response = requests.get( f"{API}/campaigns/{campaign_id}/contacts", headers=HEADERS, params={"status": "pending", "limit": 50}, timeout=30, ) response.raise_for_status() for contact in response.json()["data"]: print(contact["phone"], contact["name"], contact["attempts"]) ``` | Contact `status` | Meaning | | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `pending` | Waiting for its first call. | | `scheduled` | Due again later: a retry, or requeued. | | `callback` | The person asked to be called back. | | `dialing` | Being called now. | | `done` | Reached; `outcome` holds the disposition the assistant recorded. | | `failed` | Not reached after every attempt. | | `dnc`, `skipped`, `invalid`, `cancelled` | Never dialled; `skip_reason` says why (do-not-call, no consent, outside every window, skipped by hand…). | `GET /v1/campaigns/{id}/stats` adds attempts by result, recorded outcomes and compliance skips, and `GET /v1/campaigns/{id}/export` downloads the contacts or the attempts as a CSV that opens in Excel with Hebrew intact. Webhooks follow the campaign as it runs: `campaign.started`, `campaign.paused`, `campaign.completed`, `contact.attempted`, `contact.completed` and the call events of each call; see the [event catalogue](/webhooks/events/#campaigns). [`campaign.blocked`](/webhooks/events/#campaign.blocked) tells you when a running campaign can’t dial right now: outside its calling window, on Shabbat or a holiday, or stopped by a compliance gate. ### Requeue or skip a contact Each contact keeps its dial attempts: when, the `result` (`answered`, `no_answer`, `busy`, `voicemail`…) and the call’s `end_reason`. To try someone again now, set the contact’s `status` to `scheduled`; to take them out of the campaign, `skipped`, as the dashboard’s contact actions do: * cURL ```sh # Why wasn't this contact reached? Its dial attempts, then dial it again now. curl https://api.morevoice.ai/v1/campaigns/$CAMPAIGN_ID/contacts/$CONTACT_ID/attempts \ -H "Authorization: Bearer $MOREVOICE_API_KEY" curl -X PATCH https://api.morevoice.ai/v1/campaigns/$CAMPAIGN_ID/contacts/$CONTACT_ID \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "scheduled" }' ``` * Node.js requeue-contact.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const campaignId = process.env.CAMPAIGN_ID!; const contactId = process.env.CONTACT_ID!; // Why wasn't this contact reached? Its dial attempts, then dial it again now. for await (const attempt of mv.campaigns.contacts.listAttempts(campaignId, contactId)) { console.log(attempt.attempt_number, attempt.started_at, attempt.result, attempt.end_reason); } const contact = await mv.campaigns.contacts.update(campaignId, contactId, { status: "scheduled" }); console.log(contact.status); ``` * Python requeue_contact.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} campaign_id = os.environ["CAMPAIGN_ID"] contact_id = os.environ["CONTACT_ID"] # Why wasn't this contact reached? Its dial attempts, then dial it again now. response = requests.get(f"{API}/campaigns/{campaign_id}/contacts/{contact_id}/attempts", headers=HEADERS, timeout=30) response.raise_for_status() for attempt in response.json()["data"]: print(attempt["attempt_number"], attempt["started_at"], attempt["result"], attempt["end_reason"]) response = requests.patch( f"{API}/campaigns/{campaign_id}/contacts/{contact_id}", headers=HEADERS, json={"status": "scheduled"}, timeout=30, ) response.raise_for_status() print(response.json()["status"]) ``` A number on the do-not-call list can’t be requeued, and a contact being dialled can’t change. A requeued contact still waits for an open calling window and passes the compliance checks again before the call. ## Test mode Campaigns created with a test key are test objects: they never reach a phone network, and their calls are dialled by the virtual carrier. Calling windows, Shabbat, retries, outcomes and webhooks work exactly as in live mode, and the [test numbers](/get-started/quickstart/#test-numbers) behave the same in a list: `+972500000002` is busy and is retried by your retry policy, `+972500000004` is skipped as do-not-call. A test campaign needs no SIP connection, and its pre-flight doesn’t require recorded consent or the national do-not-call registry, because no real person can be called: the pre-flight lists them as warnings instead. Contacts on your own do-not-call list are still skipped. Test calls count against your plan’s test-mode limits (calls per day, calls at once), not against billing. Tip Before a big campaign, run the same settings on a small list of your own team’s numbers with a live key. You’ll hear the disclosure, the first message and the pacing exactly as your customers will. # CLI: MoreVoice from your terminal > The morevoice command line: log in with an API key, place and inspect calls, send test events to your webhook endpoints, and, as it grows, forward webhooks and tool calls to your laptop and follow events live. The reference is generated from the CLI's own help. `morevoice` is the API from your terminal, built on the Node.js SDK. Use it to try a call in seconds while you build, to script small jobs, and to send your webhook endpoint a test event without waiting for a real call. ## Install On npm with the API beta `@morevoice/cli` isn’t published yet. Until it is, run it from a checkout of the SDK repository. It finds the API key the way the SDKs do: `--api-key`, then the `MOREVOICE_API_KEY` environment variable, then a saved **profile**. `morevoice login` checks a key against the API and saves it as a profile (`test` or `live`, by the key’s mode), so you switch between test and live without copying keys around. Every command takes `--json` for scripts and `--help` for its options. ## Commands Everything below is the CLI’s own help, from `morevoice` 0.1.0. ```text morevoice 0.1.0 — the MoreVoice command line Usage: morevoice login [--key mv_test_sk_…] [--profile name] [--api-base url] morevoice logout [--profile name | --all] morevoice whoami morevoice profiles [list] | profiles use | profiles remove morevoice keys [--all] morevoice calls create --to +9725… (--assistant asst_… | --flow flow_…) --purpose service [--var k=v]… [--metadata k=v]… [--from-number pn_…] [--idempotency-key key] morevoice calls list [--limit 20] [--status in_progress|ended] [--direction inbound|outbound|browser] [--assistant asst_…] [--starting-after cursor] morevoice calls get [--expand assistant] morevoice trigger [--endpoint we_…] morevoice listen [--forward-to http://localhost:3000/webhooks] [--forward-tools http://localhost:3000/tools] [--events call.*,campaign.completed] [--live] Options (every command): --json machine-readable output --profile use a saved profile (default: the default profile, or MOREVOICE_PROFILE) --api-key use this key (or MOREVOICE_API_KEY) --api-base another API server, e.g. staging (or MOREVOICE_API_BASE) -h, --help this help · -v, --version Keys are saved in ~/.config/morevoice/config.json (mode 600). ``` | Command | Usage | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `morevoice login` | `morevoice login [--key mv_test_sk_…] [--profile name] [--api-base url]` | | `morevoice logout` | `morevoice logout [--profile name \| --all]` | | `morevoice whoami` | `morevoice whoami` | | `morevoice profiles` | `morevoice profiles [list] \| profiles use \| profiles remove ` | | `morevoice keys` | `morevoice keys [--all]` | | `morevoice calls create` | `morevoice calls create --to +9725… (--assistant asst_… \| --flow flow_…) --purpose service [--var k=v]… [--metadata k=v]… [--from-number pn_…] [--idempotency-key key]` | | `morevoice calls list` | `morevoice calls list [--limit 20] [--status in_progress\|ended] [--direction inbound\|outbound\|browser] [--assistant asst_…] [--starting-after cursor]` | | `morevoice calls get` | `morevoice calls get [--expand assistant]` | | `morevoice trigger` | `morevoice trigger [--endpoint we_…]` | | `morevoice listen` | `morevoice listen [--forward-to http://localhost:3000/webhooks] [--forward-tools http://localhost:3000/tools] [--events call.*,campaign.completed] [--live]` | More commands are coming Next: `tail` follows your organisation’s events live. Start with a test key Log in with a test key (`mv_test_sk_…`) first. Test calls run on the virtual carrier and are never billed; a live key’s calls reach real phones. # Compliance: consent, do-not-call, disclosures and Shabbat > What MoreVoice checks before every outbound call in Israel: the call's purpose, §30A marketing consent, the do-not-call list and national registry, AI and recording disclosure, opt-out, and calling hours around Shabbat and holidays; and how to keep consent and the do-not-call list in sync over the API. Israel’s Communications Law (§30A, the “spam law”) governs marketing calls, and it applies to calls an AI makes just as it does to people. MoreVoice builds the rules into the dialer: **every outbound call leg passes one compliance decision before it is placed**, whether a campaign dials it, the AI places it, a person dials it, or it is a transfer, consultation or added participant. You are the advertiser MoreVoice gives you the controls and keeps the records. Under §30A the business that makes the call is the advertiser and stays responsible for having consent and for the content of its calls. This page explains how the product behaves; it isn’t legal advice. Have counsel review your use, and read the [Acceptable Use Policy](https://morevoice.ai/en/legal/aup/), which every outbound call must follow. Your supervisors set these rules up in the app; the help centre’s [§30A compliance article](https://help.morevoice.ai/en/campaigns/compliance/) walks them through it, and [the national registry article](https://help.morevoice.ai/en/campaigns/national-registry/) connects the registry. This page is the developer’s view: what the platform enforces, and [the API](#over-the-api) that keeps your consent records and do-not-call list in sync with your systems. ## The call’s purpose Every outbound call has a purpose, and the purpose decides which checks run: | Check | `marketing` | `service` | `survey` | | ----------------------------------------------- | ------------------------- | --------- | -------- | | Internal do-not-call list | Blocks | Blocks | Blocks | | §30A consent on record | Required | — | — | | National do-not-call registry (Israeli numbers) | Blocks registered numbers | — | — | | AI and recording disclosure | On by default | Optional | Optional | A campaign has one purpose for all its calls. A call placed by the AI or by a person is a `service` call unless it says otherwise. Destinations that aren’t phone numbers (a SIP address or an internal extension) aren’t checked against the lists. ## The decision, step by step 1. **Internal do-not-call list.** If the number is on your list, the call is blocked, whatever its purpose. If the list can’t be read, the call is blocked too: MoreVoice never dials when it can’t check. A supervisor can allow **one** call to a listed number, placed by a person or by the AI at a person’s request, within the next 30 minutes and with a written reason. The override is recorded in the audit log, and it never applies to campaigns or to the national registry. 2. **Consent (marketing only).** The number needs an active marketing consent record. Campaigns check consent per contact, from the consent column of the import or a consent record; contacts without it are skipped with the reason `no-consent`. This can’t be turned off. 3. **National registry (marketing to Israeli numbers only).** Once you connect the registry in National do-not-call registry, MoreVoice checks marketing numbers against it: * a registered number is blocked; * an answer is reused for up to 14 days (configurable up to 15, the legal limit) and checked again after that; * an hourly pre-flight checks the next 24 hours of campaign contacts ahead of time, so dialling rarely waits; * if the registry can’t answer and there is no recent answer, the call is blocked by default (Block when the registry can’t answer). A blocked call isn’t placed. It is written to the audit log (`outbound.blocked`, with the reason `dnc`, `no-consent`, `registry` or `registry-unavailable`), and the app’s dial request answers `403`. Over the API, `POST /v1/calls` requires a `purpose`, and a blocked call answers `403` with an error of type `compliance_error` whose `code` names the rule (`dnc_listed`, `consent_missing`, `registry_listed`, `registry_unavailable` or `compliance_blocked`) and whose `details.verdict` lists every check: see [outbound calls](/guides/outbound-calls/#purpose-and-compliance). ## Do-not-call list The list is checked when a contact list is imported and again right before every dial, because a number may have been added in between. Adding a number also takes it out of every campaign where it is still waiting to be called. Numbers reach the list by hand, from an imported file, through the API, or when the person on a call opts out (below). You manage the list under SettingsCompliance. ## Opt-out during a call On campaign calls, people can ask not to be called again in three ways: * **Saying it.** A list of opt-out phrases (“תורידו אותי”, “הסירו אותי”, “אל תתקשרו אליי” and more) is matched in what the person says, ignoring punctuation and niqqud. “לא להסיר אותי” is not an opt-out. * **Pressing a key.** `9` by default; a campaign can choose its own key. * **Asking the AI in other words.** The AI agent has an opt-out tool for wordings the phrase list doesn’t cover. Any of the three adds the number to the do-not-call list at once, speaks a confirmation (“בסדר גמור, הסרנו את המספר שלך מרשימת החיוג…”), ends the call (end reason `opted-out`), and records the outcome. Your webhook endpoints receive a [`contact.opted_out`](/webhooks/events/#contact.opted_out) event (`dnc.added` in the legacy names), so your CRM can stop calling too. ## Disclosure On campaign calls, the AI can say a disclosure line before its first message. The default is “שלום, זוהי שיחה ממערכת בינה מלאכותית, והשיחה מוקלטת” (“Hello, this is a call from an artificial-intelligence system, and the call is recorded”). It is on by default for marketing campaigns; service and survey campaigns can turn it on, and you can change the text. ## Calling hours, Shabbat and holidays Campaigns dial only when every time rule allows it: * **Global calling hours**, which no campaign can exceed. The default is Sunday to Thursday 08:00–21:00 and Friday 08:00–14:00, Israel time. * **The campaign’s own windows**, in each contact’s time zone. They can only narrow the global hours. * **Shabbat and Yom Tov.** No calls from candle lighting until havdalah, on the Israeli calendar, with an extra margin before candle lighting (60 minutes by default). Multi-day holidays next to Shabbat count as one period. * **The campaign’s start and end dates.** When a rule says no, the dialer waits for the next moment every rule allows. A campaign whose windows never overlap the global hours in the next three weeks can’t start. Scheduled callbacks follow the same calling hours unless you give them their own. ## Over the API Your CRM, sign-up forms and unsubscribe links are where consent is given and withdrawn. Keep MoreVoice in step with them through `/v1/consents` and `/v1/dnc`, and the dialer enforces whatever they say on the next call. The key needs the `consents:write` and `dnc:write` scopes (the Campaigns integration preset has both). Live keys only for changes The do-not-call list and consent records are your live compliance data: the dialer reads them for every call. A test key can read them and run checks, but adding or removing an entry needs a live key (`403 live_only`), so a test integration can never add consent evidence or lift an opt-out by mistake. ### Record consent Record a marketing consent when the person gives it, with where it was given and what proves it: the form’s URL and the tick box, a recording reference, a signed document’s ID. Up to 1,000 numbers per request, in any common format: * cURL ```sh # Record the marketing consent (§30A) your sign-up form collected, with what proves it. curl https://api.morevoice.ai/v1/consents \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phones": ["+972501234567", "052-765-4321"], "source": "web_form", "evidence": "https://example.com/forms/newsletter (checkbox, IP 198.51.100.7)", "granted_at": "2026-10-30T12:00:00.000Z" }' ``` * Node.js record-consent.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Record the marketing consent (§30A) your sign-up form collected, with what proves it. const batch = await mv.consents.create({ phones: ["+972501234567", "052-765-4321"], source: "web_form", evidence: "https://example.com/forms/newsletter (checkbox, IP 198.51.100.7)", granted_at: "2026-10-30T12:00:00.000Z", }); console.log("recorded", batch.recorded, "invalid", batch.invalid); ``` * Python record_consent.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Record the marketing consent (§30A) your sign-up form collected, with what proves it. response = requests.post( f"{API}/consents", headers=HEADERS, json={ "phones": ["+972501234567", "052-765-4321"], "source": "web_form", "evidence": "https://example.com/forms/newsletter (checkbox, IP 198.51.100.7)", "granted_at": "2026-10-30T12:00:00.000Z", }, timeout=30, ) response.raise_for_status() batch = response.json() print("recorded", batch["recorded"], "invalid", batch["invalid"]) ``` The answer lists the numbers `recorded` and those that are `invalid`. `granted_at` is when the person agreed (by default, now; never in the future). Each record sends a [`consent.recorded`](/webhooks/events/#consent.recorded) event, and so does each revocation (with `revoked: true`). Campaign contacts imported without consent are dialled once their consent is recorded. ### Honour an opt-out When someone opts out anywhere else (an unsubscribe link, a reply to a text, your agents’ CRM), add the number to the do-not-call list. No call of any purpose reaches it afterwards, and it is taken out of every campaign where it is still waiting: * cURL ```sh # Someone opted out in your CRM: no call of any purpose reaches these numbers from now on. curl https://api.morevoice.ai/v1/dnc \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phones": ["+972501234567"], "reason": "Opted out in the CRM" }' ``` * Node.js add-to-dnc.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Someone opted out in your CRM: no call of any purpose reaches these numbers from now on. const batch = await mv.dnc.create({ phones: ["+972501234567"], reason: "Opted out in the CRM" }); console.log("added", batch.added, "already listed", batch.already_listed); ``` * Python add_to_dnc.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Someone opted out in your CRM: no call of any purpose reaches these numbers from now on. response = requests.post( f"{API}/dnc", headers=HEADERS, json={"phones": ["+972501234567"], "reason": "Opted out in the CRM"}, timeout=30, ) response.raise_for_status() batch = response.json() print("added", batch["added"], "already listed", batch["already_listed"]) ``` Each number added sends one `contact.opted_out` event, just as an opt-out during a call does, so one webhook handler keeps your CRM’s flag and MoreVoice’s list the same in both directions. `DELETE /v1/dnc/{phone}` removes a number (marketing calls still need consent and the registry), and `GET /v1/dnc?phone=…` looks one up. ### Withdraw consent When a person withdraws their consent, revoke it. The record stays, with `revoked_at`, as evidence of what you held and when: * cURL ```sh # The person withdrew their consent. The record stays, with revoked_at, as evidence. URL-encode the + as %2B. curl -X DELETE https://api.morevoice.ai/v1/consents/%2B972501234567 \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js revoke-consent.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // The person withdrew their consent. The record stays, with revoked_at, as evidence. const revocation = await mv.consents.delete("+972501234567"); console.log(revocation.phone, "revoked at", revocation.revoked_at); ``` * Python revoke_consent.py ```python import os from urllib.parse import quote import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # The person withdrew their consent. The record stays, with revoked_at, as evidence. URL-encode the + as %2B. phone = quote("+972501234567", safe="") response = requests.delete(f"{API}/consents/{phone}", headers=HEADERS, timeout=30) response.raise_for_status() revocation = response.json() print(revocation["phone"], "revoked at", revocation["revoked_at"]) ``` Phone numbers in a path are URL-encoded: `+972501234567` becomes `%2B972501234567`. The SDKs do it for you. ### Check numbers before calling `POST /v1/dnc/check` answers, for up to 100 numbers and a purpose, whether a call would be allowed now: the do-not-call list, recorded consent and the national registry, combined into `callable`, with `reasons` when it isn’t. It reads only; nothing is recorded: * cURL ```sh # Would a marketing call to these numbers be allowed? Your do-not-call list, consent and the national registry. curl https://api.morevoice.ai/v1/dnc/check \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phones": ["+972501234567", "0527654321"], "purpose": "marketing" }' ``` * Node.js check-numbers.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Would a marketing call to these numbers be allowed? Your do-not-call list, consent and the national registry. const check = await mv.dnc.check({ phones: ["+972501234567", "0527654321"], purpose: "marketing" }); for (const r of check.results) { console.log(r.phone ?? r.input, r.callable ? "callable" : `blocked: ${r.reasons.join(", ")}`, `registry: ${r.registry.status}`); } ``` * Python check_numbers.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Would a marketing call to these numbers be allowed? Your do-not-call list, consent and the national registry. response = requests.post( f"{API}/dnc/check", headers=HEADERS, json={"phones": ["+972501234567", "0527654321"], "purpose": "marketing"}, timeout=30, ) response.raise_for_status() for r in response.json()["results"]: verdict = "callable" if r["callable"] else "blocked: " + ", ".join(r["reasons"]) print(r["phone"] or r["input"], verdict, "registry:", r["registry"]["status"]) ``` | `reasons` | Why the call isn’t allowed | | ---------------------- | ---------------------------------------------------------------------------- | | `invalid_number` | Not a phone number. | | `dnc_listed` | On your do-not-call list. | | `consent_missing` | Marketing without an active consent record. | | `registry_listed` | Marketing to a number on the national registry. | | `registry_unavailable` | Marketing, and the registry couldn’t answer and has no recent answer cached. | Live keys look a number up in the registry when there is no recent answer; test keys use cached answers only, and a missing answer doesn’t block them. Calling hours aren’t part of the check: the dialer applies them when it dials. ### Campaigns A campaign checks all of this per contact, just before each call. `GET /v1/campaigns/{id}/preflight` lists what would stop a campaign from starting (no recorded consent, no registry for a marketing campaign, no open calling window in the next three weeks), and contacts that are skipped carry a `skip_reason` such as `no_consent`, `dnc_dial_time` or `registry`. See [campaigns](/guides/campaigns/#start-the-campaign). ## Where it is configured | Setting | In the app | | ----------------------------------------------------------------------------------------------- | ----------------------------------------------- | | Calling hours, Shabbat and holiday rules, disclosure, opt-out phrases and key, do-not-call list | SettingsCompliance | | National registry connection and its policy | SettingsComplianceNational do-not-call registry | | A campaign’s purpose, windows, disclosure and opt-out key | The campaign’s setup | # Copilot: real-time help for human agents > The copilot listens to your agents' calls and suggests what to say: objection answers, knowledge-base facts, the checklist, required disclosures and coaching nudges. Build copilot profiles over the API, attach them to queues, and read what the copilot did on each call. The **copilot** works for your human agents, not instead of them. It listens to an agent’s call as it happens and puts cards next to it in the agent workspace: the answer to the objection the customer just raised, a fact from your knowledge base, the next item on the checklist, the disclosure the agent hasn’t read yet, a nudge to stop talking and ask a question. Supervisors see its alerts on their board. What the copilot knows and suggests comes from a **copilot profile**: the playbook for one kind of call, such as policy renewals or customer service. Supervisors build profiles in the app, under Copilot & knowledge; over the API you create and change them from code, keep them in your own version control, and attach them to queues. Plans The copilot is part of every contact-centre plan and is billed per copilot seat. The Developer plan doesn’t include it. ## Start from a template Two templates cover the common cases: `sales-outbound` (opening, discovery, offer, objections and close) and `customer-service`. List them to see everything they set: * cURL ```sh # The ready-made profiles you can start from. curl https://api.morevoice.ai/v1/copilot_profiles/templates \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js list-templates.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // The ready-made profiles you can start from. for await (const template of mv.copilotProfiles.listTemplates()) { console.log(template.template_id, template.name, "—", template.description); } ``` * Python list_templates.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # The ready-made profiles you can start from. response = requests.get(f"{API}/copilot_profiles/templates", headers=HEADERS, timeout=30) response.raise_for_status() for template in response.json()["data"]: print(template["template_id"], template["name"], "—", template["description"]) ``` Create a profile from a template and override what is yours. Fields you send replace the template’s; everything else comes from it: * cURL ```sh # A customer-service profile from the template, with your company, an objection and a disclosure the agent must read. curl https://api.morevoice.ai/v1/copilot_profiles \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template_id": "customer-service", "name": "Policy renewals", "company": "Acme Insurance", "product": "Home and car insurance", "language": "he", "objections": [ { "key": "price", "label": "Too expensive", "triggers": ["יקר מדי", "זה יקר"], "response": "Compare the renewal with the cost of a claim without cover, then offer the annual payment discount.", "kb_query": "renewal discounts" } ], "compliance": { "required_disclosures": [ { "key": "recording", "label": "Recording notice", "phrases": ["השיחה מוקלטת"], "script": "לידיעתך, השיחה מוקלטת לצורכי שירות ובקרה.", "deadline_seconds": 30 } ] } }' ``` * Node.js create-profile.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // A customer-service profile from the template, with your company, an objection and a disclosure the agent must read. const profile = await mv.copilotProfiles.create({ template_id: "customer-service", name: "Policy renewals", company: "Acme Insurance", product: "Home and car insurance", language: "he", objections: [ { key: "price", label: "Too expensive", triggers: ["יקר מדי", "זה יקר"], response: "Compare the renewal with the cost of a claim without cover, then offer the annual payment discount.", kb_query: "renewal discounts", }, ], compliance: { required_disclosures: [ { key: "recording", label: "Recording notice", phrases: ["השיחה מוקלטת"], script: "לידיעתך, השיחה מוקלטת לצורכי שירות ובקרה.", deadline_seconds: 30, }, ], }, }); console.log(profile.id, `version ${profile.version}`, `${profile.objections.length} objections`); ``` * Python create_profile.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # A customer-service profile from the template, with your company, an objection and a disclosure the agent must read. response = requests.post( f"{API}/copilot_profiles", headers=HEADERS, json={ "template_id": "customer-service", "name": "Policy renewals", "company": "Acme Insurance", "product": "Home and car insurance", "language": "he", "objections": [ { "key": "price", "label": "Too expensive", "triggers": ["יקר מדי", "זה יקר"], "response": "Compare the renewal with the cost of a claim without cover, then offer the annual payment discount.", "kb_query": "renewal discounts", } ], "compliance": { "required_disclosures": [ { "key": "recording", "label": "Recording notice", "phrases": ["השיחה מוקלטת"], "script": "לידיעתך, השיחה מוקלטת לצורכי שירות ובקרה.", "deadline_seconds": 30, } ] }, }, timeout=30, ) response.raise_for_status() profile = response.json() print(profile["id"], f"version {profile['version']}", f"{len(profile['objections'])} objections") ``` A new organisation gets one profile per template the first time it lists its profiles, so there is always something to start from. ## What a profile holds | Field | What it sets | | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `goal`, `tone`, `company`, `product`, `language` | What the call is for, how the agent should sound, who they represent, and the calls’ language. | | `stages` | The call’s stages, each with a `checklist`. A checklist item can tick itself when the agent says one of its `phrases`, and has a question (`ask`) the copilot suggests while it is open. | | `script` | Instead of the linear stages, an **agent-script** [flow](/guides/flows/) that drives the call branch by branch (`flow_id`, and `version`: `published` or a pinned number). | | `objections` | The objection library: what the customer may say (`triggers` match at once, `examples` match by meaning), the recommended `response`, a `follow_up`, and a `kb_query` whose facts are attached to the card. | | `kb` | Which knowledge-base documents the copilot may answer from (`document_ids`; empty: all), reference `links`, and the confidence (`min_score`) above which it shows a knowledge card unasked. | | `fields` | Details the copilot picks out of the conversation (a budget, a policy number, a date), with a `type` and an optional `pattern`. | | `compliance` | `required_disclosures`: what the agent must say, the exact `script` to read if they missed it, and when (`deadline_seconds`, or before leaving a stage). `forbidden_phrases`: what they mustn’t say, why, and safer wording (`instead`). | | `coaching` | Thresholds for nudges: the agent’s share of talk time, monologue length, dead air, speaking rate. | | `alerts` | Conditions to flag on the call (a keyword, negative sentiment, silence, a long call, a request for a manager), their `severity`, and whether to raise them on the supervisor board. | | `qa_rubric` | The [QA](/guides/qa/) rubric these calls are scored against, instead of the organisation’s. | | `model` | How suggestions are made: the model and its fallback, latency and cost controls (`speculative`, `deep_path`, `max_calls_per_minute`, `priority`). | `PATCH /v1/copilot_profiles/{id}` changes a profile: nested objects (`kb`, `compliance`, `coaching`, `script`, `model`) merge with the stored values, and lists (`stages`, `objections`, `alerts`…) replace them, so send the whole list. Every change raises the profile’s `version`; calls that start afterwards use the new version, and calls in progress keep theirs. `POST /v1/copilot_profiles/{id}/duplicate` copies a profile, for an A/B test of two playbooks. The help centre explains each part of a profile from the supervisor’s side: [copilot profiles](https://help.morevoice.ai/en/copilot/profiles/), [objections](https://help.morevoice.ai/en/copilot/objections/) and [fields and compliance](https://help.morevoice.ai/en/copilot/fields-and-compliance/). ## Give agents a profile Calls answered from a queue get the queue’s profile: * cURL ```sh # Agents who answer the Support queue get this profile on every call. curl -X PATCH https://api.morevoice.ai/v1/queues/$QUEUE_ID \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "copilot_profile_id": "'"$COPILOT_PROFILE_ID"'" }' ``` * Node.js attach-to-queue.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Agents who answer the Support queue get this profile on every call. const queue = await mv.queues.update(process.env.QUEUE_ID!, { copilot_profile_id: process.env.COPILOT_PROFILE_ID! }); console.log(queue.name, queue.copilot_profile_id); ``` * Python attach_to_queue.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Agents who answer the Support queue get this profile on every call. response = requests.patch( f"{API}/queues/{os.environ['QUEUE_ID']}", headers=HEADERS, json={"copilot_profile_id": os.environ["COPILOT_PROFILE_ID"]}, timeout=30, ) response.raise_for_status() queue = response.json() print(queue["name"], queue["copilot_profile_id"]) ``` For calls they place themselves, agents pick a profile in the workspace before dialling. Deleting a profile leaves its queues without one, and their calls run without the copilot’s playbook. ## See what the copilot did `GET /v1/calls/{id}/copilot_events` lists what the copilot showed on a call and what the agent did with it: cards shown, used or dismissed, knowledge searches and deeper answers, each with its time in the call, the card’s kind, how long the suggestion took and the profile version: * cURL ```sh # What the copilot showed on a call, and what the agent did with it. curl https://api.morevoice.ai/v1/calls/$CALL_ID/copilot_events \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js copilot-events.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // What the copilot showed on a call, and what the agent did with it. for await (const event of mv.calls.listCopilotEvents(process.env.CALL_ID!)) { console.log(`${(event.at_ms / 1000).toFixed(1)}s`, event.type, event.kind ?? "", event.card_id ?? "", event.latency_ms ?? ""); } ``` * Python copilot_events.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # What the copilot showed on a call, and what the agent did with it. response = requests.get(f"{API}/calls/{os.environ['CALL_ID']}/copilot_events", headers=HEADERS, timeout=30) response.raise_for_status() for event in response.json()["data"]: print(f"{event['at_ms'] / 1000:.1f}s", event["type"], event["kind"] or "", event["card_id"] or "", event["latency_ms"] or "") ``` Use it to see which objection answers agents actually use, or to tune `kb.min_score` when knowledge cards are dismissed more than used. [Insights](/guides/qa/#insights) sums up copilot adoption over a period, and the [QA scorecard](/guides/qa/) of each call shows how the playbook was followed. Test a profile on yourself Profiles are configuration, shared by test and live keys. To try one, attach it to a queue that only you answer, call the queue, and read the copilot events afterwards. # Custom tools: the signed tool-call contract > Let an assistant call your server during a call, check that every request really comes from MoreVoice, and answer in a shape the assistant can use. A **custom tool** lets an assistant use your systems in the middle of a call: find free appointment slots, check an order, open a ticket. You describe the tool, and when the assistant decides it needs it, MoreVoice sends a signed `POST` to your server and gives your answer to the assistant, which tells the caller. 1. The caller asks something only your systems know: “יש תור פנוי ביום חמישי?”. 2. The model calls your tool with arguments that match its JSON schema: `{ "date": "2026-11-05" }`. If you set `request_start_message`, the assistant says it while it waits. 3. MoreVoice `POST`s the call to the tool’s URL, signed with your tool signing secret. 4. Your server checks the signature, does the work and answers with JSON. 5. The model reads your answer and phrases the reply to the caller. ## Define a tool Tools live on the assistant, in `tools.custom`. A `PATCH` replaces the whole list, so send every tool the assistant should keep: * cURL ```sh # Give the assistant a tool. `tools.custom` replaces the assistant's whole list of custom tools. curl -X PATCH https://api.morevoice.ai/v1/assistants/$ASSISTANT_ID \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tools": { "custom": [ { "name": "find_slots", "description": "Find free appointment slots for a doctor on a date. Call it before offering any time.", "parameters": { "type": "object", "properties": { "doctor": { "type": "string", "description": "The doctor'"'"'s family name, if the caller named one" }, "date": { "type": "string", "description": "The date, YYYY-MM-DD" } }, "required": ["date"] }, "url": "https://clinic.example.com/morevoice/tools", "headers": { "Authorization": "Bearer '"$CLINIC_API_TOKEN"'" }, "timeout_seconds": 10 } ] } }' ``` * Node.js add-tool.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Give the assistant a tool. `tools.custom` replaces the assistant's whole list of custom tools. await mv.assistants.update(process.env.ASSISTANT_ID!, { tools: { custom: [ { name: "find_slots", description: "Find free appointment slots for a doctor on a date. Call it before offering any time.", parameters: { type: "object", properties: { doctor: { type: "string", description: "The doctor's family name, if the caller named one" }, date: { type: "string", description: "The date, YYYY-MM-DD" }, }, required: ["date"], }, url: "https://clinic.example.com/morevoice/tools", headers: { Authorization: `Bearer ${process.env.CLINIC_API_TOKEN}` }, // stored encrypted, never returned timeout_seconds: 10, }, ], }, }); ``` * Python add_tool.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} assistant_id = os.environ["ASSISTANT_ID"] # Give the assistant a tool. `tools.custom` replaces the assistant's whole list of custom tools. response = requests.patch( f"{API}/assistants/{assistant_id}", headers=HEADERS, json={ "tools": { "custom": [ { "name": "find_slots", "description": "Find free appointment slots for a doctor on a date. Call it before offering any time.", "parameters": { "type": "object", "properties": { "doctor": {"type": "string", "description": "The doctor's family name, if the caller named one"}, "date": {"type": "string", "description": "The date, YYYY-MM-DD"}, }, "required": ["date"], }, "url": "https://clinic.example.com/morevoice/tools", # Stored encrypted, never returned. "headers": {"Authorization": f"Bearer {os.environ['CLINIC_API_TOKEN']}"}, "timeout_seconds": 10, } ] } }, timeout=30, ) response.raise_for_status() ``` | Field | What it does | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | The function name the model calls: letters, digits, `_` and `-`, up to 64, unique per assistant. | | `description` | What the tool does and when to use it. The model decides from this, so be specific: “Call it before offering any time.” | | `parameters` | A JSON Schema object for the arguments. Give each property a `description`, and list the ones the model must always send in `required`. | | `url` | Where MoreVoice sends the call (`http` or `https`). | | `headers` | Extra request headers, such as your own `Authorization`. They are stored encrypted and never returned: responses list only `header_names`. Leave `headers` out of an update to keep the stored ones. | | `timeout_seconds` | How long to wait for your answer: 1–60 seconds, 15 by default. | | `request_start_message` | A sentence the assistant says while the tool runs, such as “רגע, אני בודקת”. | | `type` | `webhook` (the default when there is a `url`), or `mock`, which answers `mock_response` without any request: handy for working on the instructions before your server exists. | | `enabled` | `false` keeps the tool but stops offering it to the model. | An assistant can have up to 50 custom tools. ## The request your server receives POST https\://clinic.example.com/morevoice/tools ```http Content-Type: application/json User-Agent: MoreVoice-Tools/1 Authorization: Bearer webhook-id: tc_5LwTfK2vXq9mR3nB8cY1aD webhook-timestamp: 1762161262 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= {"tool":"find_slots","arguments":{"date":"2026-11-05","doctor":"Levi"},"call":{"id":"9b2f4c1e-…"}} ``` | Part | Meaning | | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `tool` | The tool’s `name`. One URL can serve several tools. | | `arguments` | What the model filled in, following your `parameters` schema. Check them: a model can still send something unexpected. | | `call.id` | Identifies the call. It is the same for every tool call of one call, so you can group them. | | `webhook-id` | Unique to this tool call (`tc_…`). If you must never act twice, remember the IDs you have handled. | | `webhook-timestamp`, `webhook-signature` | The [Standard Webhooks](https://www.standardwebhooks.com) signature. Check it before anything else. | Your own `headers` go out with every call, but they can’t replace the three `webhook-` headers. ## Check the signature The signature proves the request comes from MoreVoice and nobody changed it. It is the same scheme as [webhooks](/webhooks/verify/), with a different secret: the organisation’s **tool signing secret** (`whsec_…`). Live mode and test mode each have their own. 1. Reject the request if `webhook-timestamp` is more than 5 minutes from your clock. 2. Base64-decode the secret without its `whsec_` prefix. 3. Compute HMAC-SHA256 over `..` and base64-encode it. 4. Compare it in constant time with each `v1,` in `webhook-signature` (separated by spaces). One match is enough. Read the raw body before you parse it: the signature covers the exact bytes MoreVoice sent. In Node.js, `tools.nodeHandler` (or `tools.handler` for Fetch-API runtimes) from `@morevoice/sdk` does all of this, checks the arguments against the schema and keeps your handler within a time limit. In Python, the standard library is enough. From the command line, the cURL tab sends your server a call signed the same way, so you can test it before MoreVoice calls it: * cURL sign-tool-call.sh ```sh # Send your tool server a signed test call, signed the way MoreVoice signs it: Standard Webhooks headers over the exact # body, with the tool signing secret of the mode you test. (POST /v1/tools/test sends the same call from MoreVoice.) # MOREVOICE_TOOL_SECRET=whsec_… sh sign-tool-call.sh http://localhost:3000/morevoice/tools set -eu url="${1:-http://localhost:3000/morevoice/tools}" body='{"tool":"find_slots","arguments":{"date":"2026-11-05"},"call":{"id":"test"}}' msg_id="tc_$(openssl rand -hex 11)" timestamp=$(date +%s) key=$(printf '%s' "${MOREVOICE_TOOL_SECRET#whsec_}" | openssl base64 -d -A | od -An -v -tx1 | tr -d ' \n') signature=$(printf '%s.%s.%s' "$msg_id" "$timestamp" "$body" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$key" -binary | openssl base64 -A) curl -sS "$url" \ -H "Content-Type: application/json" \ -H "webhook-id: $msg_id" \ -H "webhook-timestamp: $timestamp" \ -H "webhook-signature: v1,$signature" \ -d "$body" ``` * Node.js tool-server.ts ```ts // A custom tool server on Node's http module. tools.nodeHandler verifies the signature before // your code runs, checks the arguments against the schema and answers in the expected shape. import { createServer } from "node:http"; import { defineTool, tools } from "@morevoice/sdk"; const handle = tools.nodeHandler({ secret: process.env.MOREVOICE_TOOL_SECRET!, // whsec_…, the tool signing secret of the key's mode timeoutMs: 8_000, // answer before the tool's timeout_seconds (10) runs out handlers: { find_slots: defineTool({ parameters: { type: "object", properties: { doctor: { type: "string" }, date: { type: "string" } }, required: ["date"], }, async handler({ doctor, date }) { const slots = await freeSlots(date, doctor); // The assistant phrases its answer from this result. return { result: { date, slots: slots.slice(0, 3) } }; }, }), }, }); createServer((req, res) => { if (req.method === "POST" && req.url === "/morevoice/tools") return void handle(req, res); res.writeHead(404).end(); }).listen(3000); // Your scheduling system goes here. async function freeSlots(date: string, doctor?: string): Promise { return doctor === "Levi" ? [] : [`${date}T09:30`, `${date}T11:00`, `${date}T15:45`]; } ``` * Python tool_server.py ```python # A custom tool server with Python's standard library only (Python 3.10+). import base64 import hashlib import hmac import json import os import time from http.server import BaseHTTPRequestHandler, HTTPServer TOLERANCE_SEC = 5 * 60 def verify_signature(secret: str, headers, body: bytes, now: int | None = None) -> bool: """True when the request was signed by MoreVoice with `secret` (whsec_…) in the last 5 minutes.""" msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not msg_id or not timestamp or not signatures or not timestamp.isdigit(): return False now = int(time.time()) if now is None else now if abs(now - int(timestamp)) > TOLERANCE_SEC: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() # Two signatures while the secret rotates: either one is enough. return any( version == "v1" and hmac.compare_digest(signature, expected) for version, _, signature in (entry.partition(",") for entry in signatures.split(" ")) ) def find_slots(arguments: dict) -> dict: # Your scheduling system goes here. date = arguments["date"] slots = [] if arguments.get("doctor") == "Levi" else [f"{date}T09:30", f"{date}T11:00", f"{date}T15:45"] return {"date": date, "slots": slots[:3]} TOOLS = {"find_slots": find_slots} class ToolHandler(BaseHTTPRequestHandler): def do_POST(self): body = self.rfile.read(int(self.headers.get("Content-Length", 0))) if not verify_signature(os.environ["MOREVOICE_TOOL_SECRET"], self.headers, body): return self.answer(401, {"error": {"message": "Bad signature"}}) call = json.loads(body) # {"tool": "find_slots", "arguments": {...}, "call": {"id": "..."}} tool = TOOLS.get(call["tool"]) if tool is None: return self.answer(404, {"error": {"message": f"Unknown tool {call['tool']}"}}) try: # The assistant phrases its answer from this result. return self.answer(200, {"result": tool(call["arguments"])}) except Exception as err: # the assistant can apologise and go on return self.answer(200, {"error": {"message": str(err)}}) def answer(self, status: int, payload: dict): data = json.dumps(payload, ensure_ascii=False).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) if __name__ == "__main__": HTTPServer(("", 3000), ToolHandler).serve_forever() ``` To test your check, run it against the [test vector](/webhooks/verify/#test-your-code): tool calls are signed the same way. The tool signing secret in the dashboard Developers will show the tool signing secret of each mode and rotate it. When a secret rotates, MoreVoice keeps signing with the old one too for 24 hours (up to 72), sending both signatures, so your server can switch without failing a single call. ## Answer the call Answer with JSON and status `200` within `timeout_seconds`. Everything you answer goes to the model as the tool’s result, so answer with the facts it needs and nothing else: 200 OK ```json { "result": { "date": "2026-11-05", "slots": ["2026-11-05T09:30", "2026-11-05T11:00"] } } ``` * **Errors.** Any other status reaches the model as `{ "error": "HTTP 503", "body": }`. A timeout or a network error reaches it as an error too. Either way the call goes on and the assistant can apologise or offer something else; an `{ "error": { "message": "…" } }` answer with status `200` gives it a reason it can explain. * **One attempt.** A tool call is not retried: the caller is waiting. * **Size.** An answer larger than 1 MB fails. Keep answers small: the model reads every word. * **Speed.** The caller hears silence (or your `request_start_message`) while your server works. Aim for under a second or two. ## Which URLs can be called The URL must be reachable from the internet over `http` or `https`, on port 80, 443, or any port from 1024 up. Addresses inside a private network (`localhost`, `10.x`, `192.168.x`) are refused unless an administrator allows private network addresses for your organisation, and every redirect is checked the same way. ## Test a tool `POST /v1/tools/test` runs a tool once, exactly as a call would: the same body (with `call.id` set to `test`), the same signature headers (a test key signs with the test-mode secret) and the same network rules. Pass the tool inline as here, or name a stored one with `assistant_id` and `tool_name`: * cURL ```sh # Run the tool once, exactly as a call would: same body, same signature headers. curl https://api.morevoice.ai/v1/tools/test \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tool": { "name": "find_slots", "description": "Find free appointment slots for a doctor on a date.", "parameters": { "type": "object", "properties": { "date": { "type": "string" } }, "required": ["date"] }, "url": "https://clinic.example.com/morevoice/tools", "timeout_seconds": 10 }, "arguments": { "date": "2026-11-05" } }' ``` * Node.js test-tool.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Run the tool once, exactly as a call would: same body, same signature headers. const run = await mv.tools.test({ tool: { name: "find_slots", description: "Find free appointment slots for a doctor on a date.", parameters: { type: "object", properties: { date: { type: "string" } }, required: ["date"] }, url: "https://clinic.example.com/morevoice/tools", timeout_seconds: 10, }, arguments: { date: "2026-11-05" }, }); console.log(run.result); ``` * Python test_tool.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Run the tool once, exactly as a call would: same body, same signature headers. response = requests.post( f"{API}/tools/test", headers=HEADERS, json={ "tool": { "name": "find_slots", "description": "Find free appointment slots for a doctor on a date.", "parameters": {"type": "object", "properties": {"date": {"type": "string"}}, "required": ["date"]}, "url": "https://clinic.example.com/morevoice/tools", "timeout_seconds": 10, }, "arguments": {"date": "2026-11-05"}, }, timeout=90, ) response.raise_for_status() print(response.json()["result"]) ``` Response ```json { "object": "tool_test", "livemode": false, "tool": "find_slots", "type": "webhook", "ok": true, "duration_ms": 182, "result": { "date": "2026-11-05", "slots": ["2026-11-05T09:30", "2026-11-05T11:00", "2026-11-05T15:45"] }, "error": null } ``` When the tool fails, `ok` is `false` and `error.code` says how: `http_error` (with the `status` your server answered), `timeout`, `network`, `response_too_large` or `tool_error`. A URL that can’t be called at all answers `400` with the code `parameter_invalid`. ## Tools in flows A custom tool is the model’s choice: the assistant calls it when the conversation needs it. When a step of your process must always call your server, at a fixed point of the call, use an *API / Webhook* node in a [flow](/guides/flows/) instead: it sends the request you define, maps fields of the answer to variables, and branches on success, error or time-out. Develop against localhost The MoreVoice CLI will forward tool calls to a server on your machine (`morevoice listen`), signed as in production, so you can step through your handler while you talk to the assistant. Until then, the cURL tab above sends your server a signed call. ## The v1 tool contract Coming: rich call context and direct actions A tool will be able to choose `"contract": "v1"`. Its requests then carry the whole call context (`tool_call_id`, the call’s `call_…` ID, direction, numbers, assistant, language, `metadata` and `variables`, and `livemode`), and its answer can act on the call: `say` (a sentence to speak word for word), `end_call`, or `transfer` to a queue, a number or another assistant. Tools without `contract`, or with `"legacy"`, keep the request and answer described on this page. `tools.handler` and `tools.nodeHandler` already accept both. A v1 request (planned) ```json { "type": "tool.call", "tool_call_id": "tc_5LwTfK2vXq9mR3nB8cY1aD", "tool": "find_slots", "arguments": { "date": "2026-11-05" }, "call": { "id": "call_8tRPaZp5hLMbrGqdJ9AmNa", "direction": "inbound", "from": "+972501234567", "to": "+97237654321", "assistant_id": "asst_4Gk2LmN9pQ4rS6tV8wX0yZ", "language": "he", "metadata": {}, "variables": {} }, "livemode": true } ``` Tip Write the tool’s `description` and the instructions together. “Before offering an appointment, always call `find_slots`” in the instructions, and “Find free slots for a date” in the description, make the model call it at the right moment. # Errors, rate limits and concurrency > The error object and its types, request IDs, safe retries, request rate limits and their headers, and how many calls can run at once on each plan. ## The error object Every error answers an HTTP status from `400` up and the same JSON body: 403 Forbidden ```json { "error": { "type": "compliance_error", "code": "dnc_listed", "message": "050-123-4567 is on the do-not-call list (pressed the opt-out key, since 2026-10-12)", "doc_url": "https://docs.morevoice.ai/api/errors#dnc-listed", "details": { "verdict": { "allowed": false, "reason": "dnc", "purpose": "marketing", "phone": "+972501234567", "checks": [{ "kind": "dnc", "result": "block", "detail": "pressed the opt-out key" }] } }, "request_id": "req_4fG7hK2mN9pQ1rS3" } } ``` | Field | Meaning | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | The category. Decide what to do from it (below). | | `code` | A stable, machine-readable reason, such as `parameter_missing` or `concurrency_limit`. Switch on it. The [error codes](#error-codes) list them all. | | `message` | An explanation for people. It can change, so don’t parse it. | | `param` | The request field the error is about, such as `to` or `metadata[order_id]`. Present when there is one. | | `doc_url` | A link to the code in the [list below](#error-codes). | | `details` | Structured context for some codes: `required_scope`, the compliance `verdict`, the `preflight` checks of a campaign, the counts of a limit. | | `request_id` | The request’s `X-Request-Id`. Log it, and quote it when you contact support. | New codes are added as the API grows, so handle a code you don’t know by its `type`. ### Types and what to do | `type` | HTTP | What to do | | ----------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `invalid_request_error` | 400, 413, 415 | Fix the request: `param` names the field at fault. Sending it again unchanged fails the same way. | | `authentication_error` | 401 | Check the key: sent as `Authorization: Bearer …`, not revoked or expired, and allowed from this IP address. | | `permission_error` | 402, 403 | The key, the mode or the plan doesn’t allow this: add the scope, use a key of the right mode, or change the plan. `402` is billing: a used-up balance, a spend cap or the trial. | | `not_found` | 404 | No such object in this organisation and mode: a test key can’t see live objects, and the other way round. | | `conflict` | 409 | The object’s state doesn’t allow it now, for example a call that already ended. Read the object again before you decide. | | `rate_limit_error` | 429 | Wait for `Retry-After`, then retry with the same `Idempotency-Key`. | | `compliance_error` | 403 | The rules forbid this call: `details.verdict` says which one. Don’t retry until the reason changes. | | `idempotency_error` | 400, 409 | `request_in_progress`: retry shortly with the same key. `idempotency_mismatch`: use a new key for a different request. | | `api_error` | 500, 501, 503 | Retry with backoff and the same `Idempotency-Key`. If it persists, contact support with the `request_id`. | ## Handle errors The Node.js SDK throws an error class per type (`InvalidRequestError`, `PermissionError`, `ComplianceError`, `RateLimitError`…, all `MoreVoiceError`) with the fields above as `status`, `type`, `code`, `param`, `details` and `requestId`, and retries what can be retried. In other languages, read the body: * cURL ```sh # -i shows the response headers: X-Request-Id, RateLimit-Policy and RateLimit on every response, # Retry-After on a 429. Errors come back as {"error": {"type", "code", "message", …}}. curl -i https://api.morevoice.ai/v1/calls \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: offer-$CONTACT_ID" \ -d '{ "to": "+972501234567", "assistant_id": "'"$ASSISTANT_ID"'", "purpose": "marketing" }' ``` * Node.js handle-errors.ts ```ts import MoreVoice, { ComplianceError, MoreVoiceError, RateLimitError } from "@morevoice/sdk"; // The client retries network errors, 408, 429 and 5xx twice by default, with backoff and the // same Idempotency-Key, so a retried call never dials twice. const mv = new MoreVoice({ maxRetries: 2 }); const contactId = process.env.CONTACT_ID!; try { const call = await mv.calls.create( { to: "+972501234567", assistant_id: process.env.ASSISTANT_ID!, purpose: "marketing" }, { idempotencyKey: `offer-${contactId}` }, ); console.log(call.id); } catch (err) { if (err instanceof ComplianceError) { // Not a failure to retry: the rules say this number may not be called now. console.warn(`Not called (${err.code}):`, err.verdict); } else if (err instanceof RateLimitError) { // Still limited after the retries (for example, every line of the plan is busy). console.warn(`Try again in ${err.retryAfterMs ?? 5000} ms (${err.code})`); } else if (err instanceof MoreVoiceError) { // Quote the request ID if you contact support. console.error(err.status, err.type, err.code, err.message, err.param, err.requestId); } else { throw err; } } ``` * Python handle_errors.py ```python import os import time import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} def create_call(body: dict, idempotency_key: str, attempts: int = 3) -> dict: """POST /v1/calls, retrying 429 and 5xx with the same Idempotency-Key (so it never dials twice).""" for attempt in range(attempts): response = requests.post( f"{API}/calls", headers={**HEADERS, "Idempotency-Key": idempotency_key}, json=body, timeout=30, ) if response.ok: return response.json() error = response.json()["error"] if response.status_code == 429 or response.status_code >= 500: time.sleep(float(response.headers.get("Retry-After", 2**attempt))) continue # 4xx: fix the request. Quote the request ID if you contact support. raise RuntimeError(f"{response.status_code} {error['type']}/{error['code']}: {error['message']} ({error['request_id']})") raise RuntimeError(f"Still failing after {attempts} attempts: {error['code']} ({error['request_id']})") call = create_call( {"to": "+972501234567", "assistant_id": os.environ["ASSISTANT_ID"], "purpose": "marketing"}, idempotency_key=f"offer-{os.environ['CONTACT_ID']}", ) print(call["id"]) ``` ### When to retry * **Retry** network errors, timeouts, `429` and `5xx`, with growing waits (or the `Retry-After` you were given), and with the **same** `Idempotency-Key`: MoreVoice then does the work only once, even if an earlier attempt got through. The SDK retries twice by default. * **Don’t retry unchanged** a `400`, `401`, `403`, `404` or `409` (other than `request_in_progress`): the same request fails the same way. A `compliance_error` in particular means the call may not be made now. * A `5xx`, `409` or `429` answer doesn’t use up the `Idempotency-Key`; any other answer is stored, and a retry with the key gets it back. ## Rate limits Requests are counted per API key, in two buckets: reads (`GET`) and writes (every other method). Each bucket holds a burst of twice its per-second rate and refills continuously, so short bursts are fine. New calls (`POST /v1/calls`) are also counted per organisation and mode, in calls per second. Every response tells you where you stand, following the IETF `RateLimit` header draft: ```http RateLimit-Policy: "write";q=40;w=2, "call_starts";q=5;w=1 RateLimit: "write";r=37;t=1, "call_starts";r=4;t=1 ``` `q` is the burst a bucket holds and `w` the seconds it takes to refill; `r` is what is left now and `t` the seconds until the bucket is full again. When a bucket is empty the request answers `429` with the code `rate_limited`, a `Retry-After` header in seconds, and `details` with the `limit_type`, `limit`, `rate` and `retry_after_s`. A refused request costs nothing: it uses up no `Idempotency-Key` and starts nothing. Test-mode keys get half the request and call rates of live keys. ## Calls in progress Each plan allows a number of calls in progress at once. When the organisation is at its limit, `POST /v1/calls` answers `429` with the code `concurrency_limit` before anything is dialled, with `details.limit_type` (`concurrent_ai_calls` or `concurrent_calls`), `details.active` and `details.limit`, and `Retry-After`. Retry when a call ends, or let a [campaign](/guides/campaigns/) pace the calls for you: it keeps to its `max_concurrent` and waits for free lines on its own. `capacity_exceeded` (also `429`) means no line is free for another reason: the SIP connection’s channels or the platform’s own capacity. It is short-lived; retry after `Retry-After`. Test mode has its own quotas: test calls in progress at once (`concurrency_limit` with `limit_type: "test_concurrent_calls"`), and test calls in any 24 hours (`test_call_limit`). ## Limits per plan | Limit | Trial | Starter | Growth | Business | Enterprise | Developer | | --------------------------------------- | ----- | ------- | ------ | -------- | ---------- | --------- | | AI calls in progress at once | 2 | 5 | 20 | 60 | 100 | 20 | | Calls in progress at once (all kinds) | 4 | 30 | 120 | 400 | No limit | 40 | | New calls per second (`POST /v1/calls`) | 1 | 2 | 5 | 5 | 10 | 5 | | Read requests per second, per key | 5 | 20 | 50 | 50 | 100 | 50 | | Write requests per second, per key | 2 | 10 | 20 | 20 | 40 | 20 | | Test calls in progress at once | 2 | 5 | 10 | 10 | 20 | 10 | | Test calls per 24 hours | 50 | 100 | 500 | 1,000 | No limit | 1,000 | Rates are per second; each request bucket holds a burst of twice its rate. An organisation without a plan gets 50 reads and 20 writes a second per key, 5 new calls a second, 10 test calls at once and 1,000 a day. Need more? Extra AI lines can be added to a plan, and limits can be raised per organisation: contact us. Note These limits are the beta’s starting values and may be tuned. Read the `RateLimit-Policy` header instead of hard-coding them. ## Billing limits When billing doesn’t allow new calls, `POST /v1/calls` answers `402` with a `permission_error` whose code says why, and `details.reason` repeats it: `balance_exhausted` (top up), `spend_cap` (the spend cap of the period is reached), `trial_limit` or `unverified_destination` (trial rules), `kyc_required` (business verification first), `fraud_hold` or `org_suspended` (contact support). `plan_limit` means another limit of the plan was reached. ## Error codes Every code the API answers, by type. Codes are never renamed or removed, only added. Each error’s `doc_url` opens its row here. ### Invalid request: `invalid_request_error` | Code | HTTP | Meaning | | ------------------------ | ---- | ----------------------------------------------------------------------------------- | | `parameter_missing` | 400 | A required parameter is missing. | | `parameter_invalid` | 400 | A parameter has the wrong type, format or value. | | `parameter_unknown` | 400 | The request contains a parameter this endpoint does not accept. | | `invalid_id` | 400 | An ID in the request body or query is malformed or has the wrong prefix. | | `invalid_cursor` | 400 | The pagination cursor is malformed or belongs to another list. | | `invalid_expand` | 400 | A field in expand\[] cannot be expanded on this endpoint. | | `unknown_version` | 400 | The MoreVoice-Version header names a version that does not exist. | | `invalid_json` | 400 | The request body is not valid JSON. | | `body_too_large` | 413 | The request body is larger than the endpoint accepts. | | `unsupported_media_type` | 415 | The request body must be sent as application/json. | | `bad_request` | 400 | The request could not be processed as sent. | | `dial_rejected` | 400 | The call could not be placed with these parameters (connection, caller ID, number). | ### Authentication: `authentication_error` | Code | HTTP | Meaning | | -------------------- | ---- | ------------------------------------------------------------------------------------------------ | | `api_key_missing` | 401 | No API key was sent. Send `Authorization: Bearer mv_…`. Session cookies are not accepted on /v1. | | `invalid_api_key` | 401 | The API key is malformed or does not exist. | | `key_revoked` | 401 | The API key was revoked. | | `key_expired` | 401 | The API key expired (for example, the old half of a rolled key). | | `ip_not_allowed` | 401 | The request came from an IP address outside the key’s allow-list. | | `origin_not_allowed` | 401 | A publishable key was used from a website origin outside its allow-list. | ### Permission: `permission_error` | Code | HTTP | Meaning | | ----------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------- | | `missing_scope` | 403 | The API key lacks a scope this endpoint needs (details.required_scope). | | `publishable_key_not_allowed` | 403 | Publishable keys can only call endpoints meant for browsers; use a secret key. | | `api_access_disabled` | 403 | API access is not enabled for this organisation. | | `feature_unavailable` | 403 | The organisation’s plan does not include this feature. | | `plan_limit` | 402 | The organisation reached a limit of its plan. | | `live_only` | 403 | This endpoint changes or reads live-only data (SIP connections, compliance lists); use a live key (mv_live\_…). | | `live_key_in_browser` | 403 | Live secret keys must stay on your server: a request from a browser (it sends an Origin header) needs a test key (mv_test\_…). | | `test_mode_only` | 403 | This endpoint simulates test-mode traffic (test helpers): use a test key (mv_test\_…). | | `livemode_mismatch` | 403 | Test-mode keys cannot touch live objects, and live keys cannot touch test objects. | | `test_mode_disabled` | 403 | Test mode is not enabled for this organisation yet, so test keys (mv_test\_…) are refused. | | `not_child_org` | 403 | The Mv-Account header names an organisation that is not a client of the key’s organisation. | | `forbidden` | 403 | The API key may not perform this action. | | `balance_exhausted` | 402 | The organisation’s prepaid balance is used up; top up to place calls. | | `prepaid_exhausted` | 402 | The organisation’s prepaid balance is used up; top up to place calls (same as balance_exhausted). | | `spend_cap` | 402 | The organisation reached its spend cap for this period. | | `trial_limit` | 402 | The trial’s call allowance is used up. | | `unverified_destination` | 402 | During the trial, calls may only go to verified numbers. | | `fraud_hold` | 402 | Calling is on hold for this organisation pending a review. Contact support. | | `org_suspended` | 402 | The organisation is suspended: calls are refused until it is reactivated. | | `kyc_required` | 403 | The organisation must complete business verification (KYC) before it can call out. | | `admission_denied` | 402 | Billing does not admit new outbound calls for the organisation right now (details.reason). | | `assistant_not_public` | 403 | Publishable keys can only start web calls to assistants that are public (widget settings); mint a client token on your server instead. | | `widget_origin_not_allowed` | 403 | The page’s origin is not one of the assistant’s widget origins. | ### Not found: `not_found` | Code | HTTP | Meaning | | ------------------ | ---- | -------------------------------------------------------------- | | `resource_missing` | 404 | No object with this ID exists (in this organisation and mode). | | `route_not_found` | 404 | No endpoint matches this method and path. | ### Conflict: `conflict` | Code | HTTP | Meaning | | --------------------------- | ---- | ----------------------------------------------------------------------------------------------------- | | `resource_conflict` | 409 | The request conflicts with the object’s current state. | | `call_not_active` | 409 | The call is not live: it ended, or it has not been answered yet. | | `call_in_progress` | 409 | The call is still in progress; hang it up first. | | `unsupported_for_call_mode` | 409 | This action is not available for this kind of call (details.call_type), e.g. hold on an AI call. | | `transfer_failed` | 409 | The transfer could not be completed (details.detail says why); the call continues. | | `summary_unavailable` | 409 | There is nothing to summarise yet (no customer speech in the transcript). | | `flow_invalid` | 409 | The flow’s draft has errors that block publishing (details.issues lists them, with the node of each). | | `preflight_failed` | 409 | The campaign can’t start yet: its pre-flight checks failed (details.preflight lists what to fix). | ### Rate limit: `rate_limit_error` | Code | HTTP | Meaning | | ------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------- | | `rate_limited` | 429 | Too many requests; retry after the Retry-After delay. | | `capacity_exceeded` | 429 | No call capacity right now (concurrency, CPS or worker limits); retry later. | | `concurrency_limit` | 429 | As many calls (or streams) are in progress as the plan allows (details.limit_type, details.active, details.limit); retry when one ends. | | `test_call_limit` | 429 | The organisation used its test-mode calls for the last 24 hours (details.limit); retry after the Retry-After delay, or use a live key. | ### Compliance: `compliance_error` | Code | HTTP | Meaning | | ---------------------- | ---- | ------------------------------------------------------------------------------------------------- | | `dnc_listed` | 403 | The number is on the organisation’s do-not-call list. | | `registry_listed` | 403 | The number is on the national do-not-call registry for this purpose. | | `registry_unavailable` | 403 | The national registry could not be checked, so the marketing call was blocked. | | `consent_missing` | 403 | There is no recorded consent for this marketing call. | | `compliance_blocked` | 403 | The outbound compliance policy blocked the call (details.verdict). | | `disclosure_required` | 403 | Marketing campaigns play the AI and recording disclosure; it can’t be turned off through the API. | ### Idempotency: `idempotency_error` | Code | HTTP | Meaning | | ------------------------- | ---- | ----------------------------------------------------------------------------------- | | `idempotency_key_invalid` | 400 | The Idempotency-Key header must be 1–255 printable ASCII characters. | | `idempotency_mismatch` | 409 | The Idempotency-Key was already used with different parameters or another endpoint. | | `request_in_progress` | 409 | A request with this Idempotency-Key is still being processed; retry shortly. | ### API error: `api_error` | Code | HTTP | Meaning | | --------------------- | ---- | ------------------------------------------------------------------------------ | | `internal_error` | 500 | Something went wrong on MoreVoice’s side. Retry with the same Idempotency-Key. | | `not_implemented` | 501 | This endpoint is not available yet. | | `service_unavailable` | 503 | A dependency is temporarily unavailable; retry later. | # Flows: guide a call step by step > A flow drives a call through steps instead of instructions alone: an AI conversation, a keypad menu (IVR) or a script for human agents. Build it in the editor, publish it, and attach it to an assistant, a route or a call. An [assistant](/guides/assistants/) follows its instructions. A **flow** gives a call structure: it walks the call through steps, such as greet, ask for the customer’s ID, look the order up on your server, answer, offer a transfer, say goodbye, and branches on what happens at each one. Use a flow when the conversation has to follow your process every time, and the instructions alone when the call can wander. Flows are built in the visual editor, BuildFlows: on a canvas, from a template, or by describing the flow in words and letting the AI draw it. There are three kinds: | Kind | What it does | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | **Voice AI flow** | Guides an AI assistant through a phone call, step by step. The assistant still talks naturally inside each step. | | **IVR** | A keypad and speech menu for inbound lines (“press 1 for sales”), with opening hours, Shabbat and holidays. No AI model is needed. | | **Agent script** | A branching script the [copilot](/get-started/what-is-morevoice/#the-building-blocks) shows to human agents during their calls. | ## How a flow runs 1. **Before the call**, a *Pre-call API* node can fetch the caller’s details from your CRM, so the greeting can use them. 2. **The call starts at *Start*** and moves from node to node. Each node has transitions, and the first one that matches moves the call on: what the caller said (decided by the model), a key they pressed, a rule on the variables, or a time-out. 3. **Global nodes** can interrupt from anywhere: a *Knowledge answer* for a question off the script, a *Transfer call* when the caller asks for a person. *Return* goes back to where the call was. 4. **After the call**, the nodes of the *After the call* phase run on their own: extract details, classify the call, set its outcome, and send the results to your systems. ## Nodes ### Before the call | Node | Type | What it does | In flows for | | ---------------- | ------------- | ------------------------------------------------------------ | ------------- | | **Pre-call API** | `precall_api` | Fetch the caller’s details before saying hello (CRM lookup). | Voice AI, IVR | ### During the call | Node | Type | What it does | In flows for | | ------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------- | | **Start** | `start` | Where every call begins — the greeting. | Voice AI, Agent script, IVR | | **Say** | `say` | Say an exact line (instant, from cache) or a line the AI rephrases naturally. | Voice AI, IVR | | **Conversation** | `dialogue` | A free conversation step with a goal — the AI talks until a transition matches. | Voice AI | | **Ask & collect** | `question` | Ask for one detail (name, date, ID…), validate it and store it in a variable. | Voice AI, IVR | | **Decision** | `decision` | Branch by rules on variables — no AI, always the same result. | Voice AI, Agent script, IVR | | **Knowledge answer** | `kb_answer` | Answer the caller’s question from your knowledge base. | Voice AI | | **API / Webhook** | `api` | Call any system (CRM, calendar, your server) and use the answer in the call. | Voice AI, IVR | | **Set variable** | `set_var` | Store or calculate a value for later steps. | Voice AI, Agent script, IVR | | **SMS** | `sms` | Send a text message during the call (link, confirmation, address). | Voice AI, IVR | | **Email** | `email` | Send an email during the call. | Voice AI, IVR | | **Transfer call** | `transfer` | Hand the caller to your agents, another number, or another AI agent. | Voice AI, Agent script, IVR | | **Add participant** | `add_participant` | Dial someone into the call — the AI greets them and runs a three-way call. | Voice AI | | **Keypad menu** | `dtmf_menu` | “Press 1 for… 2 for…” — the caller may also just say the option. | Voice AI, IVR | | **Payment pause** | `pci_pause` | Stop recording and transcribing while the caller pays (card details never reach the AI); resume after. | Voice AI, IVR | | **Business hours** | `hours` | Open, closed or a holiday right now? Weekly hours, Shabbat and Israeli holidays, special dates. | IVR, Voice AI | | **Switch language** | `set_language` | Continue the call in another language and voice. | IVR, Voice AI | | **Voicemail** | `voicemail` | The caller leaves a message after the beep — recorded, transcribed and summarized. | IVR, Voice AI | | **Schedule callback** | `schedule_callback` | Book a time to call the customer back. | Voice AI, IVR | | **End call** | `end` | Say goodbye, save the outcome and hang up. | Voice AI, IVR | | **Return** | `return` | Go back to where the caller was before this side-topic. | Voice AI, Agent script, IVR | | **CRM / calendar action** | `integration` | Use a connected CRM or calendar during the call: look the caller up, add a note, create a task. | Voice AI, IVR | ### After the call | Node | Type | What it does | In flows for | | ------------------------- | ------------------ | ---------------------------------------------------------------------------- | ------------- | | **After the call** | `post_start` | Everything here runs automatically after the call ends. | Voice AI, IVR | | **Extract details** | `extract` | Let AI pull structured details out of the conversation. | Voice AI, IVR | | **Classify call** | `classify` | Let AI pick one category for the call and branch on it. | Voice AI, IVR | | **Set outcome** | `set_outcome` | Save the call’s result (disposition) for reports and campaigns. | Voice AI, IVR | | **API / Webhook** | `post_api` | Send the call’s results to any system after it ends. | Voice AI, IVR | | **CRM / calendar action** | `post_integration` | After the call, update a connected CRM: add a note, create a follow-up task. | Voice AI, IVR | | **SMS** | `post_sms` | Text the customer after the call (summary, link, confirmation). | Voice AI, IVR | | **Email** | `post_email` | Email the summary to your team or the customer. | Voice AI, IVR | ### Agent scripts | Node | Type | What it does | In flows for | | ------------------- | ----------- | ----------------------------------------------------------------------------- | ------------ | | **Call step** | `step` | One step of the agent’s script: what to say, what to ask, what not to miss. | Agent script | | **Customer answer** | `branch` | Branch the script by what the customer answers. | Agent script | | **Objection** | `objection` | When the customer objects anywhere in the call — how to answer, then go back. | Agent script | | **Call outcome** | `outcome` | How the call ended (sale, follow-up, not interested…) and what to wrap up. | Agent script | ## Variables Text in a flow can use `{{variables}}`: `שלום {{customer_name}}`, or `{{call.from}}` in the body of an API request. * **The call’s variables**: what you pass as `variables` when you [create a call](/guides/outbound-calls/#variables), and a campaign contact’s columns. * **System variables**: `{{call.id}}`, `{{call.from}}`, `{{call.to}}`, `{{call.direction}}`; `{{now.date}}`, `{{now.time}}` and `{{now.weekday}}` in Israel time; `{{contact.…}}` and `{{campaign.…}}` on campaign calls. * **Variables the flow sets**: *Ask & collect* stores the answer it validated, *Set variable* stores or calculates a value, and an *API / Webhook* node maps fields of your server’s answer to variables. * **Secrets**: `{{secret.NAME}}` puts a stored secret (an API token, say) into an API request. Its value never appears in the editor, the logs or the API. ## Publish and versions The editor saves a **draft** as you work. **Publishing** checks the flow first: errors (a step with no way out, an API node without a URL) block it, warnings don’t. Each publish creates a numbered **version**, and only the published version runs on calls, so you can keep editing the draft safely. Any earlier version can be restored into the draft and published again. Every publish sends the [`flow.published`](/webhooks/events/#flow.published) event. A flow an assistant or a copilot profile uses can’t be deleted: detach it first. ## Attach a flow A published voice flow drives every call of the assistant it is attached to (`flow_id` on the assistant), or one outbound call (`flow_id` when you create the call, instead of `assistant_id`): * cURL ```sh # Let a published voice flow drive the assistant's calls, step by step. null detaches it. curl -X PATCH https://api.morevoice.ai/v1/assistants/$ASSISTANT_ID \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "flow_id": "'"$FLOW_ID"'" }' # Or run the flow for one outbound call only: it runs on its linked assistant. curl https://api.morevoice.ai/v1/calls \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "to": "+972500000001", "flow_id": "'"$FLOW_ID"'", "purpose": "service", "variables": { "customer_name": "דנה" } }' ``` * Node.js attach-flow\.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const flowId = process.env.FLOW_ID!; // Let a published voice flow drive the assistant's calls, step by step. null detaches it. await mv.assistants.update(process.env.ASSISTANT_ID!, { flow_id: flowId }); // Or run the flow for one outbound call only: it runs on its linked assistant. const call = await mv.calls.create({ to: "+972500000001", flow_id: flowId, purpose: "service", variables: { customer_name: "דנה" }, }); console.log(call.id); ``` * Python attach_flow\.py ```python import os import uuid import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} flow_id = os.environ["FLOW_ID"] # Let a published voice flow drive the assistant's calls, step by step. None detaches it. response = requests.patch(f"{API}/assistants/{os.environ['ASSISTANT_ID']}", headers=HEADERS, json={"flow_id": flow_id}, timeout=30) response.raise_for_status() # Or run the flow for one outbound call only: it runs on its linked assistant. response = requests.post( f"{API}/calls", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json={"to": "+972500000001", "flow_id": flow_id, "purpose": "service", "variables": {"customer_name": "דנה"}}, timeout=30, ) response.raise_for_status() print(response.json()["id"]) ``` An IVR or voice flow can also answer a number directly: give an [inbound route](/guides/inbound-calls/#targets) a `flow` target. ## Follow a call through its flow `GET /v1/calls/{id}/flow_path` shows the nodes a call went through: when it entered each one, how the step was chosen (`llm`, `dtmf`, `condition`, `timeout`), how many turns the caller spent in it, and how the flow ended. While the call runs, `live` is `true` and more steps follow. * cURL ```sh # The nodes a call went through in its flow: how each step was chosen, when, and how the flow ended. curl https://api.morevoice.ai/v1/calls/$CALL_ID/flow_path \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js flow-path.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // The nodes a call went through in its flow: how each step was chosen, when, and how the flow ended. const path = await mv.calls.retrieveFlowPath(process.env.CALL_ID!); for (const step of path.steps) { console.log(`${(step.at_ms / 1000).toFixed(1)}s`, step.node_id, step.via ?? "start"); } console.log(`flow ${path.flow_id} v${path.flow_version} ended at ${path.end}${path.live ? " (still running)" : ""}`); ``` * Python flow_path.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # The nodes a call went through in its flow: how each step was chosen, when, and how the flow ended. response = requests.get(f"{API}/calls/{os.environ['CALL_ID']}/flow_path", headers=HEADERS, timeout=30) response.raise_for_status() path = response.json() for step in path["steps"]: print(f"{step['at_ms'] / 1000:.1f}s", step["node_id"], step["via"] or "start") running = " (still running)" if path["live"] else "" print(f"flow {path['flow_id']} v{path['flow_version']} ended at {path['end']}{running}") ``` When a node fails during a call (your API timed out, an SMS couldn’t be sent), MoreVoice sends the [`flow.node_failed`](/webhooks/events/#flow.node_failed) event, and the call follows the node’s error transition. Tip Test a flow before you publish it: the editor’s simulator runs it as a chat, or calls you on the phone, and shows the path it took. See [simulation and test calls](https://help.morevoice.ai/en/flows/simulate-and-test/) in the help centre. ## The flows API `/v1/flows` builds and publishes flows from code, so a flow can live in your repository and ship with your release: | Request | What it does | | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `POST /v1/flows` | Creates a flow (`voice`, `ivr` or `agent_script`) with an empty draft, a template’s, or the `graph` you send. | | `GET` / `PUT /v1/flows/{id}/draft` | Reads and replaces the editable draft. `base_rev` is the draft’s `rev` you read: a draft changed since answers `409` and nothing is written. | | `POST /v1/flows/validate` | Checks a graph without saving it: the errors that would block publishing, and warnings. | | `POST /v1/flows/{id}/publish` | Publishes the draft as a new version, with a `note`; calls use it at once. A draft with errors answers `409 flow_invalid`, with what to fix. | | `GET /v1/flows/{id}/versions`, `…/restore` | Lists the published versions, and copies one back into the draft. | | `POST /v1/flows/{id}/duplicate`, `GET …/usage` | Copies a flow, and shows what uses it (assistants and copilot profiles): a flow in use can’t be deleted. | * cURL ```sh # Change the greeting of a flow's draft, then publish it. base_rev is the draft revision you read: # if someone changed the draft meanwhile, the write answers 409 and nothing is saved. draft=$(curl -s https://api.morevoice.ai/v1/flows/$FLOW_ID/draft -H "Authorization: Bearer $MOREVOICE_API_KEY") echo "$draft" \ | jq '{ base_rev: .rev, graph: (.graph | (.nodes[] | select(.type == "start") | .start.greeting) |= "שלום, הגעתם לאקמה. במה אפשר לעזור?") }' \ | curl -X PUT https://api.morevoice.ai/v1/flows/$FLOW_ID/draft \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ --data-binary @- curl https://api.morevoice.ai/v1/flows/$FLOW_ID/publish \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "note": "New greeting" }' ``` * Node.js edit-and-publish.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const flowId = process.env.FLOW_ID!; // Change the greeting of a flow's draft, then publish it. base_rev is the draft revision you read: // if someone changed the draft meanwhile, the write answers 409 and nothing is saved. const draft = await mv.flows.retrieveDraft(flowId); for (const node of draft.graph.nodes) { if (node.type === "start" && node.start) node.start.greeting = "שלום, הגעתם לאקמה. במה אפשר לעזור?"; } await mv.flows.updateDraft(flowId, { base_rev: draft.rev, graph: draft.graph }); const flow = await mv.flows.publish(flowId, { note: "New greeting" }); console.log(`${flow.name}: version ${flow.published_version} is live`); ``` * Python edit_and_publish.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} flow_id = os.environ["FLOW_ID"] # Change the greeting of a flow's draft, then publish it. base_rev is the draft revision you read: # if someone changed the draft meanwhile, the write answers 409 and nothing is saved. response = requests.get(f"{API}/flows/{flow_id}/draft", headers=HEADERS, timeout=30) response.raise_for_status() draft = response.json() for node in draft["graph"]["nodes"]: if node["type"] == "start" and node.get("start"): node["start"]["greeting"] = "שלום, הגעתם לאקמה. במה אפשר לעזור?" response = requests.put( f"{API}/flows/{flow_id}/draft", headers=HEADERS, json={"base_rev": draft["rev"], "graph": draft["graph"]}, timeout=30, ) response.raise_for_status() response = requests.post(f"{API}/flows/{flow_id}/publish", headers=HEADERS, json={"note": "New greeting"}, timeout=30) response.raise_for_status() flow = response.json() print(f"{flow['name']}: version {flow['published_version']} is live") ``` The API’s nodes are a stable public subset of the editor’s: `start`, `say`, `question`, `ai_agent`, `kb_answer`, `api`, `post_api`, `integration`, `post_integration`, `set_variable`, `decision`, `dtmf_menu`, `hours`, `transfer` and `end`. Every other node (an SMS, a voicemail, an agent-script step, and any node the editor adds later) reads as `{ "type": "internal" }` and is written back exactly as it was, so a new node in the editor never breaks your code. Simulation and analytics over the API Running a flow as a test conversation (as the editor’s simulator does), flow analytics and `/v1/flow_templates` come next. # Human agents and queues > MoreVoice is also a contact centre: people answer calls in the browser, from queues, with the AI in front or behind. Invite agents, build queues, send calls to them, watch the floor live, act on agents' calls and work the callbacks inbox over the API. MoreVoice isn’t only AI. It is also a cloud contact centre: your people answer and place calls in the browser, in the agent workspace, and **queues** share incoming calls between them. The AI can answer first and hand over to a queue, or take the calls nobody could answer. Over the API you invite the people, build the queues, send calls to them, watch the floor live, act on agents’ calls, and work the callbacks people asked for. | Object | ID | What it is | | ------------ | -------- | -------------------------------------------------------------------------------------------------------------- | | User | `usr_…` | A member of your organisation who signs in to MoreVoice: an agent, a supervisor, an admin or the owner. | | Team | `team_…` | A group of users. Queues and reports can address a whole team. | | Queue | `q_…` | Callers waiting for a human agent, and the rules for who answers, how long callers wait and what happens then. | | Agent status | | A user’s presence right now: available, busy, in wrap-up, away or offline. | | Callback | `cb_…` | A request to call someone back, and its progress. | Users, teams and queues are configuration: test keys and live keys see and change the same ones (the objects’ `livemode` only echoes the key). Agents answer calls in the browser, in the Agent workspace; the [help centre](https://help.morevoice.ai/en/agent-workspace/) shows them how. ## Add your agents A user joins your organisation by accepting an invitation. Invite them with the role and team they should have: * cURL ```sh # Invite an agent into the Support team. The invitation is emailed; it is valid for 7 days. curl https://api.morevoice.ai/v1/invitations \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "yossi@example.com", "role": "agent", "team_id": "'"$TEAM_ID"'" }' ``` * Node.js invite-agent.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Invite an agent into the Support team. The invitation is emailed; it is valid for 7 days. const invitation = await mv.invitations.create({ email: "yossi@example.com", role: "agent", team_id: process.env.TEAM_ID! }); console.log(invitation.id, invitation.status, invitation.expires, invitation.emailed); ``` * Python invite_agent.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Invite an agent into the Support team. The invitation is emailed; it is valid for 7 days. response = requests.post( f"{API}/invitations", headers=HEADERS, json={"email": "yossi@example.com", "role": "agent", "team_id": os.environ["TEAM_ID"]}, timeout=30, ) response.raise_for_status() invitation = response.json() print(invitation["id"], invitation["status"], invitation["expires"], invitation["emailed"]) ``` - The invitation is emailed; the API never returns the link. It is valid for 7 days, and `POST /v1/invitations/{id}/resend` emails a new one. - A seat is reserved for each open invitation: `402 plan_limit` when your plan has none free. - API keys invite and manage **agents** and **supervisors**. Owners and admins are made by an owner, in the dashboard. - Someone who already has a MoreVoice account (in another organisation) joins with it: `existing_user` is `true`. Then manage people with `/v1/users` and `/v1/teams`: `PATCH /v1/users/{id}` changes a user’s `role`, `team_id` or `extension`; `DELETE /v1/users/{id}` deactivates them (they are signed out, their seat is freed and their open callbacks go back to their queue), and `POST /v1/users/{id}/reactivate` brings them back. `POST /v1/teams` creates a team; deleting one leaves its members in no team. ## Build a queue Only `name` is required; everything else takes the defaults shown on the queue object. This one shares calls between a team’s agents, tells callers their place in line, offers a callback, and hands the call to the AI when nobody can take it: * cURL ```sh # A support queue: the team's agents answer, callers hear their place in line, can press 1 for a callback, # and after 3 minutes (or when no agent is signed in) the AI assistant takes the call. curl https://api.morevoice.ai/v1/queues \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Support", "member_team_ids": ["'"$TEAM_ID"'"], "strategy": "longest_idle", "ring_timeout_seconds": 20, "max_wait_seconds": 180, "wrap_up_seconds": 15, "overflow": { "when_no_agents": true, "action": "assistant", "assistant_id": "'"$ASSISTANT_ID"'" }, "hold": { "audio": "music", "announce_position": true, "position_interval_seconds": 45 }, "wait_keys": { "callback": "1" }, "callbacks": { "route": "agent_queue", "priority": 6 } }' ``` * Node.js create-queue.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // A support queue: the team's agents answer, callers hear their place in line, can press 1 for a callback, // and after 3 minutes (or when no agent is signed in) the AI assistant takes the call. const queue = await mv.queues.create({ name: "Support", member_team_ids: [process.env.TEAM_ID!], strategy: "longest_idle", ring_timeout_seconds: 20, max_wait_seconds: 180, wrap_up_seconds: 15, overflow: { when_no_agents: true, action: "assistant", assistant_id: process.env.ASSISTANT_ID! }, hold: { audio: "music", announce_position: true, position_interval_seconds: 45 }, wait_keys: { callback: "1" }, callbacks: { route: "agent_queue", priority: 6 }, }); console.log(queue.id, queue.name); ``` * Python create_queue.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # A support queue: the team's agents answer, callers hear their place in line, can press 1 for a callback, # and after 3 minutes (or when no agent is signed in) the AI assistant takes the call. response = requests.post( f"{API}/queues", headers=HEADERS, json={ "name": "Support", "member_team_ids": [os.environ["TEAM_ID"]], "strategy": "longest_idle", "ring_timeout_seconds": 20, "max_wait_seconds": 180, "wrap_up_seconds": 15, "overflow": {"when_no_agents": True, "action": "assistant", "assistant_id": os.environ["ASSISTANT_ID"]}, "hold": {"audio": "music", "announce_position": True, "position_interval_seconds": 45}, "wait_keys": {"callback": "1"}, "callbacks": {"route": "agent_queue", "priority": 6}, }, timeout=30, ) response.raise_for_status() queue = response.json() print(queue["id"], queue["name"]) ``` | Field | What it does | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `member_user_ids`, `member_team_ids` | Who answers: users, whole teams, or both. | | `strategy` | `longest_idle` rings the agent who has waited longest since their last call; `round_robin` takes turns. | | `ring_timeout_seconds` | How long one agent’s phone rings before the next agent is tried (5–120). | | `max_wait_seconds` | How long a caller waits before the overflow action (0: no limit; up to an hour). | | `wrap_up_seconds` | After-call time before an agent gets the next call (0–600). | | `away_on_missed` | An agent who misses a call is set to away, so callers aren’t offered to an empty desk. | | `overflow` | What happens after `max_wait_seconds`, or at once when `when_no_agents` and nobody is signed in: `assistant` (the AI answers), `voicemail`, `busy`, `agent`, `queue` or `number`, with the matching `assistant_id`, `user_id`, `queue_id` or `number`. | | `hold` | What callers hear: `music`, `ringback`, `silence`, or `asset` (a recording you uploaded, `audio_asset_id`), plus an `announcement` and their place in line every `position_interval_seconds`. | | `wait_keys` | Keys a waiting caller presses to leave a `voicemail` or ask for a `callback` (empty: off). | | `callbacks` | Who returns the callbacks asked for in this queue: its agents (`agent_queue`), one `agent`, or an `assistant`, and their `priority`. | | `copilot_profile_id` | The [copilot](/guides/copilot/) profile agents get on this queue’s calls. | `PATCH /v1/queues/{id}` changes a queue: nested objects (`overflow`, `hold`, `callbacks`…) merge with the stored values, and lists replace them. The change applies to the next call at once. Deleting a queue sends its waiting callers to overflow, and inbound routes that targeted it stop doing so. ## Send calls to a queue * **Inbound.** Give an [inbound route](/guides/inbound-calls/#targets) a `queue` target: `{ "type": "queue", "queue_id": "q_…" }`. * **From the AI.** An assistant or a flow hands over with its transfer step, or you transfer a live AI call yourself: `POST /v1/calls/{id}/transfer` with `{ "to": { "queue_id": "q_…" }, "note": "…" }`. The agent sees your `note` next to the call. See [outbound calls](/guides/outbound-calls/#act-on-a-live-call). * **From an agent.** An agent’s call can be transferred to another queue, cold or warm: see [below](#control-a-live-call). Your endpoints follow every queued call: [`queue.call_entered`](/webhooks/events/#queue.call_entered) (with its `position`), then [`queue.call_answered`](/webhooks/events/#queue.call_answered) (`agent_id`, `wait_ms`), [`queue.call_abandoned`](/webhooks/events/#queue.call_abandoned) when the caller hangs up first, or [`queue.overflowed`](/webhooks/events/#queue.overflowed). The call object keeps `queue_id`, and `agent_id` once an agent took it. ## Watch the floor `GET /v1/queues/{id}/live` is the queue right now: who is waiting and for how long, the members who are signed in by status, and today’s numbers, including the service level (the share of calls answered within 20 seconds): * cURL ```sh # The queue right now: who waits, which agents can take them, and today's numbers. curl https://api.morevoice.ai/v1/queues/$QUEUE_ID/live \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js queue-live.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // The queue right now: who waits, which agents can take them, and today's numbers. const live = await mv.queues.retrieveLive(process.env.QUEUE_ID!); console.log(`${live.waiting} waiting, longest ${live.longest_wait_seconds ?? 0} s, ${live.agents.available} agents available`); for (const caller of live.callers) console.log(caller.position, caller.from, `${caller.waited_seconds} s`, caller.state); if (live.today) console.log(`today: ${live.today.answered}/${live.today.calls} answered, service level ${live.today.service_level}`); ``` * Python queue_live.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # The queue right now: who waits, which agents can take them, and today's numbers. response = requests.get(f"{API}/queues/{os.environ['QUEUE_ID']}/live", headers=HEADERS, timeout=30) response.raise_for_status() live = response.json() print(f"{live['waiting']} waiting, longest {live['longest_wait_seconds'] or 0} s, {live['agents']['available']} agents available") for caller in live["callers"]: print(caller["position"], caller["from"], f"{caller['waited_seconds']} s", caller["state"]) today = live["today"] if today: print(f"today: {today['answered']}/{today['calls']} answered, service level {today['service_level']}") ``` `GET /v1/agents/{id}/status` is one agent’s presence, the call they are on, and the queues they answer: * cURL ```sh # Your agents, then one agent's presence: available, busy on a call, in wrap-up, away or offline. curl "https://api.morevoice.ai/v1/users?role=agent&status=active" \ -H "Authorization: Bearer $MOREVOICE_API_KEY" curl https://api.morevoice.ai/v1/agents/$USER_ID/status \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js agent-status.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Your agents and their presence: available, busy on a call, in wrap-up, away or offline. for await (const user of mv.users.list({ role: "agent", status: "active" })) { const presence = await mv.agents.retrieveStatus(user.id); console.log(user.name, user.extension, presence.status, presence.call?.call_id ?? ""); } ``` * Python agent_status.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Your agents and their presence: available, busy on a call, in wrap-up, away or offline. response = requests.get(f"{API}/users", headers=HEADERS, params={"role": "agent", "status": "active"}, timeout=30) response.raise_for_status() for user in response.json()["data"]: presence = requests.get(f"{API}/agents/{user['id']}/status", headers=HEADERS, timeout=30) presence.raise_for_status() status = presence.json() on_call = status["call"]["call_id"] if status["call"] else "" print(user["name"], user["extension"], status["status"], on_call) ``` | `status` | Meaning | | ----------- | ----------------------------------------------------------------------------- | | `available` | Signed in and ready: queue calls ring them. Chosen by the agent. | | `busy` | On a call. | | `wrap_up` | Finishing the last call (`wrap_up_until`); no new calls until then. | | `away` | Signed in, not taking calls: chosen by the agent, or set after a missed call. | | `offline` | Not signed in. | The agents set their own status in the workspace; the API reads it. [`agent.status_changed`](/webhooks/events/#agent.status_changed) and [`agent.wrapup_completed`](/webhooks/events/#agent.wrapup_completed) push the changes, so a wallboard or a workforce tool doesn’t have to poll. ## Control a live call While an agent is on a call, your code can do what the agent’s call controls do: from a CRM button, a supervisor tool or an automation. Each action returns or changes the call’s **control state**: who is on the call, whether the customer is on hold, and any consultation in progress. | Action | Request | On | | ------------------------------------------------------------------------------ | --------------------------------------------------------------- | ------------------------------------------------------ | | Put the customer on hold, or take them off | `POST /v1/calls/{id}/hold` with `on` | Agents’ calls | | Send DTMF tones (to navigate another company’s IVR) | `POST /v1/calls/{id}/dtmf` with `digits` (`,` pauses) | Agents’ calls | | Transfer to a queue, a number, an assistant or another agent, `cold` or `warm` | `POST /v1/calls/{id}/transfer` | Agents’ calls; AI and IVR calls to a queue or a number | | Finish a warm transfer: `complete`, `merge` (three-way), `swap` or `cancel` | `POST /v1/calls/{id}/consult/{action}` | Agents’ calls | | Ring someone into the call (up to 8 people), or add an assistant | `POST /v1/calls/{id}/participants` | Agents’ calls | | Mute, hold or promote a participant; remove one | `PATCH` / `DELETE /v1/calls/{id}/participants/{participant_id}` | Agents’ calls | | Read the control state | `GET /v1/calls/{id}/control` | Every call | | End the call for everyone | `POST /v1/calls/{id}/hangup` | Every call | A **warm transfer** puts the customer on hold while the agent talks to the target, then hands the customer over: * cURL ```sh # A warm transfer on an agent's call: the agent talks to a colleague first while the customer holds… curl https://api.morevoice.ai/v1/calls/$CALL_ID/transfer \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": { "agent_id": "'"$COLLEAGUE_ID"'" }, "mode": "warm", "note": "Billing question on invoice 1042" }' # …then hands the customer over (or "merge" for a three-way call, "cancel" to go back to the customer). curl -X POST https://api.morevoice.ai/v1/calls/$CALL_ID/consult/complete \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js warm-transfer.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const callId = process.env.CALL_ID!; // A warm transfer on an agent's call: the agent talks to a colleague first while the customer holds… const control = await mv.calls.transfer(callId, { to: { agent_id: process.env.COLLEAGUE_ID! }, mode: "warm", note: "Billing question on invoice 1042", }); console.log(control.phase, control.consult?.state); // …then hands the customer over (or "merge" for a three-way call, "cancel" to go back to the customer). await mv.calls.consult(callId, "complete"); ``` * Python warm_transfer.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} call_id = os.environ["CALL_ID"] # A warm transfer on an agent's call: the agent talks to a colleague first while the customer holds… response = requests.post( f"{API}/calls/{call_id}/transfer", headers=HEADERS, json={"to": {"agent_id": os.environ["COLLEAGUE_ID"]}, "mode": "warm", "note": "Billing question on invoice 1042"}, timeout=30, ) response.raise_for_status() control = response.json() print(control["phase"], control["consult"]["state"] if control["consult"] else None) # …then hands the customer over (or "merge" for a three-way call, "cancel" to go back to the customer). response = requests.post(f"{API}/calls/{call_id}/consult/complete", headers=HEADERS, timeout=30) response.raise_for_status() ``` **Adding a participant** rings them in and starts a conference; the new participant is `ringing` until they answer, so follow them in the control state: * cURL ```sh # Ring an expert's phone into an agent's call, then see who is on the line. curl https://api.morevoice.ai/v1/calls/$CALL_ID/participants \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": { "number": "+97235551234" }, "announce": "Joining you to a customer call about a claim" }' curl https://api.morevoice.ai/v1/calls/$CALL_ID/control \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js add-participant.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const callId = process.env.CALL_ID!; // Ring an expert's phone into an agent's call, then see who is on the line. const added = await mv.calls.addParticipant(callId, { to: { number: "+97235551234" }, announce: "Joining you to a customer call about a claim", }); console.log(added.participant_id, added.state); // ringing const control = await mv.calls.retrieveControl(callId); for (const p of control.participants) console.log(p.participant_id, p.kind, p.name, p.state, p.muted ? "muted" : ""); ``` * Python add_participant.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} call_id = os.environ["CALL_ID"] # Ring an expert's phone into an agent's call, then see who is on the line. response = requests.post( f"{API}/calls/{call_id}/participants", headers=HEADERS, json={"to": {"number": "+97235551234"}, "announce": "Joining you to a customer call about a claim"}, timeout=30, ) response.raise_for_status() added = response.json() print(added["participant_id"], added["state"]) # ringing response = requests.get(f"{API}/calls/{call_id}/control", headers=HEADERS, timeout=30) response.raise_for_status() for p in response.json()["participants"]: print(p["participant_id"], p["kind"], p["name"], p["state"], "muted" if p["muted"] else "") ``` An action that doesn’t fit the call answers `409`: `unsupported_for_call_mode` (hold on an AI call, say; `details.call_type` names the kind of call), `call_not_active` once the call has ended, or `transfer_failed` when the target couldn’t take it (the call continues). Calls to numbers you add pass the [compliance](/guides/compliance/) gate like any outbound call. ## Callbacks Callers who press the callback key while they wait, voicemails left in a queue (with `voicemail_to_callback`), your website’s callback form, the AI and your own code all fill one **callbacks inbox**. A callback routed to a queue is offered to its next free agent, who calls back from the workspace; the inbox tracks attempts, notes and an SLA target in business hours. * cURL ```sh # Callbacks waiting for the Support queue's agents, then close one that was resolved another way. curl "https://api.morevoice.ai/v1/callbacks?status=pending&queue_id=$QUEUE_ID" \ -H "Authorization: Bearer $MOREVOICE_API_KEY" curl https://api.morevoice.ai/v1/callbacks/$CALLBACK_ID/complete \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "note": "Answered by email" }' ``` * Node.js callbacks.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Callbacks waiting for the Support queue's agents, then close one that was resolved another way. for await (const cb of mv.callbacks.list({ status: "pending", queue_id: process.env.QUEUE_ID! })) { console.log(cb.id, cb.phone, cb.name, `due ${cb.due_at}`, `priority ${cb.priority}`, cb.notes); } const done = await mv.callbacks.complete(process.env.CALLBACK_ID!, { note: "Answered by email" }); console.log(done.status); // completed ``` * Python callbacks.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Callbacks waiting for the Support queue's agents, then close one that was resolved another way. response = requests.get( f"{API}/callbacks", headers=HEADERS, params={"status": "pending", "queue_id": os.environ["QUEUE_ID"]}, timeout=30, ) response.raise_for_status() for cb in response.json()["data"]: print(cb["id"], cb["phone"], cb["name"], f"due {cb['due_at']}", f"priority {cb['priority']}", cb["notes"]) response = requests.post( f"{API}/callbacks/{os.environ['CALLBACK_ID']}/complete", headers=HEADERS, json={"note": "Answered by email"}, timeout=30, ) response.raise_for_status() print(response.json()["status"]) # completed ``` - **Create** one with `POST /v1/callbacks`: `route` is `agent_queue` (with `queue_id`), `agent` (with `agent_id`) or `assistant` (the AI calls back; see [outbound calls](/guides/outbound-calls/#call-back-later)). - **Work it**: `PATCH` reschedules or reassigns, `POST …/notes` adds a note, `POST …/complete` closes it when it was resolved another way, `POST …/cancel` drops it. - **Measure** with `GET /v1/callbacks/stats`: totals, how many were returned within their SLA, the average time to return, by source and by agent. - **Events**: [`callback.scheduled`](/webhooks/events/#callback.scheduled), then `callback.completed`, `callback.failed` or `callback.cancelled`. Tip Run your contact centre’s numbers from the API too: scheduled reports (`/v1/reports`, in the [API reference](/api/)) email the dashboard’s figures on a cadence, and [QA](/guides/qa/) scores every agent’s calls. # Inbound routing: decide who answers each call > Send each inbound call to an AI assistant, an IVR or AI flow, a queue of people, one person or a conference room, by the number that was dialled and the number that is calling. Once your numbers reach MoreVoice over a SIP line (see [phone numbers and SIP](/guides/phone-numbers-and-sip/)), **inbound routes** decide who answers each call: an AI assistant, an IVR or AI flow, a queue of people, one person or a conference room. A route matches calls by the number that was dialled and the number that is calling, so one line can serve sales, support and an after-hours assistant at once. ## Inbound routes A route (a “rule” in the app) has: | Field | What it does | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `target` | Who answers: `{ "type": "assistant", "assistant_id": … }`, or `flow`, `queue`, `user` or `conference_room` with its `_id`. | | `fallback_assistant_id` | The assistant that answers when the target can’t take the call (a queue that is switched off, a person who was deactivated). Not with an `assistant` target. | | `match_did` | The dialled number: an exact number, a prefix ending in `*`, or empty for any number. | | `match_caller` | The caller’s number, with the same three forms. | | `connection_id` | Only calls on this [connection](/guides/phone-numbers-and-sip/#connections); `null` for any connection. | | `priority` | The order routes are checked in: lower first. `100` by default. | | `enabled` | `false` keeps the route but skips it. | | `name` | Your name for it. | ### Patterns `match_did` and `match_caller` take the same three forms. Spaces, dashes and brackets are ignored, so `03-555 0100` and `035550100` are the same number. | Pattern | Matches | Example | | -------------------- | -------------------------------- | -------------------------------------- | | Exact number | Only that number | `+97235550100` | | Prefix ending in `*` | Every number that starts with it | `+9723*` matches every Tel Aviv number | | Empty | Any number | | The number is compared as the carrier sends it. If your carrier sends international format, write the patterns that way too (`+9723*`); if it sends local format, write `03*`. ### Targets | `target.type` | What happens | | ----------------- | ----------------------------------------------------------------------------------------- | | `assistant` | An [AI assistant](/guides/assistants/) answers. | | `flow` | A published [flow](/guides/flows/) runs: an IVR menu, or an AI conversation step by step. | | `queue` | The call waits in a queue for a person, and can overflow to an AI assistant. | | `user` | One person’s direct line: their own forwarding rules apply. | | `conference_room` | The caller is asked for the room’s PIN, then joins the room. | ## Create routes The first route sends the sales line to a queue of people, with an assistant behind it; the second one answers every other Tel Aviv number with the assistant: * cURL ```sh # Calls to the sales line wait for a person in the sales queue; when the queue can't take them, an assistant answers. curl https://api.morevoice.ai/v1/inbound_routes \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Sales line", "match_did": "+97231234567", "priority": 10, "target": { "type": "queue", "queue_id": "'"$SALES_QUEUE_ID"'" }, "fallback_assistant_id": "'"$ASSISTANT_ID"'" }' # Every other number that starts with +9723 goes straight to the assistant. curl https://api.morevoice.ai/v1/inbound_routes \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Tel Aviv numbers", "match_did": "+9723*", "priority": 100, "target": { "type": "assistant", "assistant_id": "'"$ASSISTANT_ID"'" } }' ``` * Node.js create-route.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Calls to the sales line wait for a person in the sales queue; when the queue can't take them, an assistant answers. const sales = await mv.inboundRoutes.create({ name: "Sales line", match_did: "+97231234567", priority: 10, target: { type: "queue", queue_id: process.env.SALES_QUEUE_ID! }, fallback_assistant_id: process.env.ASSISTANT_ID!, }); // Every other number that starts with +9723 goes straight to the assistant. const rest = await mv.inboundRoutes.create({ name: "Tel Aviv numbers", match_did: "+9723*", priority: 100, target: { type: "assistant", assistant_id: process.env.ASSISTANT_ID! }, }); console.log(sales.id, rest.id); ``` * Python create_route.py ```python import os import uuid import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} def create_route(route: dict) -> dict: response = requests.post(f"{API}/inbound_routes", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json=route, timeout=30) response.raise_for_status() return response.json() # Calls to the sales line wait for a person in the sales queue; when the queue can't take them, an assistant answers. sales = create_route( { "name": "Sales line", "match_did": "+97231234567", "priority": 10, "target": {"type": "queue", "queue_id": os.environ["SALES_QUEUE_ID"]}, "fallback_assistant_id": os.environ["ASSISTANT_ID"], } ) # Every other number that starts with +9723 goes straight to the assistant. rest = create_route( { "name": "Tel Aviv numbers", "match_did": "+9723*", "priority": 100, "target": {"type": "assistant", "assistant_id": os.environ["ASSISTANT_ID"]}, } ) print(sales["id"], rest["id"]) ``` A route takes effect with the next call: calls in progress are not affected, and the SIP connections are not restarted. An exact number that another organisation already routes on the same SIP provider is refused with `409` and the code `did_taken`. ## How a call is routed 1. **The routes are checked in order**: by `priority`, lowest first, and by age when two have the same priority. The first enabled route whose connection, `match_did` and `match_caller` all match the call is used. 2. **The route’s target answers.** When it can’t take the call (the flow has no published version, the queue is switched off, the person was deactivated), its `fallback_assistant_id` answers. When the route has neither, MoreVoice moves on to the next matching route. 3. **If no route matches**, the connection’s `inbound_assistant_id` answers (Default assistant for inbound calls in the app). If the connection has none, the most recently edited assistant answers. 4. **If there is still no one to answer**, the call is refused with `480`. Tip Give every queue, flow and person route a `fallback_assistant_id`. The line keeps answering when the team is offline or a flow is being edited. ## List and change routes `GET /v1/inbound_routes` lists your routes in the order they are checked: * cURL ```sh # Your routes in the order they are checked: priority, then age. The first enabled route that matches a call wins. curl "https://api.morevoice.ai/v1/inbound_routes?limit=100" \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js list-routes.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Your routes in the order they are checked: priority, then age. The first enabled route that matches a call wins. for await (const route of mv.inboundRoutes.list({ limit: 100 })) { const target = route.target ? route.target.type : "the connection's assistant"; console.log(route.priority, route.enabled ? "on " : "off", route.match_did || "any number", "→", target); } ``` * Python list_routes.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Your routes in the order they are checked: priority, then age. The first enabled route that matches a call wins. params = {"limit": 100} while True: response = requests.get(f"{API}/inbound_routes", headers=HEADERS, params=params, timeout=30) response.raise_for_status() page = response.json() for route in page["data"]: target = route["target"]["type"] if route["target"] else "the connection's assistant" print(route["priority"], "on " if route["enabled"] else "off", route["match_did"] or "any number", "→", target) if not page["has_more"]: break params["starting_after"] = page["next_cursor"] ``` `PATCH /v1/inbound_routes/{id}` changes the fields you send. A new `target` replaces the old one, and an `assistant` target drops `fallback_assistant_id`. To stop a route for a while, set `enabled: false`; `DELETE /v1/inbound_routes/{id}` removes it for good. * cURL ```sh # Point the sales line at a published IVR flow instead of the queue. The next call uses it. curl -X PATCH https://api.morevoice.ai/v1/inbound_routes/$ROUTE_ID \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "target": { "type": "flow", "flow_id": "'"$FLOW_ID"'" } }' # Or switch the route off without deleting it: calls fall through to the next matching route. curl -X PATCH https://api.morevoice.ai/v1/inbound_routes/$ROUTE_ID \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "enabled": false }' ``` * Node.js update-route.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const routeId = process.env.ROUTE_ID!; // Point the sales line at a published IVR flow instead of the queue. The next call uses it. await mv.inboundRoutes.update(routeId, { target: { type: "flow", flow_id: process.env.FLOW_ID! } }); // Or switch the route off without deleting it: calls fall through to the next matching route. const route = await mv.inboundRoutes.update(routeId, { enabled: false }); console.log(route.enabled); ``` * Python update_route.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} route_id = os.environ["ROUTE_ID"] # Point the sales line at a published IVR flow instead of the queue. The next call uses it. response = requests.patch(f"{API}/inbound_routes/{route_id}", headers=HEADERS, json={"target": {"type": "flow", "flow_id": os.environ["FLOW_ID"]}}, timeout=30) response.raise_for_status() # Or switch the route off without deleting it: calls fall through to the next matching route. response = requests.patch(f"{API}/inbound_routes/{route_id}", headers=HEADERS, json={"enabled": False}, timeout=30) response.raise_for_status() print(response.json()["enabled"]) ``` Routes are configuration: the same routes serve live and test mode. Choose the assistant per call, from your server A route will be able to ask your server which assistant answers, and with which variables, before MoreVoice picks up: a signed request with the caller’s number and the number dialled, answered within a short timeout, so a CRM can look up the caller and personalise the greeting. If your server doesn’t answer in time, the route’s own target answers. ## Set it up in the app 1. Open SettingsSIP / Phone. Under Inbound routing, add a Rule. 2. Pick the connection (or Any connection), enter the Dialled number (DID) and Caller number patterns, choose who answers in Answer with, and click Save rule. 3. Change the order with Move up and Move down: the order in the app is the API’s `priority`. 4. Call the number. The call appears under Live calls while it runs, and in the call log afterwards, with the route that answered it. # MCP: MoreVoice for AI agents > Two Model Context Protocol servers: the docs MCP server, which lets Claude, Cursor or any AI assistant search and read these docs while it writes your integration, and MoreVoice's own MCP server, which lets an AI agent build assistants, place calls and read results with your API key. The [Model Context Protocol](https://modelcontextprotocol.io) lets an AI assistant use tools that a server offers. MoreVoice has two MCP servers, for two jobs: | | The docs MCP server | The MoreVoice MCP server | | ------------ | -------------------------------------------------- | -------------------------------------------------------------------------------------- | | What it does | Searches and reads these docs and the help centre. | Acts on your organisation: assistants, calls, campaigns, the do-not-call list, events. | | Who uses it | You, while an AI assistant helps you write code. | An AI agent that runs MoreVoice for you. | | Address | `https://docs.morevoice.ai/mcp` | `/mcp` on the API | | Key | None: it reads public docs only. | Your API key, with its scopes. | ## The docs MCP server Connect `https://docs.morevoice.ai/mcp` to Claude, Cursor, VS Code or any MCP client, and your assistant looks things up in these docs instead of guessing. It needs no account or key. [Build with AI](/get-started/build-with-ai/#the-docs-mcp-server) has the setup for each client, the tools it offers, and the other ways to give an assistant the docs: `llms.txt`, a Markdown version of every page, and the OpenAPI description. ## The MoreVoice MCP server The MoreVoice MCP server turns the API into tools an AI agent can call: “create an assistant that books appointments in Hebrew”, “call these three test numbers and summarise how it went”, “which campaigns are blocked, and why?”. Each tool is one API operation, named after it, described from the [API reference](/api/), and run **through the API with the caller’s own key**, so everything that applies to an API client applies to the agent. The hosted endpoint is coming The tools below are ready; the endpoint that serves them over Streamable HTTP is switched on with the API beta’s MCP release. * **Scopes.** The agent sees only the tools its key’s scopes allow. Give it a [restricted key](/get-started/authentication/) with just what the job needs. * **Test mode.** With a test key, calls are simulated on the virtual carrier and nothing is billed: the right place for an agent to learn. * **Safety switch.** Tools that change data or reach people (placing calls, starting campaigns, deleting) are listed only when the organisation has the `mcp_risky_tools` setting turned on. Read-only tools are always available. * **Limits and logs.** Rate limits, idempotency and compliance checks apply, and every request appears in the API request log with the key that made it. The tools it will offer: one per API operation, named after its operation ID. A key sees only the tools its scopes allow. | Tool | What it does | Scopes | Kind | | --------------------------- | ----------------------------------- | --------------------------- | ---------------------------------------------------- | | `assistants_list` | List assistants | `assistants:read` | Reads | | `assistants_retrieve` | Retrieve an assistant | `assistants:read` | Reads | | `assistants_create` | Create an assistant | `assistants:write` | Changes data · needs `mcp_risky_tools` | | `assistants_update` | Update an assistant | `assistants:write` | Changes data · needs `mcp_risky_tools` | | `assistants_duplicate` | Duplicate an assistant | `assistants:write` | Changes data · needs `mcp_risky_tools` | | `assistants_delete` | Delete an assistant | `assistants:write` | Changes data (destructive) · needs `mcp_risky_tools` | | `calls_list` | List calls | `calls:read` | Reads | | `calls_retrieve` | Retrieve a call | `calls:read` | Reads | | `calls_retrieve_transcript` | Retrieve a call’s transcript | `calls:read` | Reads | | `calls_retrieve_summary` | Retrieve a call’s summary | `calls:read` | Reads | | `calls_create` | Create an outbound call | `calls:write` | Reaches people · needs `mcp_risky_tools` | | `calls_hangup` | Hang up a call | `calls:write` | Changes data (destructive) · needs `mcp_risky_tools` | | `campaigns_list` | List campaigns | `campaigns:read` | Reads | | `campaigns_retrieve` | Retrieve a campaign | `campaigns:read` | Reads | | `campaigns_stats` | Retrieve a campaign’s results | `campaigns:read` | Reads | | `campaigns_preflight` | Check whether a campaign can start | `campaigns:read` | Reads | | `campaigns_contacts_list` | List a campaign’s contacts | `contacts:read` | Reads | | `campaigns_create` | Create a campaign | `campaigns:write` | Changes data · needs `mcp_risky_tools` | | `campaigns_update` | Update a campaign | `campaigns:write` | Changes data · needs `mcp_risky_tools` | | `campaigns_contacts_create` | Add contacts to a campaign | `contacts:write` | Changes data · needs `mcp_risky_tools` | | `campaigns_start` | Start a campaign | `campaigns:write` | Reaches people · needs `mcp_risky_tools` | | `campaigns_pause` | Pause a campaign | `campaigns:write` | Changes data · needs `mcp_risky_tools` | | `campaigns_resume` | Resume a paused campaign | `campaigns:write` | Reaches people · needs `mcp_risky_tools` | | `campaigns_cancel` | Cancel a campaign | `campaigns:write` | Changes data (destructive) · needs `mcp_risky_tools` | | `dnc_check` | Check numbers before calling | `dnc:read`, `consents:read` | Reads | | `dnc_retrieve` | Retrieve a do-not-call entry | `dnc:read` | Reads | | `dnc_create` | Add numbers to the do-not-call list | `dnc:write` | Changes data · needs `mcp_risky_tools` | | `webhook_endpoints_list` | List webhook endpoints | `webhooks:read` | Reads | | `events_list` | List events | `events:read` | Reads | An agent that can place calls An AI agent with a live key and the risky tools turned on can call real people. Keep live keys away from agents you are still testing, prefer narrow scopes, and review what the agent proposes before it runs anything that dials. MoreVoice’s compliance checks still apply to every call it places. # Media streams: bring your own bot > Stream a call's audio to your own WebSocket server: fork a copy for analytics or recording, or connect your own voice bot in place of MoreVoice's AI while MoreVoice keeps the phone lines, queues, recording and compliance. The protocol is compatible with Twilio Media Streams. Media streams are part of the Growth, Business, Enterprise and Developer plans. A **media stream** sends a call’s audio, as it happens, to a WebSocket server of yours. It comes in two modes: * **`fork`**: your server gets a copy of the call’s audio (the caller, what the caller hears, or both) and listens: real-time analytics, your own transcription, a compliance recorder. The call goes on as before. * **`connect`**: your server gets the caller’s audio and talks: your bot answers instead of MoreVoice’s AI. You send audio back, and can transfer the call, hang up or tag it. With `connect`, MoreVoice still does everything around the conversation: the phone numbers and SIP trunks, IVR menus and routing, queues and human agents, recording, transcripts and webhooks, and the [§30A compliance](/guides/compliance/) of every outbound call. Your bot does only the talking. ## The protocol The protocol is **compatible with Twilio Media Streams**: the same JSON messages over one WebSocket, the same field names and the same μ-law audio. A bot written for Twilio works with a URL change, and the bot frameworks that read Twilio streams read these. MoreVoice adds three messages of its own (`transfer`, `hangup` and `metadata`) and linear PCM at 16 kHz, which most speech engines prefer. Every frame is a JSON text message with an `event` field. In `connect` mode your bot may send messages back; in `fork` mode MoreVoice ignores everything from your server except closing the connection. | `event` | Direction | What it means | Fields | | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `connected` | To your bot | The first frame, once: the protocol’s name and version. | `protocol`, `version` | | `start` | To your bot | The stream’s IDs (`accountSid` is your organisation, `callSid` the call, `streamSid` this stream), its tracks, the `customParameters` you set and the audio format. | `sequenceNumber`, `start.accountSid`, `start.streamSid`, `start.callSid`, `start.tracks`, `start.customParameters`, `start.mediaFormat.encoding`, `start.mediaFormat.sampleRate`, `start.mediaFormat.channels`, `start.mediaFormat.byteOrder`, `streamSid` | | `media` | To your bot | 20 ms of one track’s audio, base64, with its chunk number and its time in the stream (ms). | `sequenceNumber`, `media.track`, `media.chunk`, `media.timestamp`, `media.payload`, `streamSid` | | `dtmf` | To your bot | The caller pressed a key. | `sequenceNumber`, `dtmf.track`, `dtmf.digit`, `streamSid` | | `mark` | To your bot | Everything you sent before one of your marks has finished playing. | `sequenceNumber`, `mark.name`, `streamSid` | | `stop` | To your bot | The last frame, once: the stream is over. | `sequenceNumber`, `stop.accountSid`, `stop.callSid`, `streamSid` | | `media` | From your bot | Audio to play to the caller, base64, in the stream’s format. | `streamSid`, `media.payload` | | `mark` | From your bot | A named point in the audio you sent. It comes back as a `mark` once everything before it has played. | `streamSid`, `mark.name` | | `clear` | From your bot | Drop the audio still queued to play (the caller interrupted). Pending marks come back at once. | `streamSid` | | `transfer` | From your bot | Hand the call to a queue (`queue_id`), a phone number (`number`, `cold` or `warm`) or an assistant (`assistant_id`): exactly one. | `streamSid`, `transfer.queue_id`, `transfer.number`, `transfer.assistant_id`, `transfer.mode` | | `hangup` | From your bot | End the call, with an optional `reason`. | `streamSid`, `hangup.reason` | | `metadata` | From your bot | Merge key–value strings into the call’s `metadata`. | `streamSid`, `metadata` | ### Audio formats | Format | `mediaFormat.encoding` | Sample rate | Byte order | | ---------------------- | ---------------------- | ----------- | ------------- | | `mulaw_8000` (default) | `audio/x-mulaw` | 8,000 Hz | — | | `l16_16000` | `audio/l16` | 16,000 Hz | little-endian | | `l16_8000` | `audio/l16` | 8,000 Hz | little-endian | ### Limits * The `connected` frame names the protocol `Call`, version `1.0.0`. * A bot may ask for the WebSocket subprotocol `mv-media.v1`; plain connections are accepted too. * One `media` frame from your bot carries at most 98,304 base64 characters; send long audio as several frames. * Mark names are echoed back as they are, up to 256 characters. ### Differences from Twilio Port a Twilio bot by changing the URL, then check these differences: * **IDs** are MoreVoice’s: `accountSid` is your organisation (`org_…`), `callSid` the call (`call_…`, as the API and webhooks show it) and `streamSid` the stream. Treat them as opaque strings. * **Audio formats.** Besides Twilio’s μ-law at 8 kHz, a stream can carry linear 16-bit PCM. MoreVoice’s PCM is **little-endian**, what speech engines and bot frameworks read natively, and `mediaFormat.byteOrder` says so (RFC 3551 network order is big-endian): convert if your code assumes otherwise. * **Tracks** are `inbound` (the caller) and `outbound` (what the caller hears: the assistant, an agent, prompts). * **More messages.** `transfer`, `hangup` and `metadata` let a `connect` bot finish the job without a separate API call. Ignore any message your bot doesn’t know: MoreVoice may add more. ## Start a stream ### Stream a call’s audio to your WebSocket `POST /v1/calls/{id}/streams` · scope `calls:write` · [API reference](/api/operations/calls_create_stream/) Starts a media stream on a live call in `fork` mode: a one-way copy of the caller (`inbound`), what the caller hears (`outbound`), or both, sent to your `wss://` URL as Twilio Media Streams messages (`connected`, `start`, `media`, `stop`). The call goes on exactly as before: if your server is slow, frames older than 2 s are dropped (`frames_dropped`); if it disconnects, the stream reconnects (3 tries) and otherwise gives up (`failed`) without touching the call. The upgrade request is signed with your signing secret over its path and query (`webhook-id` is the stream’s ID). The stream stops when the call ends or with DELETE. At most 2 streams per call. | Field | Required | What it does | | ------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `custom_parameters` | | Up to 20 string key/values the receiver gets in `start.customParameters` (as Twilio’s stream parameters). | | `format` | | `mulaw_8000` (the default): G.711 μ-law at 8 kHz, Twilio’s; `l16_16000` / `l16_8000`: signed 16-bit PCM, little-endian. | | `mode` | | `fork` (the default): a one-way copy; your server’s messages are ignored. | | `tracks` | | `inbound`: the caller; `outbound`: what the caller hears (the assistant, an agent, prompts); `both` (the default). | | `url` | Yes | Your WebSocket URL (`wss://`). The upgrade is signed with your signing secret (`webhook-id`, `webhook-timestamp`, `webhook-signature` over the path and query). | * cURL ```sh curl -X POST https://api.morevoice.ai/v1/calls/call_7Hk2Lm9Qp/streams \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "custom_parameters": { "crm_id": "42" }, "format": "mulaw_8000", "tracks": "both", "url": "wss://media.example.com/streams" }' ``` * Node.js ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // MOREVOICE_API_KEY from the environment const mediaStream = await mv.calls.createStream("call_7Hk2Lm9Qp", { custom_parameters: { crm_id: "42", }, format: "mulaw_8000", tracks: "both", url: "wss://media.example.com/streams", }); console.log(mediaStream); ``` * Python ```python from morevoice import MoreVoice client = MoreVoice() # MOREVOICE_API_KEY from the environment media_stream = client.calls.create_stream("call_7Hk2Lm9Qp", { "custom_parameters": { "crm_id": "42", }, "format": "mulaw_8000", "tracks": "both", "url": "wss://media.example.com/streams", }) print(media_stream) ``` ### Stop a media stream `DELETE /v1/calls/{id}/streams/{stream_id}` · scope `calls:write` · [API reference](/api/operations/calls_delete_stream/) Stops the stream: what is queued is flushed, your server gets `stop`, and the socket closes. The call is not affected. Streams also stop on their own when the call ends. * cURL ```sh curl -X DELETE https://api.morevoice.ai/v1/calls/call_7Hk2Lm9Qp/streams/ms_4fG7hJ2kL9mN3pQ6rS8tUv \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` * Node.js ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // MOREVOICE_API_KEY from the environment const mediaStream = await mv.calls.deleteStream("call_7Hk2Lm9Qp", "ms_4fG7hJ2kL9mN3pQ6rS8tUv"); console.log(mediaStream); ``` * Python ```python from morevoice import MoreVoice client = MoreVoice() # MOREVOICE_API_KEY from the environment media_stream = client.calls.delete_stream("call_7Hk2Lm9Qp", "ms_4fG7hJ2kL9mN3pQ6rS8tUv") print(media_stream) ``` Your bot, your responsibility In `connect` mode your bot speaks to the caller in your name. Tell callers they are talking to an AI, honour opt-outs (send `hangup`, and add the number to the [do-not-call list](/guides/compliance/#honour-an-opt-out)), and follow the [Acceptable Use Policy](https://morevoice.ai/en/legal/aup/). MoreVoice still checks every outbound call before it is dialled. # Outbound calls > Place a call with POST /v1/calls: the caller ID, the assistant or flow, variables and metadata, the call purpose and its compliance checks, retrying safely, answering machines, how a call ends, and calling someone back later. `POST /v1/calls` calls one person with an assistant. MoreVoice checks the call against the compliance rules, dials it, and answers straight away with the call object; the conversation then runs on its own. To call a list of people within calling hours, with retries, use a [campaign](/guides/campaigns/) instead. * cURL ```sh # A reminder call. The Idempotency-Key is your own ID for this call: a retry with the same key # returns the first answer and never dials twice. curl https://api.morevoice.ai/v1/calls \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reminder-$APPOINTMENT_ID" \ -d '{ "to": "+972501234567", "from_number_id": "'"$FROM_NUMBER_ID"'", "assistant_id": "'"$ASSISTANT_ID"'", "purpose": "service", "variables": { "customer_name": "דנה", "appointment": "יום שלישי ב-10:30" }, "metadata": { "crm_contact_id": "0031x00000AbCdE", "appointment_id": "'"$APPOINTMENT_ID"'" }, "max_duration_s": 300 }' ``` * Node.js create-call.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const appointmentId = process.env.APPOINTMENT_ID!; // A reminder call. The idempotency key is your own ID for this call: a retry with the same key // returns the first answer and never dials twice. const call = await mv.calls.create( { to: "+972501234567", from_number_id: process.env.FROM_NUMBER_ID!, assistant_id: process.env.ASSISTANT_ID!, purpose: "service", variables: { customer_name: "דנה", appointment: "יום שלישי ב-10:30" }, metadata: { crm_contact_id: "0031x00000AbCdE", appointment_id: appointmentId }, max_duration_s: 300, }, { idempotencyKey: `reminder-${appointmentId}` }, ); console.log(call.id, call.status); // call_… queued ``` * Python create_call.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} appointment_id = os.environ["APPOINTMENT_ID"] # A reminder call. The Idempotency-Key is your own ID for this call: a retry with the same key # returns the first answer and never dials twice. response = requests.post( f"{API}/calls", headers={**HEADERS, "Idempotency-Key": f"reminder-{appointment_id}"}, json={ "to": "+972501234567", "from_number_id": os.environ["FROM_NUMBER_ID"], "assistant_id": os.environ["ASSISTANT_ID"], "purpose": "service", "variables": {"customer_name": "דנה", "appointment": "יום שלישי ב-10:30"}, "metadata": {"crm_contact_id": "0031x00000AbCdE", "appointment_id": appointment_id}, "max_duration_s": 300, }, timeout=30, ) response.raise_for_status() call = response.json() print(call["id"], call["status"]) # call_… queued ``` ## The request | Field | Required | What it does | | ---------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `to` | Yes | The number to call, in E.164 format: `+972501234567`. | | `purpose` | Yes | `service`, `marketing` or `survey`. It decides the [compliance checks](#purpose-and-compliance). | | `assistant_id` | One of the two | The assistant that talks. | | `flow_id` | One of the two | A published voice flow, which runs with the assistant linked to it. | | `from_number_id` | Live mode | The number to call from (`pn_…`, from `GET /v1/phone_numbers`): it sets the SIP connection and the caller ID. | | `connection_id`, `caller_id` | | Instead of `from_number_id`: a SIP connection (by default, the organisation’s default one) and a number it is allowed to present. | | `variables` | | Values for the `{{variables}}` of the instructions, first message and flow. | | `metadata` | | Your own key–value strings, kept on the call. | | `max_duration_s` | | End the call after this many seconds (10–14,400). By default, the assistant’s limit. | | `amd` | | Answering-machine detection for this call: see [answering machines](#answering-machines). By default, the assistant’s setting. | The answer is `201 Created` with the [call object](/guides/call-objects/), in status `queued`. `metadata`, `variables` and `purpose` are on it from the start. ## Variables Variables personalise one call without changing the assistant. Write `{{customer_name}}` in the assistant’s `first_message` or `system_prompt`, and pass `"variables": { "customer_name": "דנה" }` with each call. * Up to 50 per call. Names are letters, digits and `_` (up to 40 characters, not starting with a digit); values are strings of up to 1,000 characters. * The call keeps its `variables`, so you can see later what each call was told. ## Metadata `metadata` is for you: your CRM’s contact ID, the order the call is about, the campaign in your own system. MoreVoice never reads it: it keeps it on the call and sends it back in the call object and in the call’s webhook events. Up to 50 keys of up to 40 characters, values of up to 500 characters. Use `metadata` to find your record when an event arrives; use `variables` for what the assistant should say. ## Purpose and compliance Every outbound call states why it is made, and live calls pass the same compliance gate as every other outbound call before anything is dialled: | `purpose` | Checked before the call | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `service` | The organisation’s do-not-call list. | | `survey` | The organisation’s do-not-call list. | | `marketing` | The do-not-call list, a recorded consent to marketing calls (§30A), and the national do-not-call registry for Israeli numbers. A number the registry can’t confirm is blocked too. | A blocked call isn’t dialled. The request answers `403` with a `compliance_error` whose `code` names the rule (`dnc_listed`, `registry_listed`, `registry_unavailable`, `consent_missing` or `compliance_blocked`) and whose `details.verdict` lists every check. [Compliance](/guides/compliance/) explains each rule. Calling hours, Shabbat and holidays are kept by [campaigns](/guides/campaigns/), which wait for an allowed moment. A call you place with `POST /v1/calls` goes out when you place it, so place it within your calling hours, or [ask for a callback](#call-back-later) and let MoreVoice pick the moment. ### Check before you dial When you call from your own list, ask first instead of handling a `403` per call. `POST /v1/dnc/check` takes up to 100 numbers and a purpose, and answers for each one whether a call would be allowed now, and why not: * cURL ```sh # Would a marketing call to these numbers be allowed? Your do-not-call list, consent and the national registry. curl https://api.morevoice.ai/v1/dnc/check \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phones": ["+972501234567", "0527654321"], "purpose": "marketing" }' ``` * Node.js check-numbers.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Would a marketing call to these numbers be allowed? Your do-not-call list, consent and the national registry. const check = await mv.dnc.check({ phones: ["+972501234567", "0527654321"], purpose: "marketing" }); for (const r of check.results) { console.log(r.phone ?? r.input, r.callable ? "callable" : `blocked: ${r.reasons.join(", ")}`, `registry: ${r.registry.status}`); } ``` * Python check_numbers.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Would a marketing call to these numbers be allowed? Your do-not-call list, consent and the national registry. response = requests.post( f"{API}/dnc/check", headers=HEADERS, json={"phones": ["+972501234567", "0527654321"], "purpose": "marketing"}, timeout=30, ) response.raise_for_status() for r in response.json()["results"]: verdict = "callable" if r["callable"] else "blocked: " + ", ".join(r["reasons"]) print(r["phone"] or r["input"], verdict, "registry:", r["registry"]["status"]) ``` The check reads the same do-not-call list, consent records and national registry as the dialer, so a number marked `callable` is dialled unless something changes in between (the person opts out, a consent is revoked). [Compliance over the API](/guides/compliance/#over-the-api) has the details. ## Retrying safely: the Idempotency-Key `POST /v1/calls` requires an `Idempotency-Key` header, so a call is never placed twice: * Send the same key again, with the same body, and you get the first answer back, with an `Idempotent-Replayed: true` header. Nobody is dialled again. * Use a key that means something in your system, such as `reminder-`, so that two copies of the same job can’t both call. * Keys are kept for 24 hours, per organisation and mode, and are up to 255 printable characters. * The same key with a different body answers `409` with the code `idempotency_mismatch`. While the first request is still running, a retry answers `409` with `request_in_progress`: wait a moment and retry. * A `5xx`, `409` or `429` answer doesn’t use up the key, so retry with the same one. The SDK sends a key with every `POST` and reuses it when it retries; pass `idempotencyKey` to choose your own. Every other `POST` and `DELETE` of the API accepts `Idempotency-Key` too, but only `POST /v1/calls` requires it. ## When the call can’t start | HTTP | Code | Why | | ---- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | 400 | `parameter_missing`, `parameter_invalid` | A field is missing or wrong; `param` says which. An unknown assistant or flow is `parameter_invalid` on `assistant_id` or `flow_id`. | | 400 | `dial_rejected` | The connection or the caller ID can’t place this call. | | 402 | `balance_exhausted`, `spend_cap`, `trial_limit`, … | Billing doesn’t allow new calls; `details.reason` says why. | | 403 | `dnc_listed`, `consent_missing`, … | A compliance rule blocks the call. | | 429 | `concurrency_limit` | As many calls are in progress as your plan allows; `details` has `active` and `limit`. | | 429 | `rate_limited` | More than the new calls per second your plan allows. | | 429 | `capacity_exceeded` | No line is free right now. | A `429` carries `Retry-After`: wait, then send the same request with the same key. [Errors, rate limits and concurrency](/guides/errors-and-limits/) has the details. Many calls at once The concurrency limit counts every call in progress, not only yours from the API, and the calls-per-second limit counts new calls. A job that calls hundreds of people is a [campaign](/guides/campaigns/): it paces itself under both limits, keeps calling hours, retries busy lines and checks compliance per contact, so you don’t rebuild a dialer around `429`s. ## Answering machines With answering-machine detection on, MoreVoice listens to the first seconds after the call is answered and decides whether a person or a machine picked up. Set it per call with `amd`, or leave it out to use the assistant’s setting: | `amd` | What happens on a machine | | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `{ "enabled": false }` | Nothing special: the assistant talks to whatever answered. | | `{ "enabled": true, "on_machine": "hangup" }` | The call ends at once, with the end reason `amd-machine`. | | `{ "enabled": true, "on_machine": "leave_message", "message": "…" }` | MoreVoice waits for the beep, leaves `message` (by default, the assistant’s voicemail message), after the AI disclosure when one applies, and hangs up with `amd-left-message`. Without any message to leave, it hangs up as with `hangup`. | * cURL ```sh # A reminder that is left as a voicemail when a machine answers. curl https://api.morevoice.ai/v1/calls \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reminder-$APPOINTMENT_ID" \ -d '{ "to": "+972501234567", "from_number_id": "'"$FROM_NUMBER_ID"'", "assistant_id": "'"$ASSISTANT_ID"'", "purpose": "service", "variables": { "customer_name": "דנה" }, "amd": { "enabled": true, "on_machine": "leave_message", "message": "שלום דנה, מזכירים שיש לך תור ביום שלישי ב-10:30. לשינוי, אפשר לחזור אלינו." } }' ``` * Node.js leave-message.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // A reminder that is left as a voicemail when a machine answers. const call = await mv.calls.create( { to: "+972501234567", from_number_id: process.env.FROM_NUMBER_ID!, assistant_id: process.env.ASSISTANT_ID!, purpose: "service", variables: { customer_name: "דנה" }, amd: { enabled: true, on_machine: "leave_message", message: "שלום דנה, מזכירים שיש לך תור ביום שלישי ב-10:30. לשינוי, אפשר לחזור אלינו.", }, }, { idempotencyKey: `reminder-${process.env.APPOINTMENT_ID}` }, ); console.log(call.id, call.status); ``` * Python leave_message.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # A reminder that is left as a voicemail when a machine answers. response = requests.post( f"{API}/calls", headers={**HEADERS, "Idempotency-Key": f"reminder-{os.environ['APPOINTMENT_ID']}"}, json={ "to": "+972501234567", "from_number_id": os.environ["FROM_NUMBER_ID"], "assistant_id": os.environ["ASSISTANT_ID"], "purpose": "service", "variables": {"customer_name": "דנה"}, "amd": { "enabled": True, "on_machine": "leave_message", "message": "שלום דנה, מזכירים שיש לך תור ביום שלישי ב-10:30. לשינוי, אפשר לחזור אלינו.", }, }, timeout=30, ) response.raise_for_status() call = response.json() print(call["id"], call["status"]) ``` The verdict is on the call as `answered_by` (`human`, `machine` or `unknown`) and in the `call.answered` event. A person gets the assistant’s first message as usual; `unknown` is treated as a person. ## How a call ends A call goes from `queued` to `ringing`, `in_progress` (answered) and `ended`. Once it has ended, the call object carries `ended_at`, `duration_ms`, `end_reason` and `cost`, and the summary follows a few seconds later. The events arrive in this order: | Event | When | | -------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | [`call.created`](/webhooks/events/#call.created) | The call was accepted and is being dialled. | | [`call.ringing`](/webhooks/events/#call.ringing) | The far end rings. | | [`call.answered`](/webhooks/events/#call.answered) | Someone (or something: `answered_by`) picked up. | | [`call.ended`](/webhooks/events/#call.ended) | The call ended, with `end_reason` and `duration_ms`. Unanswered calls end here too. | | [`call.analyzed`](/webhooks/events/#call.analyzed) | The summary, outcome and extracted details are ready. | | [`call.cost_finalized`](/webhooks/events/#call.cost_finalized) | The price is final. | `end_reason` tells you what to do next: | `end_reason` | Meaning | What to do | | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | | `customer-ended-call`, `assistant-ended-call`, `assistant-said-end-call-phrase`, `flow-ended`, `tool-requested` | The conversation ended as designed. | Read the summary (`call.analyzed`). | | `transferred` | The call was handed to a queue, a number or a person. | Follow it in your queue or contact centre. | | `customer-busy` | The line was busy. | Try again later. | | `customer-did-not-answer`, `customer-declined` | Nobody answered, or the call was rejected. | Try again later, or [schedule a callback](#call-back-later). | | `amd-machine`, `amd-left-message` | A machine answered; with `leave_message`, your message was left. | Don’t call straight back. | | `opted-out` | The person asked not to be called again: the number is on your do-not-call list now. | Don’t call again; update your CRM (`contact.opted_out` arrives too). | | `exceeded-max-duration`, `silence-timed-out` | The call hit `max_duration_s`, or nobody spoke. | Check the transcript. | | `api-ended-call`, `operator-ended-call` | You hung up through the API or the dashboard. | — | | `invalid-number`, `peer-rtp-timeout`, `sip-…` | The number or the network failed. | Check the number and the [SIP connection](/guides/phone-numbers-and-sip/). | Campaigns read the same reasons to decide on [retries](/guides/campaigns/#retries). ## Follow the call Follow a call with the webhooks above, or read it with `GET /v1/calls/{id}`. The SDK’s `calls.waitUntilEnded()` polls until the call has ended (the [quickstart](/get-started/quickstart/) shows it). To find calls, list them with filters: * cURL ```sh # Ended calls of one assistant, newest first. curl "https://api.morevoice.ai/v1/calls?assistant_id=$ASSISTANT_ID&status=ended&limit=20" \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js list-calls.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Ended calls of one assistant, newest first. The list fetches the next page when you need it. for await (const call of mv.calls.list({ assistant_id: process.env.ASSISTANT_ID!, status: "ended", limit: 20 })) { console.log(call.id, call.to, call.end_reason, call.metadata); } ``` * Python list_calls.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Ended calls of one assistant, newest first. response = requests.get( f"{API}/calls", headers=HEADERS, params={"assistant_id": os.environ["ASSISTANT_ID"], "status": "ended", "limit": 20}, timeout=30, ) response.raise_for_status() for call in response.json()["data"]: print(call["id"], call["to"], call["end_reason"], call["metadata"]) ``` ## Act on a live call End a call from your side: * cURL ```sh curl -X POST https://api.morevoice.ai/v1/calls/$CALL_ID/hangup \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js hang-up.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY const call = await mv.calls.hangup(process.env.CALL_ID!); console.log(call.status); ``` * Python hang_up.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} call_id = os.environ["CALL_ID"] response = requests.post(f"{API}/calls/{call_id}/hangup", headers=HEADERS, timeout=30) response.raise_for_status() print(response.json()["status"]) ``` Or hand it to people: a queue, a phone number, another assistant or a specific agent. `cold` hands the call over; `warm` lets the assistant whisper your `note` to whoever takes it (with a human call, the agent talks to the target first): * cURL ```sh # Hand the call to the people in a queue, with a note they see next to it. curl https://api.morevoice.ai/v1/calls/$CALL_ID/transfer \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": { "queue_id": "'"$QUEUE_ID"'" }, "mode": "cold", "note": "Wants to move Tuesday'"'"'s appointment to next week" }' ``` * Node.js transfer.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Hand the call to the people in a queue, with a note they see next to it. await mv.calls.transfer(process.env.CALL_ID!, { to: { queue_id: process.env.QUEUE_ID! }, mode: "cold", note: "Wants to move Tuesday's appointment to next week", }); ``` * Python transfer.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} call_id = os.environ["CALL_ID"] # Hand the call to the people in a queue, with a note they see next to it. response = requests.post( f"{API}/calls/{call_id}/transfer", headers=HEADERS, json={ "to": {"queue_id": os.environ["QUEUE_ID"]}, "mode": "cold", "note": "Wants to move Tuesday's appointment to next week", }, timeout=30, ) response.raise_for_status() ``` Both answer `409` with the code `call_not_active` when the call has already ended. Calls handled by people can also be put on hold, sent DTMF tones, consulted on and joined by more participants: see [live control of agents’ calls](/guides/human-agents/#control-a-live-call). ## Call back later When the time isn’t right (outside calling hours, the person asked for tomorrow, nobody answered), don’t keep the job in your own scheduler: ask MoreVoice for a **callback**. With `route: "assistant"` the AI calls back by itself; with `agent_queue` or `agent` your people do (see [callbacks for agents](/guides/human-agents/#callbacks)): * cURL ```sh # Ask the AI to call the customer back tomorrow morning. The time moves into your calling hours. curl https://api.morevoice.ai/v1/callbacks \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "+972501234567", "name": "Dana Levi", "due_at": "2026-11-04T09:00:00+02:00", "route": "assistant", "assistant_id": "'"$ASSISTANT_ID"'", "max_attempts": 2, "note": "Wants to hear about the renewal offer", "metadata": { "crm_contact_id": "0031x00000AbCdE" } }' ``` * Node.js call-back-later.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Ask the AI to call the customer back tomorrow morning. The time moves into your calling hours. const callback = await mv.callbacks.create({ phone: "+972501234567", name: "Dana Levi", due_at: "2026-11-04T09:00:00+02:00", route: "assistant", assistant_id: process.env.ASSISTANT_ID!, max_attempts: 2, note: "Wants to hear about the renewal offer", metadata: { crm_contact_id: "0031x00000AbCdE" }, }); console.log(callback.id, callback.status, callback.due_at); ``` * Python call_back_later.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Ask the AI to call the customer back tomorrow morning. The time moves into your calling hours. response = requests.post( f"{API}/callbacks", headers=HEADERS, json={ "phone": "+972501234567", "name": "Dana Levi", "due_at": "2026-11-04T09:00:00+02:00", "route": "assistant", "assistant_id": os.environ["ASSISTANT_ID"], "max_attempts": 2, "note": "Wants to hear about the renewal offer", "metadata": {"crm_contact_id": "0031x00000AbCdE"}, }, timeout=30, ) response.raise_for_status() callback = response.json() print(callback["id"], callback["status"], callback["due_at"]) ``` - **When.** `due_at` is the earliest time to call (by default now, at most 90 days ahead). It is moved into your callback hours, which follow the [calling hours, Shabbat and holidays](/guides/compliance/#calling-hours-shabbat-and-holidays), and `window_end` says when it is too late. - **Attempts.** `max_attempts` (1–10) tries before the callback `failed`. Each attempt is a call with its own call object; `call_id` points to the one that got through. - **One per person.** A second request for the same number while one is open is merged into it (`merged_requests` counts them); `call_id` on the request ties it to the call it came from, so repeating the request for the same call returns the first callback. - **Events.** [`callback.scheduled`](/webhooks/events/#callback.scheduled), then `callback.completed`, `callback.failed` or `callback.cancelled`. The callback’s calls go through the same compliance gate as every other outbound call. ## Test mode With a test key, `POST /v1/calls` places a simulated call on the virtual carrier: no phone network is reached, nothing is billed (`cost` is 0), and the [test numbers](/get-started/test-mode/#test-numbers) let you play out a busy line, a voicemail or a do-not-call refusal. `from_number_id` is optional in test mode; when you send one, it must be one of your sandbox numbers (`GET /v1/phone_numbers` with a test key). Note Test mode simulates the do-not-call refusal (the test number `+972500000004`, and numbers on your own do-not-call list) but not the consent and national-registry checks of marketing calls: they need real numbers and run in live mode only. Build your error handling against the test numbers, then test with a live key on your own phone before calling customers. # Phone numbers and SIP > Connect your phone lines to MoreVoice over SIP, as a trunk or a registration account, set their limits, and see the numbers your account can answer and call from. MoreVoice reaches the phone network over **SIP**: you bring the lines your carrier gives you, and MoreVoice answers and places calls on them. A **connection** is one such line. The **phone numbers** of your account are the numbers those connections answer and call from. Once a line is connected, [inbound routes](/guides/inbound-calls/) decide who answers each number. Live mode only Connections carry real calls, so they exist in live mode only: a test key gets `403` with the code [`live_only`](/guides/errors-and-limits/#live-only). Test mode needs no line at all: its calls run on a virtual carrier with [test numbers](/get-started/test-mode/#test-numbers). ## Connections There are two kinds: | Kind (`type`) | How calls reach MoreVoice | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | SIP trunk (`trunk`) | Your carrier sends calls straight to MoreVoice’s address. It usually trusts IP addresses, so a username is only needed when the trunk uses digest authentication. | | Registration account (`registration`) | MoreVoice registers to your provider like a desk phone, with a username and a password, and the provider sends calls to that registration. A cloud PBX extension is a registration account. | Connections speak plain SIP over UDP with G.711 audio: A-law (`pcma`, the standard in Israel and Europe) and μ-law (`pcmu`, the standard in the US). Every connection runs through the same engine: AI agents, routing, recordings and limits work the same way. `backend: "freeswitch"` runs a connection through the optional FreeSWITCH front end instead of the built-in SIP stack; nothing else changes. ### Connect a SIP trunk Give your carrier MoreVoice’s SIP address (the dashboard shows it under SettingsSIP / Phone, in the Phone system card), then create the connection with the carrier’s host and the addresses it sends calls from: * cURL ```sh # Connect a SIP trunk: your carrier sends calls to MoreVoice, from these IP addresses only. Live keys only. curl https://api.morevoice.ai/v1/connections \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Main trunk", "type": "trunk", "host": "sip.carrier.example.com", "inbound_acl": ["198.51.100.0/24"], "caller_ids": ["+97231234567", "+97231234568"], "default_caller_id": "+97231234567", "max_channels": 30, "inbound_reserve_pct": 20, "max_cps": 5, "overflow_policy": "unavailable" }' ``` * Node.js create-trunk.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY (a live key: connections are live-only) // Connect a SIP trunk: your carrier sends calls to MoreVoice, from these IP addresses only. const connection = await mv.connections.create({ name: "Main trunk", type: "trunk", host: "sip.carrier.example.com", inbound_acl: ["198.51.100.0/24"], caller_ids: ["+97231234567", "+97231234568"], default_caller_id: "+97231234567", max_channels: 30, inbound_reserve_pct: 20, // 6 of the 30 channels stay free for inbound calls max_cps: 5, overflow_policy: "unavailable", // 503 when full: the carrier can fail over to its backup route }); console.log(connection.id, connection.status.state); ``` * Python create_trunk.py ```python import os import uuid import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # a live key: connections are live-only # Connect a SIP trunk: your carrier sends calls to MoreVoice, from these IP addresses only. response = requests.post( f"{API}/connections", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json={ "name": "Main trunk", "type": "trunk", "host": "sip.carrier.example.com", "inbound_acl": ["198.51.100.0/24"], "caller_ids": ["+97231234567", "+97231234568"], "default_caller_id": "+97231234567", "max_channels": 30, "inbound_reserve_pct": 20, # 6 of the 30 channels stay free for inbound calls "max_cps": 5, "overflow_policy": "unavailable", # 503 when full: the carrier can fail over to its backup route }, timeout=30, ) response.raise_for_status() connection = response.json() print(connection["id"], connection["status"]["state"]) ``` | Field | What it does | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `host`, `port` | The carrier’s SIP host and port (5060 by default). | | `inbound_acl` | The addresses, single or CIDR ranges, that may send calls on this connection besides `host`. | | `caller_ids` | The numbers the carrier lets you present on outbound calls. They also become [phone numbers](#phone-numbers) of your account. | | `default_caller_id` | The caller ID of an outbound call that names none. | | `max_channels` | Calls in progress at once on the connection; `0` means no limit. | | `inbound_reserve_pct` | A share of the channels outbound calls can’t take, so customers can always get through. | | `max_cps` | New outbound calls per second. | | `overflow_policy` | The answer when no channel is free: `busy` (`486 Busy`) or `unavailable` (`503`, which lets a carrier with a backup route fail over). | | `answer_inbound` | `false` refuses every inbound call on the connection with `486 Busy`. | | `inbound_assistant_id` | The assistant that answers when no [inbound route](/guides/inbound-calls/) matches a call. | ### Register to a provider A registration account needs the provider’s host, the account’s username and its password. `register: true` keeps the registration alive, renewing it every `register_expires_seconds` (300 by default): * cURL ```sh # A registration account: MoreVoice registers to your provider like a desk phone. The password is write-only. curl https://api.morevoice.ai/v1/connections \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Office line", "type": "registration", "host": "pbx.provider.example.com", "username": "0312345670", "password": "'"$SIP_PASSWORD"'", "register": true, "max_channels": 4 }' # After you change the account at the provider, register again now and read the result. curl -X POST https://api.morevoice.ai/v1/connections/$CONNECTION_ID/register \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` * Node.js register.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY (a live key) // A registration account: MoreVoice registers to your provider like a desk phone. The password is write-only. const line = await mv.connections.create({ name: "Office line", type: "registration", host: "pbx.provider.example.com", username: "0312345670", password: process.env.SIP_PASSWORD!, register: true, max_channels: 4, }); // After you change the account at the provider, register again now and read the result. const registration = await mv.connections.register(line.id); console.log(registration.status.state, registration.status.error ?? ""); ``` * Python register.py ```python import os import uuid import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # a live key # A registration account: MoreVoice registers to your provider like a desk phone. The password is write-only. response = requests.post( f"{API}/connections", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, json={ "name": "Office line", "type": "registration", "host": "pbx.provider.example.com", "username": "0312345670", "password": os.environ["SIP_PASSWORD"], "register": True, "max_channels": 4, }, timeout=30, ) response.raise_for_status() line = response.json() # After you change the account at the provider, register again now and read the result. response = requests.post(f"{API}/connections/{line['id']}/register", headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())}, timeout=30) response.raise_for_status() status = response.json()["status"] print(status["state"], status["error"] or "") ``` The password is write-only: no answer ever contains it, and `password_set` says whether one is stored. To change it, send a new `password`; to remove it, send `null`. `POST /v1/connections/{id}/register` registers again at once, for example after you changed the account at the provider, and answers with the state right after the attempt. ### Who may send calls MoreVoice accepts a new inbound call (`INVITE`) only when it comes from the connection’s `host` or from an address in its `inbound_acl`. Anything else is refused with `403 Forbidden`, so nobody can reach your assistants by sending calls to MoreVoice’s address directly. When several connections could own a call (two extensions of the same PBX, for example), MoreVoice picks the one whose username or caller IDs match the dialled number. ### Connection status Every connection carries its live `status`: | `status.state` | Meaning | | -------------- | ------------------------------------------------------ | | `registered` | A registration account is registered and ready. | | `listening` | A trunk is ready to receive calls. | | `registering` | A registration is in progress. | | `incomplete` | Settings are missing (a host, a username). | | `failed` | The last registration failed: `status.error` says why. | | `disabled` | The connection is switched off (`enabled: false`). | `active_calls` counts the calls on the connection right now. Subscribe to the [`connection.status_changed`](/webhooks/events/#connection.status_changed) event to hear when a registration goes up or down. Changes to a connection apply to the next call; calls in progress are not affected. A connection that carries calls can’t be deleted (`409`). ## Phone numbers `GET /v1/phone_numbers` lists the numbers of your account, whatever their source: | `source` | Where the number comes from | | ------------- | ------------------------------------------------------------------------------------------------------------- | | `connection` | A number of one of your SIP connections: its `caller_ids`, and the numbers your inbound routes match exactly. | | `provisioned` | A number bought through MoreVoice. | | `sandbox` | A test-mode number on the virtual carrier (test keys list only these). | * cURL ```sh # The numbers on your account: their source, whether they answer inbound calls, and whether they can be a caller ID. curl "https://api.morevoice.ai/v1/phone_numbers?limit=100" \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js list-numbers.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // The numbers on your account: their source, whether they answer inbound calls, and whether they can be a caller ID. for await (const n of mv.phoneNumbers.list({ limit: 100 })) { console.log(n.e164, n.source, n.inbound ? "inbound" : "", n.outbound_caller_id ? "caller ID" : ""); } ``` * Python list_numbers.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # The numbers on your account: their source, whether they answer inbound calls, and whether they can be a caller ID. params = {"limit": 100} while True: response = requests.get(f"{API}/phone_numbers", headers=HEADERS, params=params, timeout=30) response.raise_for_status() page = response.json() for n in page["data"]: print(n["e164"], n["source"], "inbound" if n["inbound"] else "", "caller ID" if n["outbound_caller_id"] else "") if not page["has_more"]: break params["starting_after"] = page["next_cursor"] ``` `inbound` says whether calls to the number are answered, and `inbound_route_id` names the route that answers them. `outbound_caller_id` says whether the number can be presented on outbound calls. To call from a number, pass its ID as `from_number_id` when you [create a call](/guides/outbound-calls/): it picks the connection and the caller ID together. Buying and porting numbers Numbers will be bought, ported and released through the API (`POST /v1/phone_numbers`), with the events `number.provisioned`, `number.released` and `number.port_completed`. Until then, the numbers of your account come from your own SIP connections. ## Set it up in the app 1. Open SettingsSIP / Phone. The Phone system card turns the SIP stack on and shows its public address. Give this address to your carrier for a trunk. 2. Click New SIP trunk or New SIP account, fill in the host and the credentials from your carrier, set Max channels, Reserved for inbound, Max calls / second and Allowed inbound IPs, and click Save connection. 3. Add [inbound routes](/guides/inbound-calls/) for the numbers on the line, and call one of them. # QA: scorecards, the rubric and insights > Every agent call is scored against your rubric a few seconds after it ends, with quotes from the call as evidence. Set the rubric, read and correct scorecards, score a call on demand, and pull conversation insights over the API. Quality checks without listening to every call: a few seconds after a call with an agent ends, AI scores it against your **rubric** and writes a **scorecard**: a score out of 100, pass or fail, a score for each rubric item with the quotes from the transcript it rests on, a compliance verdict, the call’s outcome and coaching tips. **Insights** sum up the scored calls of a period: by agent, by week, and which behaviour wins. Over the API you keep the rubric in your own code, send scorecards to your coaching or HR tools, let supervisors’ corrections flow back, and feed the insights into your BI. ## The rubric The rubric is what every call is checked against. Each item is scored from 0 to 5 (or not applicable), and the call’s score is the weighted average of the applicable items on a 0–100 scale: * cURL ```sh # Your organisation's rubric: three items on one 0–100 weight scale, a critical disclosure, pass at 75. curl -X PUT https://api.morevoice.ai/v1/qa/rubric \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Service calls", "pass_threshold": 75, "items": [ { "key": "disclosure", "label": "Recording notice", "description": "Says the call is recorded before asking for details.", "weight": 20, "type": "boolean", "critical": true }, { "key": "discovery", "label": "Understands the problem", "description": "Asks open questions and confirms the issue before solving it.", "weight": 50 }, { "key": "next_step", "label": "Clear next step", "description": "Ends with what happens next and when.", "weight": 30 } ] }' ``` * Node.js set-rubric.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Your organisation's rubric: three items on one 0–100 weight scale, a critical disclosure, pass at 75. const rubric = await mv.qa.updateRubric({ name: "Service calls", pass_threshold: 75, items: [ { key: "disclosure", label: "Recording notice", description: "Says the call is recorded before asking for details.", weight: 20, type: "boolean", critical: true }, { key: "discovery", label: "Understands the problem", description: "Asks open questions and confirms the issue before solving it.", weight: 50 }, { key: "next_step", label: "Clear next step", description: "Ends with what happens next and when.", weight: 30 }, ], }); console.log(rubric.name, rubric.items.length, "items, pass at", rubric.pass_threshold); ``` * Python set_rubric.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Your organisation's rubric: three items on one 0–100 weight scale, a critical disclosure, pass at 75. response = requests.put( f"{API}/qa/rubric", headers=HEADERS, json={ "name": "Service calls", "pass_threshold": 75, "items": [ {"key": "disclosure", "label": "Recording notice", "description": "Says the call is recorded before asking for details.", "weight": 20, "type": "boolean", "critical": True}, {"key": "discovery", "label": "Understands the problem", "description": "Asks open questions and confirms the issue before solving it.", "weight": 50}, {"key": "next_step", "label": "Clear next step", "description": "Ends with what happens next and when.", "weight": 30}, ], }, timeout=30, ) response.raise_for_status() rubric = response.json() print(rubric["name"], len(rubric["items"]), "items, pass at", rubric["pass_threshold"]) ``` | Item field | What it does | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `key` | Your stable name for the item (letters, digits, `-` and `_`). Overrides and scorecards refer to it. | | `label`, `description` | What is checked, and what a good answer looks like: the scorer reads the description, so write it as you would for a new supervisor. | | `weight` | The item’s importance, on one 0–100 scale. Only the ratios count: 60 and 40 weigh like 3 and 2. | | `type` | `scale` (0–5) or `boolean` (done: 5, or not: 0). | | `critical` | A compliance item: scoring 2 or less fails the call, whatever the total. | The call passes at `pass_threshold` (0–100) or above, with no critical item failed. `PUT /v1/qa/rubric` replaces the whole rubric; calls scored from then on use it, and existing scorecards keep the rubric they were scored with. Until you save one, `GET /v1/qa/rubric` returns the built-in rubric (`builtin: true`). Calls with a [copilot profile](/guides/copilot/) that has its own `qa_rubric` are scored against that one instead, so a sales queue and a service queue can be judged differently. The scorecard’s `rubric_source` says which rubric applied. ## Scorecards Calls handled by agents are scored automatically once they end, as your QA policy (in the app, under the supervisor’s Policy & QA) sets it. A call that is too short, or has no transcript, isn’t scored automatically: its scorecard is `skipped`, and `error` says why. Read a call’s scorecard when [`qa.scored`](/webhooks/events/#qa.scored) arrives: * cURL ```sh # A call's scorecard: the score, pass or fail, and each item with the quotes it rests on. curl https://api.morevoice.ai/v1/calls/$CALL_ID/scorecard \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js get-scorecard.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // A call's scorecard: the score, pass or fail, and each item with the quotes it rests on. const card = await mv.qa.retrieveScorecard(process.env.CALL_ID!); console.log(card.status, card.score, card.passed ? "passed" : "failed", card.critical_failed); for (const item of card.items) { const quote = item.evidence[0]; console.log(`${item.label}: ${item.score ?? "n/a"}/5`, quote ? `“${quote.quote}” (${quote.speaker}, line ${quote.line})` : ""); } ``` * Python get_scorecard.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # A call's scorecard: the score, pass or fail, and each item with the quotes it rests on. response = requests.get(f"{API}/calls/{os.environ['CALL_ID']}/scorecard", headers=HEADERS, timeout=30) response.raise_for_status() card = response.json() print(card["status"], card["score"], "passed" if card["passed"] else "failed", card["critical_failed"]) for item in card["items"]: quote = item["evidence"][0] if item["evidence"] else None where = f"“{quote['quote']}” ({quote['speaker']}, line {quote['line']})" if quote else "" print(f"{item['label']}: {item['score']}/5", where) ``` | Field | What it holds | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | `pending`, `running`, `done`, `skipped` (too short to score) or `error` (scoring failed). | | `score`, `passed`, `critical_failed` | The 0–100 score after any overrides, pass or fail, and the keys of the critical items that failed the call. | | `model_score` | The score as the model gave it, before any overrides. | | `items` | One per rubric item: `score` (0–5, or `null` for not applicable), `reasoning`, and `evidence`: quotes, each verified against the transcript, with the speaker, the transcript line and the offset in the recording. | | `compliance` | A `pass`, `warn` or `fail` verdict, with the issues found and their quotes. | | `disposition`, `summary`, `customer_sentiment` | How the call ended for the business (`sale`, `appointment`, `resolved`, `not_interested`…), in a few sentences, and how the customer felt. | | `strengths`, `coaching`, `next_steps`, `objections` | What went well, tips with better wording, follow-ups, and each objection with whether it was handled and how. | `GET` answers `404` until the call has a scorecard. ### Score a call now `POST /v1/calls/{id}/scorecard/run` scores a call on demand, even one too short for automatic scoring, and answers the scorecard (it can take a few seconds). A call that already has a finished scorecard is returned as it is unless you send `force: true`, which scores it again against today’s rubric. A call still in progress answers `409 call_in_progress`. ### Correct a score A supervisor who disagrees with an item sets its score, with a note. The call’s score and pass are recomputed, the change is kept in the scorecard’s history, and [`qa.overridden`](/webhooks/events/#qa.overridden) is sent: * cURL ```sh # A supervisor corrects one item; the call's score and pass are recomputed. curl -X PATCH https://api.morevoice.ai/v1/calls/$CALL_ID/scorecard/items/discovery \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "score": 4, "note": "Also confirmed the policy number before solving." }' ``` * Node.js override-item.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // A supervisor corrects one item; the call's score and pass are recomputed. const card = await mv.qa.overrideScorecardItem(process.env.CALL_ID!, "discovery", { score: 4, note: "Also confirmed the policy number before solving.", }); console.log(`score ${card.model_score} → ${card.score}`, card.passed ? "passed" : "failed"); ``` * Python override_item.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # A supervisor corrects one item; the call's score and pass are recomputed. response = requests.patch( f"{API}/calls/{os.environ['CALL_ID']}/scorecard/items/discovery", headers=HEADERS, json={"score": 4, "note": "Also confirmed the policy number before solving."}, timeout=30, ) response.raise_for_status() card = response.json() print(f"score {card['model_score']} → {card['score']}", "passed" if card["passed"] else "failed") ``` Send `score: null` to mark the item not applicable, or `clear: true` to remove the override so the model’s score applies again. The item keeps both: `score` after the override, `model_score` as the model gave it, and `override` with who changed it and when. Only a finished scorecard (`done`) can be corrected; any other answers `409`. ## Insights `GET /v1/insights` sums up the scored calls of the last `days` days (1–365, 30 by default), for the whole organisation or one agent (`agent_id`): * cURL ```sh # The last 30 days of scored calls: KPIs, the objections that cost deals and the answers that win, per-agent trends. curl "https://api.morevoice.ai/v1/insights?days=30" \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js insights.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // The last 30 days of scored calls: KPIs, the objections that cost deals and the answers that win, per-agent trends. const insights = await mv.insights.retrieve({ days: 30 }); console.log(`${insights.kpis.analyzed} calls, average ${insights.kpis.average_score}, pass rate ${insights.kpis.pass_rate}`); for (const o of insights.top_objections) console.log(o.label, `handled ${o.handled_rate}`, `best answer: ${o.best_response ?? "—"}`); for (const a of insights.agents) console.log(a.name, a.calls, a.average_score, a.compliance_fails); ``` * Python insights.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # The last 30 days of scored calls: KPIs, the objections that cost deals and the answers that win, per-agent trends. response = requests.get(f"{API}/insights", headers=HEADERS, params={"days": 30}, timeout=30) response.raise_for_status() insights = response.json() kpis = insights["kpis"] print(f"{kpis['analyzed']} calls, average {kpis['average_score']}, pass rate {kpis['pass_rate']}") for o in insights["top_objections"]: print(o["label"], f"handled {o['handled_rate']}", f"best answer: {o['best_response'] or '—'}") for a in insights["agents"]: print(a["name"], a["calls"], a["average_score"], a["compliance_fails"]) ``` - **`kpis`**: calls analysed, average score, pass rate, compliance-fail rate, win rate (calls with a positive outcome, such as a sale or an appointment) and average duration. - **`top_objections`**: what customers object to most, how often agents handled it, the win rate when they did and when they didn’t, and the answer that won most often (`best_response`). - **`behaviour`**: win rate and score by the agent’s share of talk time, by the number of questions asked, and by how much of the checklist they covered. - **`agents`**: each agent’s calls, average score, pass rate, win rate and compliance fails, with a weekly trend. - **`copilot`**: how often agents used the [copilot](/guides/copilot/)’s cards, and their thumbs up and down. Note Objections are grouped by meaning in live mode. Test keys group them by their words (`semantic_clustering: false`). The help centre explains the same screens to supervisors: [scorecards](https://help.morevoice.ai/en/qa/scorecards/), [the rubric](https://help.morevoice.ai/en/qa/rubric/) and [insights](https://help.morevoice.ai/en/qa/insights/). # React: hooks and components > @morevoice/react brings MoreVoice calls to React apps: hooks for a call and its live transcript, a talk button and a softphone, built on the @morevoice/web call client. `@morevoice/react` wraps the browser call client of [`@morevoice/web`](/guides/web-widget/) for React: hooks that hold a call’s state and its live transcript, and ready-made components for a talk button and an agent’s softphone. React components are coming `@morevoice/react` will wrap the browser call client in hooks and components: `useCall()` and `useTranscript()`, a `` and a ``, with the typed API client. This section lists them from the package once it ships; until then, use the web component below in any React app. ## Use the web widget in React today `@morevoice/web` works in any React app as it is: * **The talk button** is a standard custom element: render `` with its [attributes](/guides/web-widget/#attributes) after loading the script or importing `@morevoice/web/define` once, set `metadata` or `auth` on it through a ref, and listen to its `morevoice-…` events with `addEventListener` in an effect. * **Your own UI** creates a `MoreVoiceCall` in an effect, subscribes to its [events](/guides/web-widget/#build-your-own-call-ui) into state, and calls `destroy()` in the effect’s cleanup, so leaving the page never leaves a microphone open. Calls placed from the browser are authorised as described in [who may call](/guides/web-widget/#who-may-call). # Real-time events: follow calls as they happen > Three ways to learn what happens on your calls: webhooks pushed to your server (with opt-in live events such as transcript lines and tool calls), the event log for catching up, and Server-Sent Event streams you read as calls run. A CRM that pops the caller’s record, a wallboard, a live transcript next to a supervisor, a compliance monitor: each needs to know what happens on calls while they happen. MoreVoice gives you the same events three ways: * **[Webhooks](/webhooks/setup/).** MoreVoice `POST`s each event to your HTTPS endpoint, signed, and retries for 72 hours. Best for server-side work that must not miss an event: CRM updates, billing, follow-ups. * **[The event log](#catch-up-with-the-event-log).** You list past events with `GET /v1/events` (kept 30 days). Best for catching up after downtime, reconciling and backfills. * **[Event streams](#event-streams).** You hold a connection open and events arrive as they happen (Server-Sent Events). Best for live screens, and for tools behind a firewall without a public endpoint. Every way carries the same [event envelope](/webhooks/setup/#the-event-envelope) and the same [event types](/webhooks/events/), so one handler serves all three. ## Live events over webhooks Besides the events of a call’s life (`call.created`, `call.answered`, `call.ended`, `call.analyzed`…), three **live events** follow a call turn by turn: | Event | While the call runs | | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | [`transcript.final`](/webhooks/events/#transcript.final) | A final line of the transcript: the caller’s or the assistant’s. | | [`call.tool_called`](/webhooks/events/#call.tool_called) | An assistant’s tool call finished, with its name, arguments and result or error. | | [`call.dtmf`](/webhooks/events/#call.dtmf) | The caller pressed a key. Keys typed into a question (an account number, an ID) arrive masked as `*`. | They are **opt-in**: an endpoint receives them only when it names them. `*` and `call.*` don’t include them, so an existing endpoint never starts receiving a delivery per sentence by surprise: * cURL ```sh # An endpoint for a live screen: every final transcript line and tool call while calls run, then the end of the call. # Live events are opt-in: name them; "*" and "call.*" don't include them. curl https://api.morevoice.ai/v1/webhook_endpoints \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/morevoice/live", "enabled_events": ["transcript.final", "call.tool_called", "call.ended"], "description": "Live call screen" }' ``` * Node.js live-webhook-endpoint.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // An endpoint for a live screen: every final transcript line and tool call while calls run, then the end of the call. // Live events are opt-in: name them; "*" and "call.*" don't include them. const endpoint = await mv.webhookEndpoints.create({ url: "https://hooks.example.com/morevoice/live", enabled_events: ["transcript.final", "call.tool_called", "call.ended"], description: "Live call screen", }); console.log(endpoint.id, endpoint.secret); // keep the whsec_… secret to verify deliveries ``` * Python live_webhook_endpoint.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # An endpoint for a live screen: every final transcript line and tool call while calls run, then the end of the call. # Live events are opt-in: name them; "*" and "call.*" don't include them. response = requests.post( f"{API}/webhook_endpoints", headers=HEADERS, json={ "url": "https://hooks.example.com/morevoice/live", "enabled_events": ["transcript.final", "call.tool_called", "call.ended"], "description": "Live call screen", }, timeout=30, ) response.raise_for_status() endpoint = response.json() print(endpoint["id"], endpoint["secret"]) # keep the whsec_… secret to verify deliveries ``` A live event is delivered as soon as it happens, but like every webhook it can arrive late or twice when your endpoint is slow or a delivery is retried. Order lines by the event’s `created` time and skip IDs you have seen. ## Catch up with the event log Events are kept for 30 days. After your endpoint was down, or to rebuild a report, list what you missed by type and time: * cURL ```sh # Back after downtime: the calls that ended since your last processed event. curl -g "https://api.morevoice.ai/v1/events?types[]=call.ended&types[]=call.analyzed&created[gte]=2026-11-03T08:00:00Z&limit=100" \ -H "Authorization: Bearer $MOREVOICE_API_KEY" ``` * Node.js catch-up-events.ts ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // reads MOREVOICE_API_KEY // Back after downtime: the calls that ended since your last processed event. The list pages on its own. for await (const event of mv.events.list({ types: ["call.ended", "call.analyzed"], created: { gte: "2026-11-03T08:00:00Z" }, limit: 100 })) { console.log(event.id, event.type, event.created); } ``` * Python catch_up_events.py ```python import os import requests API = "https://api.morevoice.ai/v1" HEADERS = {"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}"} # Back after downtime: the calls that ended since your last processed event (follow next_cursor for more pages). response = requests.get( f"{API}/events", headers=HEADERS, params={"types[]": ["call.ended", "call.analyzed"], "created[gte]": "2026-11-03T08:00:00Z", "limit": 100}, timeout=30, ) response.raise_for_status() page = response.json() for event in page["data"]: print(event["id"], event["type"], event["created"]) print("more:", page["has_more"], page["next_cursor"]) ``` `type` takes one type or a group (`call.*`), `types[]` up to 20 of them, and `created[gte]` / `created[lt]` a time range. The list is newest first and pages with `starting_after` (the `next_cursor` of the previous page). A missed webhook can also be redelivered: `POST /v1/webhook_endpoints/{id}/replay` sends an endpoint the events of a period again, and `POST /v1/webhook_deliveries/{id}/retry` retries one delivery. ## Event streams ### Stream a call’s live events `GET /v1/calls/{id}/events` · scope `calls:read` · [API reference](/api/operations/calls_stream/) Server-Sent Events for one call: `call.state_changed`, `transcript.partial`, `transcript.final`, `call.tool_called`, and `call.ended`, after which the stream closes. The stream starts with what already happened on the call. Reconnect with `Last-Event-ID` (EventSource does it for you) to get only what you missed. An ended call’s stream sends `call.ended` at once. A call that has not started yet (a web call before the browser connects) is a 404: subscribe once it starts, or follow it on GET /v1/events/stream. Counts against the key’s stream limit (`concurrency_limit`, `limit_type: api_streams`). | Query parameter | What it does | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `types` | Only these event types (comma-separated or repeated): `call.state_changed`, `transcript.partial`, `transcript.final`, `call.tool_called`, `call.ended`, `transcript.*`, `call.*` or `*`. Default: all of them. | | `last_event_id` | Resume after this event, for clients that cannot send the `Last-Event-ID` header (the header wins when both are sent). | * cURL ```sh curl -N https://api.morevoice.ai/v1/calls/call_7Hk2Lm9Qp/events \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Accept: text/event-stream" ``` * Node.js ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // MOREVOICE_API_KEY from the environment for await (const event of mv.calls.stream("call_7Hk2Lm9Qp")) { console.log(event.type, event.data.object); } ``` * Python ```python import json import os import requests # Read the stream as it arrives: each event is an `event:` line and a `data:` line with the event envelope. with requests.get( "https://api.morevoice.ai/v1/calls/call_7Hk2Lm9Qp/events", headers={"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}", "Accept": "text/event-stream"}, stream=True, timeout=(10, None), ) as response: response.raise_for_status() for line in response.iter_lines(decode_unicode=True): if line and line.startswith("data:"): event = json.loads(line[5:]) print(event["id"], event.get("type")) ``` What the stream looks like ```text retry: 3000 id: call_8tRPaZp5hLMbrGqdJ9AmNa:1 event: call.state_changed data: {"id":"call_8tRPaZp5hLMbrGqdJ9AmNa:1","object":"event","type":"call.state_changed","created":"2026-11-03T09:14:22Z","api_version":"2026-11-01","livemode":true,"org_id":"org_0000000000000000000001","data":{"object":{"call_id":"call_8tRPaZp5hLMbrGqdJ9AmNa","state":"listening"}}} id: call_8tRPaZp5hLMbrGqdJ9AmNa:2 event: transcript.final data: {"id":"call_8tRPaZp5hLMbrGqdJ9AmNa:2","object":"event","type":"transcript.final","created":"2026-11-03T09:14:24Z","api_version":"2026-11-01","livemode":true,"org_id":"org_0000000000000000000001","data":{"object":{"call_id":"call_8tRPaZp5hLMbrGqdJ9AmNa","utterance_id":"u1","speaker":"customer","role":"user","text":"Hi, where is my order?","final":true,"interrupted":false}}} ``` ### Stream a call’s live transcript `GET /v1/calls/{id}/transcript/stream` · scope `calls:read` · [API reference](/api/operations/calls_transcript_stream/) Server-Sent Events with what is said on a call as it is said: `transcript.partial` while a line is spoken (its text so far) and `transcript.final` when it is complete. A final supersedes the partials with the same `utterance_id`. `speaker` is `customer` (the caller), `assistant` (the AI), `agent` (a human agent), `supervisor` or `external` (a transfer party); AI and human calls alike. `start_ms` and `end_ms` are when the line began and became final, in milliseconds from the call’s start, as observed live (null for lines spoken before the stream connected; the call’s transcript has the exact times after the call). The stream starts with the conversation so far and ends with `call.ended`. Reconnect with `Last-Event-ID` to get only what you missed. Send `partials=false` for complete lines only. Counts against the key’s stream limit (`concurrency_limit`, `limit_type: api_streams`). | Query parameter | What it does | | --------------- | ---------------------------------------------------------------------------------------------------------------------- | | `partials` | `false`: only complete lines (`transcript.final`), no partials. Default `true`. | | `last_event_id` | Resume after this event, for clients that cannot send the `Last-Event-ID` header (the header wins when both are sent). | * cURL ```sh curl -N https://api.morevoice.ai/v1/calls/call_7Hk2Lm9Qp/transcript/stream \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Accept: text/event-stream" ``` * Node.js ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // MOREVOICE_API_KEY from the environment for await (const event of mv.calls.transcriptStream("call_7Hk2Lm9Qp")) { console.log(event.type, event.data.object); } ``` * Python ```python import json import os import requests # Read the stream as it arrives: each event is an `event:` line and a `data:` line with the event envelope. with requests.get( "https://api.morevoice.ai/v1/calls/call_7Hk2Lm9Qp/transcript/stream", headers={"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}", "Accept": "text/event-stream"}, stream=True, timeout=(10, None), ) as response: response.raise_for_status() for line in response.iter_lines(decode_unicode=True): if line and line.startswith("data:"): event = json.loads(line[5:]) print(event["id"], event.get("type")) ``` What the stream looks like ```text retry: 3000 id: call_8tRPaZp5hLMbrGqdJ9AmNa:2 event: transcript.partial data: {"id":"call_8tRPaZp5hLMbrGqdJ9AmNa:2","object":"event","type":"transcript.partial","created":"2026-11-03T09:14:23Z","api_version":"2026-11-01","livemode":true,"org_id":"org_0000000000000000000001","data":{"object":{"call_id":"call_8tRPaZp5hLMbrGqdJ9AmNa","utterance_id":"u1","speaker":"customer","role":"user","text":"Hi, where is","final":false,"interrupted":false,"start_ms":4120,"end_ms":null,"language":"en-US"}}} id: call_8tRPaZp5hLMbrGqdJ9AmNa:3 event: transcript.final data: {"id":"call_8tRPaZp5hLMbrGqdJ9AmNa:3","object":"event","type":"transcript.final","created":"2026-11-03T09:14:24Z","api_version":"2026-11-01","livemode":true,"org_id":"org_0000000000000000000001","data":{"object":{"call_id":"call_8tRPaZp5hLMbrGqdJ9AmNa","utterance_id":"u1","speaker":"customer","role":"user","text":"Hi, where is my order?","final":true,"interrupted":false,"start_ms":4120,"end_ms":5310,"language":"en-US"}}} ``` ### Open a CLI listen session `GET /v1/cli/listen` · scope `events:read` · [API reference](/api/operations/cli_listen_stream/) The stream behind the CLI’s `listen` command: your organisation’s events for this key’s mode (each with the exact body and Standard Webhooks headers to POST to your local endpoint, signed with the session’s secret) and, with `forward_tools=true`, tool calls to answer from your machine — tools whose URL is `cli://` and, in test mode, every webhook tool of your test calls. The session ends when the stream closes. Use a test key; a live key needs `live=true` and the `webhooks:write` scope. Counts against the key’s stream limit. | Query parameter | What it does | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `events` | Event types to forward: a type (`call.ended`), a group (`call.*`) or `*`; comma-separated or repeated. Default: every event (opt-in live types only when named). | | `forward_tools` | `true`: this session answers tool calls — tools whose URL is `cli://` and, in test mode, every webhook tool of the organisation’s test calls. Needs the `tools:write` scope. | | `live` | Required (`true`) with a live key: live events and tool calls of live calls reach your machine. Needs the `webhooks:write` scope. | | `last_event_id` | Resume after this event (`evt_…`), for clients that cannot send `Last-Event-ID`. | * cURL ```sh curl -N https://api.morevoice.ai/v1/cli/listen \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Accept: text/event-stream" ``` * Node.js ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // MOREVOICE_API_KEY from the environment for await (const event of mv.cliListen.stream()) { console.log(event); } ``` * Python ```python import json import os import requests # Read the stream as it arrives: each event is an `event:` line and a `data:` line with the event envelope. with requests.get( "https://api.morevoice.ai/v1/cli/listen", headers={"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}", "Accept": "text/event-stream"}, stream=True, timeout=(10, None), ) as response: response.raise_for_status() for line in response.iter_lines(decode_unicode=True): if line and line.startswith("data:"): event = json.loads(line[5:]) print(event["id"], event.get("type")) ``` What the stream looks like ```text retry: 3000 event: cli.session data: {"object":"cli_session","id":"clis_5bH8jK3mN6pQ9rT2vW4xYz","livemode":false,"events":["call.*"],"forward_tools":true,"forward_all_tools":true,"secret":"whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw","created":"2026-11-03T09:14:20.000Z"} id: evt_3fT9kq2ZpLr8YwVbN1cX0a event: webhook data: {"object":"cli_webhook","id":"evt_3fT9kq2ZpLr8YwVbN1cX0a","type":"call.ended","headers":{"content-type":"application/json","user-agent":"MoreVoice-Webhooks/1.0 (cli)","webhook-id":"evt_3fT9kq2ZpLr8YwVbN1cX0a","webhook-timestamp":"1762161262","webhook-signature":"v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4="},"body":"{\"id\":\"evt_3fT9kq2ZpLr8YwVbN1cX0a\",\"object\":\"event\",\"type\":\"call.ended\"}"} event: tool_call data: {"object":"cli_tool_call","id":"clitc_7cJ9kL4mP2qR5sT8uV1wXy","tool":"order_status","call_id":"call_8tRPaZp5hLMbrGqdJ9AmNa","timeout_ms":15000,"url":"cli://orders","headers":{"content-type":"application/json","webhook-id":"tc_2bN…","webhook-timestamp":"1762161263","webhook-signature":"v1,…"},"body":"{\"tool\":\"order_status\",\"arguments\":{\"order\":\"A-1\"}}"} ``` ### Stream events as they happen `GET /v1/events/stream` · scope `events:read` · [API reference](/api/operations/events_stream/) Server-Sent Events: the same event envelopes as GET /v1/events and webhooks, live, for this key’s mode. Filter with `types` (`call.*`, `campaign.completed`, …). Reconnect with `Last-Event-ID` (EventSource does it for you) to replay what you missed, up to 24 hours back; delivery is at-least-once, so dedupe on `id`. Counts against the key’s stream limit (`concurrency_limit`, `limit_type: api_streams`). | Query parameter | What it does | | --------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `types` | Only these event types: a type (`call.ended`), a group (`call.*`) or `"*"`; comma-separated or repeated. Default: every event. | | `last_event_id` | Resume after this event, for clients that cannot send the `Last-Event-ID` header (the header wins when both are sent). | * cURL ```sh curl -N https://api.morevoice.ai/v1/events/stream \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Accept: text/event-stream" ``` * Node.js ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // MOREVOICE_API_KEY from the environment for await (const event of mv.events.stream()) { console.log(event.type, event.data.object); } ``` * Python ```python import json import os import requests # Read the stream as it arrives: each event is an `event:` line and a `data:` line with the event envelope. with requests.get( "https://api.morevoice.ai/v1/events/stream", headers={"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}", "Accept": "text/event-stream"}, stream=True, timeout=(10, None), ) as response: response.raise_for_status() for line in response.iter_lines(decode_unicode=True): if line and line.startswith("data:"): event = json.loads(line[5:]) print(event["id"], event.get("type")) ``` What the stream looks like ```text retry: 3000 id: evt_3fT9kq2ZpLr8YwVbN1cX0a event: call.ended data: {"id":"evt_3fT9kq2ZpLr8YwVbN1cX0a","object":"event","type":"call.ended","created":"2026-11-03T09:14:22Z","api_version":"2026-11-01","livemode":true,"org_id":"org_0000000000000000000001","data":{"object":{"id":"call_7Hk2","object":"call","status":"ended"}},"request":{"id":null,"idempotency_key":null}} ``` ### Stream request logs as they are written `GET /v1/request_logs/stream` · scope `api_keys:read` · [API reference](/api/operations/request_logs_stream/) Server-Sent Events: each new request log (same filters as the list, without `created`) a few seconds after the request finished. Counts against the key’s stream limit. | Query parameter | What it does | | --------------- | -------------------------------------------------------------------------------------------------- | | `status` | A status class (`2xx`, `3xx`, `4xx`, `5xx`), `error` (any 4xx or 5xx), or one status code (`404`). | | `method` | Only this HTTP method. | | `path` | Only paths starting with this (`/v1/calls` matches `/v1/calls/call_…`). | | `key_id` | Only requests made with this API key (`key_…`). | | `request_id` | Only the request with this X-Request-Id (`req_…`). | | `operation_id` | Only this operation (`calls_create`). | * cURL ```sh curl -N https://api.morevoice.ai/v1/request_logs/stream \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Accept: text/event-stream" ``` * Node.js ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // MOREVOICE_API_KEY from the environment for await (const event of mv.requestLogs.stream()) { console.log(event); } ``` * Python ```python import json import os import requests # Read the stream as it arrives: each event is an `event:` line and a `data:` line with the event envelope. with requests.get( "https://api.morevoice.ai/v1/request_logs/stream", headers={"Authorization": f"Bearer {os.environ['MOREVOICE_API_KEY']}", "Accept": "text/event-stream"}, stream=True, timeout=(10, None), ) as response: response.raise_for_status() for line in response.iter_lines(decode_unicode=True): if line and line.startswith("data:"): event = json.loads(line[5:]) print(event["id"], event.get("type")) ``` What the stream looks like ```text retry: 3000 id: rlog_4Gk2LmN9pQ4rS6tV8wX0yZ event: request_log data: {"id":"rlog_4Gk2LmN9pQ4rS6tV8wX0yZ","object":"request_log","livemode":false,"request_id":"req_7Pq2Rk9sT1uV3wX5yZ8aBc","created":"2026-11-03T09:14:22.135Z","method":"POST","path":"/v1/calls","query":null,"route":"/v1/calls","operation_id":"calls_create","api_version":"2026-11-01","status":400,"error_code":"parameter_invalid","latency_ms":42,"key_id":"key_3fT9kq2ZpLr8YwVbN1cX0a","ip":"203.0.113.7","user_agent":"morevoice-node/0.4.0","idempotency_key":"8e0f3a2c-5d1b-4c7e-9f6a-2b4d8c1e7a90","request_body":{"to":"+97250000000","assistant_id":"asst_8tRPaZp5hLMbrGqdJ9AmNa"},"response_body":{"error":{"type":"invalid_request_error","code":"parameter_invalid","message":"to must be an E.164 number","param":"to"}}} ``` ### Working with streams The streams are standard [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html), so any SSE client reads them: * **Read them from your server.** A stream is opened with your secret API key, which must never reach a browser. Relay what your page needs from your own server, or give the page a [web call](/guides/web-widget/) of its own. * **Resume, don’t restart.** Each event has an `id`. When the connection drops, reconnect with the last ID you processed in the `Last-Event-ID` header (or `last_event_id`), and the stream carries on from there; standard clients do this for you. * **Expect repeats.** After a reconnect you may see an event again: skip IDs you have already handled. * **Keep it light.** Comment lines (`: ping`) keep the connection alive through proxies; ignore them. Hand slow work to a queue so reading never falls behind, and close streams you no longer need: each key may hold only a few open at once. Which one? Use webhooks for anything that must happen once and reliably, and a stream for what a person watches. Many integrations use both: a stream drives the live screen, and webhooks write the result to the CRM. # Web widget: talk to your AI from a web page > Put a talk button on any page with the web component, or build your own call UI with the MoreVoiceCall client from @morevoice/web: attributes, events, theming, Hebrew and RTL, and how a visitor's call is authorised. `@morevoice/web` puts a voice call with your assistant on a web page. It has two parts: * **``**, a web component: one click and the visitor is talking to your assistant, with a live status, mute, hang-up, captions and the AI notice. It works in any page and any framework, in Hebrew (right to left) and English, in light and dark. * **`MoreVoiceCall`**, the call client the button is built on, for your own interface: start a call, follow its events, mute, send keys and hang up. The call runs over WebRTC from the visitor’s microphone to MoreVoice, and the same call object, transcript, recording and webhooks follow as for a phone call (with `direction: "browser"`). ## Add the button Load the script once, and place the element where the button should be: index.html ```html Acme Insurance

שאלות על הפוליסה? פשוט לשאול.

``` The script registers the element and exposes `window.MoreVoice` (`MoreVoiceCall`, `MoreVoiceCallError`, `defineTalkButton`) for pages without a bundler. With a bundler, install the package and import the element instead: talk-button.ts ```ts import "@morevoice/web/define"; // registers import type { MoreVoiceTalkButton } from "@morevoice/web/talk-button"; const button = document.querySelector("morevoice-talk-button"); if (button) button.metadata = { page: location.pathname }; ``` Not on npm yet `@morevoice/web` is published to npm with the API beta. Until then, build it from the SDK repository (`npm run build` in `packages/web`) and serve `dist/morevoice-web.js` from your own site. ### Attributes | Attribute | What it does | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `server` | Your MoreVoice server’s address, where your team signs in. By default, the page’s own origin. | | `assistant` | The assistant to talk to: its ID in the dashboard. | | `lang` | `he` or `en`. By default, the page’s language. Hebrew lays the button out right to left. | | `label` | The button’s text, instead of “Talk to us” / “דברו איתנו”. | | `org-name` | Your business’s name, in the AI notice: “You are talking with an AI agent of Acme Insurance. The call is recorded and transcribed.” | | `captions` | Show what is said, live, under the button. | | `variant="floating"` | A floating button in the page’s corner (the end side: bottom-left in Hebrew), instead of inline. | | `theme` | `light` or `dark`. By default, the visitor’s system setting. | | `notice="off"` | Hide the AI notice, but only after the server confirmed that this assistant says it is an AI at the start of the call. The first call always shows it before the microphone opens. | ### Events, properties and methods The element sends events that bubble out of its shadow root, each with the details in `event.detail`: | Event | When | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `morevoice-status` | The call’s own status changed: `starting` (asking for the microphone), `connecting`, `active`, `ending`, `ended`. | | `morevoice-answer` | MoreVoice answered: `callId` is the call’s ID, for your logs and for the API. | | `morevoice-state` | The assistant is `listening`, `thinking`, using a tool or `speaking`. | | `morevoice-transcript` | A line of the conversation, partial or final, with who said it. | | `morevoice-ended` | The call ended, with a `reason`. | | `morevoice-error` | Something went wrong: `code` is `mic_denied`, `mic_unavailable`, `unsupported`, `webrtc_failed`, `ws_closed` or `server_error`. | Set `metadata` (string key–values kept on the call, like [`metadata`](/guides/outbound-calls/#metadata) on an API call) and `auth` (below) as properties. `start()` and `hangup()` do what the buttons do, and `call` is the current `MoreVoiceCall`. ### Theming The button takes your brand’s colours from CSS custom properties on the element: `--mv-accent`, `--mv-accent-2` (the gradient), `--mv-on-accent`, `--mv-bg`, `--mv-fg`, `--mv-muted`, `--mv-line`, `--mv-bad`, `--mv-font` and `--mv-radius`. For more, style its parts with `::part()`: `button`, `bar`, `orb`, `status`, `timer`, `mute`, `end`, `notice`, `caption`, `error` and `info`. ```html ``` The button respects reduced motion and Windows high-contrast mode, keeps keyboard focus where the visitor expects it, and announces the call’s state to screen readers. ## Who may call Every call is authorised by the element’s `auth` property, or a function that returns it per call (a one-time credential must be fresh each time): | `auth` | For | | ---------------------------------- | ------------------------------------------------------------------------------------------------------- | | `{ kind: "cookie" }` (the default) | People signed in to MoreVoice on the same site: internal tools and test pages on your MoreVoice domain. | | `{ kind: "clientToken", value }` | Your website’s visitors: a short-lived client token your server mints for one call. | | `{ kind: "ticket", value }` | MoreVoice’s own apps (the softphone, the browser extension). | ### Create a client token `POST /v1/client_tokens` · scope `web_calls:write` · [API reference](/api/operations/client_tokens_create/) Mint, on your server, a short-lived token a browser uses to start one call to one assistant with @morevoice/web (`MoreVoiceWeb.start({ token })`). The token works once, until `expires_at`; bind it to your site with `origin`. It carries the call’s ID, so you can follow the call (webhooks, GET /v1/calls/{id}/events) before it starts. | Field | Required | What it does | | --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `agent_user_id` | | The agent whose softphone the token opens (the embeddable softphone; `/ws/call` refuses agent tokens). | | `assistant_id` | | The assistant the browser will talk to. | | `metadata` | | Up to 50 key/value pairs (keys up to 40 characters, values up to 500) you attach to an object. Returned as sent. | | `origin` | | The only website the token works from (`https://shop.example`): the browser’s Origin must match. Strongly recommended. http is accepted for localhost only. | | `ttl_s` | | How long the token can be used to start the call, in seconds (300–900, default 300). The call itself may run longer. | * cURL ```sh curl -X POST https://api.morevoice.ai/v1/client_tokens \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "assistant_id": "asst_8tRPaZp5hLMbrGqdJ9AmNa", "metadata": { "crm_contact_id": "0031x00000AbCdE" }, "origin": "https://shop.example", "ttl_s": 300 }' ``` * Node.js ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // MOREVOICE_API_KEY from the environment const clientToken = await mv.clientTokens.create({ assistant_id: "asst_8tRPaZp5hLMbrGqdJ9AmNa", metadata: { crm_contact_id: "0031x00000AbCdE", }, origin: "https://shop.example", ttl_s: 300, }); console.log(clientToken); ``` * Python ```python from morevoice import MoreVoice client = MoreVoice() # MOREVOICE_API_KEY from the environment client_token = client.client_tokens.create({ "assistant_id": "asst_8tRPaZp5hLMbrGqdJ9AmNa", "metadata": { "crm_contact_id": "0031x00000AbCdE", }, "origin": "https://shop.example", "ttl_s": 300, }) print(client_token) ``` ### Start a web call `POST /v1/web_calls` · scope `web_calls:write` · [API reference](/api/operations/web_calls_create/) From the browser with a publishable key (`mv_live_pk_…`), or from your server with a secret key: returns the call ID and a single-use client token for `/ws/call`. With a publishable key the assistant must be public, the page must be one of the key’s allowed origins (and of the assistant’s widget origins, when it lists any), and each visitor may start 10 web calls a minute. The org’s concurrent-call quota applies. | Field | Required | What it does | | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------- | | `assistant_id` | Yes | The assistant to talk to. With a publishable key it must be public (widget settings). | | `metadata` | | Up to 50 key/value pairs (keys up to 40 characters, values up to 500) you attach to an object. Returned as sent. | * cURL ```sh curl -X POST https://api.morevoice.ai/v1/web_calls \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "assistant_id": "asst_8tRPaZp5hLMbrGqdJ9AmNa", "metadata": { "page": "/pricing" } }' ``` * Node.js ```ts import MoreVoice from "@morevoice/sdk"; const mv = new MoreVoice(); // MOREVOICE_API_KEY from the environment const webCall = await mv.webCalls.create({ assistant_id: "asst_8tRPaZp5hLMbrGqdJ9AmNa", metadata: { page: "/pricing", }, }); console.log(webCall); ``` * Python ```python from morevoice import MoreVoice client = MoreVoice() # MOREVOICE_API_KEY from the environment web_call = client.web_calls.create({ "assistant_id": "asst_8tRPaZp5hLMbrGqdJ9AmNa", "metadata": { "page": "/pricing", }, }) print(web_call) ``` ## Build your own call UI `MoreVoiceCall` is the button without the button: under 8 kB gzipped, no dependencies, with typed events. Use it for a call screen of your own, or inside a framework component: call.ts ```ts import { MoreVoiceCall, MoreVoiceCallError } from "@morevoice/web"; const call = new MoreVoiceCall(); call.on("answer", ({ callId }) => console.log("call", callId)); call.on("state", ({ state }) => console.log("assistant is", state)); call.on("transcript", (line) => { if (line.final) console.log(`${line.role}: ${line.text}`); }); call.on("ended", ({ reason }) => console.log("ended:", reason)); call.on("error", ({ code, message }) => console.warn(code, message)); document.querySelector("#talk")?.addEventListener("click", async () => { try { await call.start({ server: "https://voice.example.com", assistantId: "3cYbE6uYvGkH8w4ZK1rTqd", metadata: { page: location.pathname } }); } catch (err) { if (err instanceof MoreVoiceCallError && err.code === "mic_denied") alert("Allow the microphone to talk to us."); } }); document.querySelector("#mute")?.addEventListener("click", () => call.mute(!call.muted)); document.querySelector("#hangup")?.addEventListener("click", () => call.hangup()); ``` `sendDtmf("1")` presses a key for an IVR or a flow’s keypad menu. `localStream` and `remoteStream` give you the audio for a level meter, and `audio.output` in `start()` plays the assistant through an `