Real-time events: follow calls as they happen
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. MoreVoice
POSTs 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. You list past events with
GET /v1/events(kept 30 days). Best for catching up after downtime, reconciling and backfills. - 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 and the same event types, so one handler serves all three.
Live events over webhooks
Section titled “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 |
A final line of the transcript: the caller’s or the assistant’s. |
call.tool_called |
An assistant’s tool call finished, with its name, arguments and result or error. |
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:
# 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" }'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 deliveriesimport 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 deliveriesA 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
Section titled “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:
# 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"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);}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
Section titled “Event streams”Stream a call’s live events
Section titled “Stream a call’s live events”GET /v1/calls/{id}/events · scope calls:read · API reference
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_ |
Resume after this event, for clients that cannot send the Last-Event-ID header (the header wins when both are sent). |
curl -N https://api.morevoice.ai/v1/calls/call_7Hk2Lm9Qp/events \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Accept: text/event-stream"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);}import jsonimport 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
retry: 3000
id: call_8tRPaZp5hLMbrGqdJ9AmNa:1event: call.state_changeddata: {"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:2event: transcript.finaldata: {"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
Section titled “Stream a call’s live transcript”GET /v1/calls/{id}/transcript/stream · scope calls:read · API reference
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_ |
Resume after this event, for clients that cannot send the Last-Event-ID header (the header wins when both are sent). |
curl -N https://api.morevoice.ai/v1/calls/call_7Hk2Lm9Qp/transcript/stream \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Accept: text/event-stream"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);}import jsonimport 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
retry: 3000
id: call_8tRPaZp5hLMbrGqdJ9AmNa:2event: transcript.partialdata: {"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:3event: transcript.finaldata: {"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
Section titled “Open a CLI listen session”GET /v1/cli/listen · scope events:read · API reference
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://<name> 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_ |
true: this session answers tool calls — tools whose URL is cli://<name> 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_ |
Resume after this event (evt_…), for clients that cannot send Last-Event-ID. |
curl -N https://api.morevoice.ai/v1/cli/listen \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Accept: text/event-stream"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);}import jsonimport 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
retry: 3000
event: cli.sessiondata: {"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_3fT9kq2ZpLr8YwVbN1cX0aevent: webhookdata: {"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_calldata: {"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
Section titled “Stream events as they happen”GET /v1/events/stream · scope events:read · API reference
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_ |
Resume after this event, for clients that cannot send the Last-Event-ID header (the header wins when both are sent). |
curl -N https://api.morevoice.ai/v1/events/stream \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Accept: text/event-stream"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);}import jsonimport 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
retry: 3000
id: evt_3fT9kq2ZpLr8YwVbN1cX0aevent: call.endeddata: {"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
Section titled “Stream request logs as they are written”GET /v1/request_logs/stream · scope api_keys:read · API reference
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_ |
Only requests made with this API key (key_…). |
request_ |
Only the request with this X-Request-Id (req_…). |
operation_ |
Only this operation (calls_create). |
curl -N https://api.morevoice.ai/v1/request_logs/stream \ -H "Authorization: Bearer $MOREVOICE_API_KEY" \ -H "Accept: text/event-stream"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);}import jsonimport 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
retry: 3000
id: rlog_4Gk2LmN9pQ4rS6tV8wX0yZevent: request_logdata: {"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
Section titled “Working with streams”The streams are standard Server-Sent Events, 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 of its own.
- Resume, don’t restart. Each event has an
id. When the connection drops, reconnect with the last ID you processed in theLast-Event-IDheader (orlast_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.