# Events and webhooks for Octo integrations

> Subscribe to workspace state changes via outbound webhooks or the GET /v1/events SSE feed — including changes your agent did not initiate.

Most API calls let an agent observe a turn it *started*. Events let an agent
**listen** for workspace state changes it did **not** start — a turn completing,
a confirmation resolving, a run finishing — without polling.

There are two complementary surfaces. Use either or both:

1. **Outbound webhooks** — register an endpoint; we POST a signed, retried
   delivery when a subscribed event fires.
2. **`GET /v1/events`** — a long-lived Server-Sent Events stream of the same
   events, with a resume cursor, for agents that prefer to hold a connection
   open rather than host an endpoint.

Both carry the same events, and all payloads are snake_case. The stream sends the `2026-10-01` envelope, or v2 with `?api_version=v2`; a webhook endpoint receives the format its `api_version` names ([Payload versions](/platform-api/events/#payload-versions)).

## Event catalog

| `type` | Fires when | `data` fields |
| --- | --- | --- |
| `conversation.created` | A new conversation is opened | `conversation_id`, `channel`, `contact_id?` |
| `turn.completed` | An agent turn finishes | `conversation_id`, `surface`, `reply_preview`, `finish_reason`, `model` |
| `confirmation.requested` | The agent parks a risky action for confirmation | `action_id`, `action_code`, `summary`, `conversation_id?` |
| `confirmation.resolved` | A parked action is approved/rejected/expired | `action_id`, `action_code`, `resolution` |
| `spend.recorded` | Billable spend is recorded (amounts in credits) | `amount_credits`, `category`, `conversation_id?`, `balance_credits?` |
| `run.completed` | A workflow run finishes | `run_id`, `workflow_id`, `status` |
| `message.received` | An SMS or WhatsApp message reaches one of your numbers (after STOP/START handling) | `message_id`, `conversation_id`, `contact_id`, `channel_id`, `from`, `to`, `body`, `media[]`, `media_failed`, `received_at`, `campaign_id`, `keyword`, `channel` (`sms` or `whatsapp`; absent on older events, meaning `sms`), `quick_reply` (a WhatsApp button tap's `payload`, else `null`) |
| `message.sent` | A text left for the carrier: an AI agent's or a teammate's reply, or an API send | `message_id`, `conversation_id`, `author`, `agent_id`, `to`, `from`, `channel_id`, `body`, `send_ids[]`, `sent_at` |
| `message.status_updated` | A text's or a WhatsApp message's status changed | `channel`, `status`, `send_id`, `campaign_id`, `message_id`, `to`, `from`, `state`, `display_status`, `segments`, `billed_credits`, `error`, `sent_at`, `delivered_at`, `updated_at` (SMS). WhatsApp: `channel`, `status`, `message_id`, `conversation_id`, `campaign_id`, `to`, `from`, `error`, `template`, `updated_at` |
| `campaign.started` | A campaign was started (SMS: its send is confirmed) | `campaign_id`, `campaign_short_id`, `name`, `campaign_type`, `status`, `started_at` |
| `campaign.paused` | A campaign stopped sending: a teammate, out of credits, its credit limit, the carrier's daily limit, too many failures, the carrier refusing the number, a needed review, a card for the trial number, or a problem on our side | the common fields, `reason`, `message`, `paused_at`, `resumes_at` |
| `campaign.resumed` | A paused campaign is sending again | the common fields, `resumed_at`, `previous_reason` |
| `campaign.completed` | A campaign finished | the common fields, `completed_at`, `totals`, `billed_credits` |
| `campaign.canceled` | A campaign was canceled | the common fields, `canceled_at`, `in_flight`, `not_yet_sent` |
| `campaign.waiting` | An SMS campaign is outside its sending hours and waits for them to open (not a pause) | the common fields, `reason`, `waiting_at`, `resumes_at`, `tz` |
| `campaign.window_opened` | Its sending hours started again and it goes on sending | the common fields, `opened_at`, `tz` |
| `meta_response.completed` | A background `POST /v1/responses` request finished or failed | `response_id`, `conversation_id`, `status`, `error` |
| `meta_chat.updated` | A chat with Octo got a new question and reply, and was saved | `conversation_id`, `surface`, `title`, `preview`, `message_count` |
| `call.ringing` | An outbound phone call started ringing | `call_id`, `conversation_id`, `direction`, `from`, `to`, `agent_id`, `campaign_id`, `status`, `occurred_at` |
| `call.answered` | A phone call was answered (by a person or a machine) | the call fields, `answered_at` |
| `call.completed` | A phone call ended (`status` completed, voicemail, no_answer or busy) | the call fields, `answered_at`, `outcome`, `duration_sec`, `ended_at`, `disconnect_reason` |
| `call.failed` | A phone call failed (a system or carrier failure) | the same as `call.completed` |

The three `message.*` events, the seven `campaign.*` events, the four
`call.*` events, `turn.completed`, `meta_response.completed` and
`meta_chat.updated` are emitted today;
[Receive webhooks](/platform-api/events/) says what each `message.*`,
`campaign.*` and `call.*` event promises. The other types are published on the same log, webhooks and
stream as their emitters roll out: you can subscribe now, but don't build a
hard dependency on them until the [changelog](/changelog/) announces them.
`message.status_updated` for campaign texts, and the `call.*` events of a
campaign's calls, reach only endpoints that name the type; `"*"` does not
include them. `"*"` includes every other type. The `campaign.*` events are a handful per
campaign, so `"*"` includes them. They are delivered at least once and can, very rarely,
be missing after a failure on our side or repeat after a race: act on the
`status` in the payload, not on the number of events, and read
`GET /v1/campaigns/{id}` when it matters (it is the source of truth). A
status event we could not write at the time is written later, once (usually
10 to 15 minutes after the change); it carries the time it was written, not
the time of the change, and a late `campaign.resumed` has
`previous_reason: null`.

### Octo events

These two used to be sent by the older integration webhook
(`/api/users/me/integrations`), which is retired: those routes now answer
`410 integration_webhook_retired`. Subscribe to the types on a webhook
endpoint, or read them from `/v1/events`, like any other event. The payloads
are snake_case, unlike the old webhook's.

- **`meta_response.completed`**: a background response finished (see
  [`background` mode](/openai-api/statefulness/#background-mode-responses-api)).
  `response_id` is the `resp_…` id `GET /v1/responses/{id}` takes, `status` is
  `completed` or `failed`, and `error` is `null` when it completed, otherwise
  `{ "type", "code", "message" }`, the same error the stored response carries
  (`code` is `null` when there is none). A response failed by our restart
  sweep sends it too.
- **`meta_chat.updated`**: one per saved Octo turn, wherever it came from
  (`surface`: `web`, `api`, `mcp`, or a channel such as `email`). `title` is
  the chat's title, `preview` the start of Octo's reply (about 120
  characters), `message_count` the number of messages saved in the chat.

### Envelope

Every event — over a webhook or the SSE feed — has this shape:

```json
{
  "id": "evt_9f8c…",
  "type": "turn.completed",
  "workspace_id": "5b1d2c7e-…",
  "created_at": "2026-07-15T22:31:04.001Z",
  "seq": 48213,
  "data": { "conversation_id": "cv-…", "surface": "api", "reply_preview": "On it —…", "finish_reason": "stop", "model": "octo" }
}
```

This is the `2026-10-01` envelope, which the SSE feed and endpoints that
existed before release v1.84.0.0 receive. A new endpoint receives the v2 envelope (`object`,
`api_version`, `livemode`, and `data` in the `/v1` words): see
[Payload versions](/platform-api/events/#payload-versions).

`workspace_id` is the workspace's UUID. `seq` is the event's position in the
workspace's log: the `id:` of its `/v1/events` frame, and what to order
webhooks by (see [Handle events](/platform-api/events/#handle-events)).

## Outbound webhooks

<a id="register-an-endpoint"></a><a id="change-the-secret"></a><a id="send-a-test-event"></a><a id="verify-a-delivery"></a><a id="retries"></a><a id="order-and-repeats"></a>

Webhooks for these events work exactly as for the messaging events, and are
documented once, in [Receive webhooks](/platform-api/events/):

| to | see |
|---|---|
| register an endpoint (`POST /v1/webhooks`, the `secret` shown once) | [Register an endpoint](/platform-api/events/#register-an-endpoint) |
| list, change, pause, delete, rotate the secret, send a test event | [Manage endpoints](/platform-api/events/#manage-endpoints) |
| check the `webhook-signature` of a delivery | [Verify the signature](/platform-api/events/#verify-the-signature) |
| the retry schedule, and when an endpoint is disabled | [Retries](/platform-api/events/#retries) |
| repeats and ordering by `seq` | [Handle events](/platform-api/events/#handle-events) |
| the v2 or `2026-10-01` format an endpoint receives | [Payload versions](/platform-api/events/#payload-versions) |

Owners and admins can also manage endpoints in the dashboard, under
**Settings → API Keys & Webhooks**, where the last 7 days of deliveries show
what was sent and what your server answered.

## `GET /v1/events` (SSE)

<a id="resume"></a><a id="filter"></a><a id="limits"></a>

The same events as a Server-Sent Events stream, with a resume cursor, for an
agent that would rather hold a connection open than host an endpoint. Pass
`?types=turn.completed,run.completed` to receive only those. Connecting,
resuming, the 7-day log and the stream's limits:
[The event stream](/platform-api/events/#the-event-stream).