# Event types

> Every webhook event Ody can send, when it fires, and the fields in its data object.

Source: https://dev.ody.co/webhooks/events

Subscribe an endpoint to individual types, or to `["*"]` for every current and future type. `GET /v1/api/webhook-events` returns the live catalog, and the SDKs export it as [`WEBHOOK_EVENT_TYPES`](https://dev.ody.co/sdks/typescript#webhooks) with a typed `data` shape per event.

## Summary

| Event | Fires when |
|---|---|
| [`message.received`](#message-received) | An inbound SMS/MMS is stored |
| [`message.delivered`](#message-delivered) | An outbound message is accepted by the carrier, and again on each delivery-status update |
| [`message.failed`](#message-failed) | The carrier reports an outbound message failed |
| [`call.ringing`](#call-ringing) | An inbound call reaches one of your numbers, before anyone answers |
| [`call.answered`](#call-answered) | Someone picks up: a teammate, the AI agent, or the person you called |
| [`call.forwarded`](#call-forwarded) | An inbound call is sent on to an outside phone number |
| [`call.completed`](#call-completed) | An answered call ends (by a teammate or the AI agent) |
| [`call.missed`](#call-missed) | An inbound call ends unanswered |
| [`call.recording.completed`](#call-recording-completed) | A call recording is stored |
| [`call.voicemail.completed`](#call-voicemail-completed) | A voicemail is stored |
| [`call.transcript.completed`](#call-transcript-completed) | A call or voicemail transcript is ready |
| [`call.summary.completed`](#call-summary-completed) | The AI summary of a call is ready |
| [`call.flow.started`](#call-flow-events) | An inbound call enters an IVR call flow |
| [`call.flow.completed`](#call-flow-events) | A call flow reaches its final step |
| [`call.flow.aborted`](#call-flow-events) | A call flow stops early |
| [`contact.updated`](#contact-updated) | A contact is created or edited in the app, the API or MCP |
| [`contact.deleted`](#contact-deleted) | A contact is deleted |

## The life of a call

Call events arrive in this order. The first three are **live** signals, sent while the call is still going on; the rest describe the finished call and carry a `conversationId`.

| Inbound call | Outbound call |
|---|---|
| `call.ringing` → `call.answered` *or* `call.forwarded` → `call.completed` / `call.missed` → `call.recording.completed`, `call.transcript.completed`, `call.summary.completed` | `call.answered` → `call.completed` / `call.missed` → `call.recording.completed`, `call.transcript.completed` |

> **Correlating live and finished events:**
`call.ringing`, `call.answered` and `call.forwarded` fire before the call has a timeline entry, so they have no `conversationId`. Use `telnyxCallId` to join them to the later `call.completed` or `call.missed`, which carries both IDs.

Most events share these `data` fields:

- `conversationId` *uuid* — The thread. Fetch it with [`GET /conversations/:id`](https://dev.ody.co/api/conversations#retrieve-a-conversation).
- `inboxId` *uuid* — The inbox the thread belongs to.
- `activityId` *uuid* — The message, call or voicemail inside the thread.
- `numberE164` *string* — Your Ody number involved.
- `telnyxCallId` *string* — For call events: the carrier's call ID. Join live and finished events on it, and quote it when talking to support.

## Messages

### message.received

An inbound SMS or MMS was stored in a thread. Fetch the body and media from the conversation.

```json
{
  "type": "message.received",
  "data": {
    "numberE164": "+15125550100",
    "conversationId": "c4f1a2b3-…",
    "inboxId": "1e2d3c4b-…",
    "activityId": "a1b2c3d4-…",
    "contactId": "8b1d6f3e-…"
  }
}
```

### message.delivered

Fires when an outbound message (from the app, the API, MCP or the AI) is **accepted by the carrier**, and again each time the carrier reports progress. Status only ever moves forward, so a late `sent` never overwrites `delivered`.

- `deliveryStatus` *string* — Present on carrier status updates, for example `sent` or `delivered`. Absent on the first event at send time.

```json
{
  "type": "message.delivered",
  "data": { "conversationId": "c4f1…", "inboxId": "1e2d…", "activityId": "5e7f…", "deliveryStatus": "delivered" }
}
```

### message.failed

The carrier reported that an outbound message could not be delivered (for example, the number is unreachable or the recipient opted out).

- `deliveryStatus` *string* — The carrier's failure status, for example `sending_failed` or `delivery_failed`.

## Calls

### call.ringing

An inbound call reached one of your numbers and your phones (or the AI agent, or the call flow) are about to take it. This is the earliest signal Ody has about a call: use it to open the caller's record in your CRM while the phone is still ringing.

Sent once per inbound call, before routing. Not sent for outbound calls, or when the workspace is suspended.

- `numberE164` *string* — The Ody number that was called.
- `from` *string | null* — The caller, in E.164. `null` when the caller withheld their number.
- `callerName` *string | null* — The caller name the carrier delivered (CNAM), if any.
- `telnyxCallId` *string | null* — Join key for the later `call.answered` / `call.completed` / `call.missed`.
- `direction` *string* — Always `inbound`.
- `routedBy` *string* — `number` when the number's own settings route the call, `flow` when a [call flow](https://dev.ody.co/switchboard/overview) (phone menu) takes it. Flow calls also send `call.flow.started`.

```json
{
  "type": "call.ringing",
  "data": {
    "numberE164": "+15125550100",
    "from": "+15125559999",
    "callerName": "ADA LOVELACE",
    "telnyxCallId": "v3:6aL…",
    "direction": "inbound",
    "routedBy": "number"
  }
}
```

### call.answered

Someone picked up. `answeredBy` tells you who, and the other fields depend on it:

| `answeredBy` | Meaning | Extra fields |
|---|---|---|
| `agent` | The AI agent took an inbound call. Sent the moment the agent picks up. | `agentId` |
| `member` | A teammate answered an inbound call on the Ody app. | `memberUid` — the teammate's user ID |
| `callee` | The person you dialed answered an outbound call placed from the app. | `conversationId`, `inboxId`, `activityId` — the call already has its timeline entry |

- `answeredBy` *string* — `agent`, `member` or `callee`.
- `direction` *string* — `inbound` or `outbound`.
- `numberE164` *string | null* — Your Ody number. For `member` answers it is included when the workspace has one number or the teammate has an assigned line; otherwise it is omitted rather than guessed, and `call.completed` carries the definitive number.
- `from` *string | null* — The caller (inbound).
- `telnyxCallId` *string | null* — Join key.

```json
{
  "type": "call.answered",
  "data": {
    "numberE164": "+15125550100",
    "from": "+15125559999",
    "telnyxCallId": "v3:6aL…",
    "direction": "inbound",
    "answeredBy": "member",
    "memberUid": "uid_ana"
  }
}
```

Calls the AI agent places for you through [`POST /calls`](https://dev.ody.co/api/calls) do not send `call.answered`; their first event is `call.completed`.

### call.forwarded

An inbound call is being connected to an outside phone number: the number is set to forward, the caller pressed a menu key that forwards, or a call flow reached a forward step. No `call.answered` follows for the outside line. When the call ends you get `call.completed` (it was picked up) or `call.missed`.

- `forwardedTo` *string* — The outside number the call went to, in E.164.
- `numberE164` *string*, `from` *string | null*, `telnyxCallId` *string | null*, `direction` *string* — As in `call.ringing`.

```json
{
  "type": "call.forwarded",
  "data": {
    "numberE164": "+15125550100",
    "from": "+15125559999",
    "telnyxCallId": "v3:6aL…",
    "direction": "inbound",
    "forwardedTo": "+15125550001"
  }
}
```

### call.completed

An answered call ended. When the AI agent handled the call, `data.answeredBy` is `"agent"`. The recording, transcript and summary follow as separate events.

```json
{
  "type": "call.completed",
  "data": {
    "conversationId": "c4f1…",
    "inboxId": "1e2d…",
    "activityId": "9d8c…",
    "numberE164": "+15125550100",
    "telnyxCallId": "v3:6aL…",
    "answeredBy": "agent"
  }
}
```

### call.missed

An inbound call ended without being answered (no voicemail, or before one was left). Same fields as `call.completed`, without `answeredBy`.

### call.recording.completed

A call recording was stored on the call activity. The signed recording URL is on the activity, which you get from [`GET /conversations/:id`](https://dev.ody.co/api/conversations#retrieve-a-conversation).

### call.voicemail.completed

A caller left a voicemail. The audio is on the activity; the transcript follows as `call.transcript.completed`.

### call.transcript.completed

A speaker-labelled transcript is ready for a call or voicemail. Read it from the activity's `callTranscript`.

### call.summary.completed

The AI summary of a call is ready. For calls the AI agent handled, `data.summaryText` carries the summary inline. For other calls, read it from the activity.

```json
{
  "type": "call.summary.completed",
  "data": {
    "conversationId": "c4f1…",
    "activityId": "9d8c…",
    "telnyxCallId": "v3:6aL…",
    "summaryText": "Ada asked to move Friday's appointment to Monday 10am; confirmed and texted the new time."
  }
}
```

## Call flow events

Fired by IVR call flows (phone menus) built in the app. Use them to measure menu drop-off or trigger follow-ups.

- `numberE164` *string* — The number that was called.
- `flowId` *string*, `flowName` *string*, `flowVersion` *integer* — Which flow, and which published version.
- `telnyxCallId` *string | null*
- `terminal` *string* — `completed` only: the final step type, for example `ring`, `voicemail`, `forward`, `astra` or `hangup`.
- `reason` *string* — `aborted` only: why the flow stopped.
- `nodeId` *string* — `completed` and `aborted`: the step it ended on.

## Contacts

Contact events carry only the contact's ID, never the record itself, so an endpoint that is only meant to learn "something changed" is never sent names and phone numbers. Fetch the current state with [`GET /contacts/:id`](https://dev.ody.co/api/contacts#retrieve-a-contact).

They fire for deliberate changes made in the app, through the API or through MCP. They do **not** fire for the placeholder contacts Ody creates automatically when an unknown number calls or texts, nor for bulk CSV imports and CRM syncs: a 2,000-row import would otherwise send 2,000 deliveries. Read the result of an import through the Contacts API instead.

### contact.updated

A contact was created or edited.

- `contactId` *uuid* — The contact.
- `change` *string* — `created` or `updated`.
- `fields` *string[]* — `updated` only: the top-level fields the caller sent (for example `["lastName", "tags"]`). Sending a field with its existing value still lists it, so treat this as "what was touched", not a diff.

```json
{
  "type": "contact.updated",
  "data": { "contactId": "8b1d6f3e-…", "change": "updated", "fields": ["lastName", "tags"] }
}
```

### contact.deleted

A contact was deleted. `GET /contacts/:id` now returns `404`, so remove or archive your copy.

```json
{
  "type": "contact.deleted",
  "data": { "contactId": "8b1d6f3e-…" }
}
```

## The test event

[`POST /webhooks/:id/test`](https://dev.ody.co/api/webhook-endpoints#send-a-test-delivery) sends `{"type": "ping", "data": {"message": "Test delivery from Ody"}}`, signed like a real event. Its ID starts with `evt_test_`. `ping` isn't in the catalog and you can't subscribe to it, so make sure your handler doesn't error on unknown types.
