# 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](https://docs.morevoice.ai/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](https://docs.morevoice.ai/webhooks/setup/#the-event-envelope) and the same [event types](https://docs.morevoice.ai/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`](https://docs.morevoice.ai/webhooks/events/#transcript.final) | A final line of the transcript: the caller's or the assistant's. |
| [`call.tool_called`](https://docs.morevoice.ai/webhooks/events/#call.tool_called) | An assistant's tool call finished, with its name, arguments and result or error. |
| [`call.dtmf`](https://docs.morevoice.ai/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**

```ts title="live-webhook-endpoint.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**

```python title="live_webhook_endpoint.py"
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**

```ts title="catch-up-events.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**

```python title="catch_up_events.py"
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](https://docs.morevoice.ai/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](https://docs.morevoice.ai/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](https://docs.morevoice.ai/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://<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_tools` | `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_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](https://docs.morevoice.ai/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](https://docs.morevoice.ai/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](https://docs.morevoice.ai/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.
