# Receive webhooks

> Register a webhook endpoint for message statuses, replies, calls and campaign changes, verify each delivery's signature, handle retries, and replay from the /v1/events stream.

Rather than poll each message, call or campaign, get a signed POST the
moment it changes.

**You need:** a public `https://` URL, and an API key whose owner is an owner
or admin of the workspace (`403 insufficient_role` otherwise). Every field:
[Webhooks](/platform-api/reference/webhooks/) and
[Events](/platform-api/reference/events/).

## Register an endpoint

```bash
curl -X POST https://api.omniocto.com/v1/webhooks -H "Authorization: Bearer $OMNI_OCTO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/octo", "event_types": ["message.received", "message.sent", "message.status_updated", "campaign.paused", "campaign.completed"] }'
```

The answer is `201 Created` with the endpoint and its `secret`, which is shown
only this once:

```json
{ "id": "whe-0000000001", "object": "webhook_endpoint", "url": "https://example.com/octo",
  "event_types": ["message.received", "message.sent", "message.status_updated", "campaign.paused", "campaign.completed"],
  "description": null, "enabled": true, "api_version": "v2",
  "created_at": "2026-10-14T17:02:11.000Z", "updated_at": "2026-10-14T17:02:11.000Z",
  "secret": "whsec_…" }
```

Store the `id` and the `secret`. The endpoint receives events from then on,
each first tried within about 10 seconds, in the [v2 format](#payload-versions).
Check it right away with `POST /v1/webhooks/{id}/test`, which sends one
signed `webhook.test` event. Redirects are not followed: register the exact
URL that answers, `https` and trailing slash included.

## Events

Name the types you want in `event_types`; leave it out for `["*"]`, every
type. **`"*"` leaves out the status events of campaign texts and the call
events of campaign calls**, because a large campaign sends a few per
recipient: name `message.status_updated` or the `call.*` types to get them.
`["*", "message.status_updated"]` gets everything.

| you want | subscribe to |
|---|---|
| delivery receipts | `message.status_updated` |
| replies and inbound messages (SMS and WhatsApp) | `message.received` |
| what your agent or team texted (SMS only) | `message.sent` |
| how a call went | `call.ringing`, `call.answered`, `call.completed`, `call.failed` |
| a campaign that started, paused or finished | `campaign.started`, `campaign.paused`, `campaign.resumed`, `campaign.completed`, `campaign.canceled`, `campaign.waiting`, `campaign.window_opened` |

Filter `message.received` on `data.channel` (`sms` or `whatsapp`); see
[Hear replies](/platform-api/whatsapp/#hear-replies).

<a id="channel-coverage"></a>

| channel | what you get |
|---|---|
| SMS | `message.status_updated`, `message.received`, `message.sent` |
| WhatsApp | `message.status_updated`, `message.received` (not `message.sent`) |
| calls | `call.ringing`, `call.answered`, `call.completed`, `call.failed` |
| campaigns (every channel) | the `campaign.*` types above; plus their texts' and calls' own events when you name those types |

Every type and its fields: [Events](/platform-api/reference/events/).

## Verify the signature

Webhooks use the [Standard Webhooks](https://www.standardwebhooks.com/) scheme.
Every delivery has `webhook-id`, `webhook-timestamp` and `webhook-signature`
(`v1,<base64>`): HMAC-SHA256 over `${id}.${timestamp}.${rawBody}` with the
base64-decoded part of your secret after `whsec_`. Verify the raw bytes,
before you parse them: a body parsed and serialized again no longer matches.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, id: string, ts: string, rawBody: string, header: string) {
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = `v1,${createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64")}`;
  const want = Buffer.from(expected);
  return header.split(" ").some((sig) => {
    const got = Buffer.from(sig);
    // Compare byte lengths, not string lengths: timingSafeEqual throws on a
    // length mismatch, and a non-ASCII signature has more bytes than characters.
    return got.length === want.length && timingSafeEqual(got, want);
  });
}
```

A whole receiver, in Express. `webhook-timestamp` is unix seconds; read the
body raw (`express.raw`, not `express.json()`):

```ts title="Express receiver"
app.post("/octo", express.raw({ type: "application/json" }), (req, res) => {
  const id = req.header("webhook-id") ?? "";
  const ts = req.header("webhook-timestamp") ?? ""; // unix seconds
  const sig = req.header("webhook-signature") ?? "";
  const body = req.body.toString("utf8");
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
  if (!verify(process.env.OCTO_WEBHOOK_SECRET ?? "", id, ts, body, sig)) return res.sendStatus(400);
  res.sendStatus(204); // answer first, then do the work
  handle(JSON.parse(body));
});
```

```python title="Python"
import base64
import hashlib
import hmac


def verify(secret: str, msg_id: str, ts: str, raw_body: bytes, header: str) -> bool:
    key = base64.b64decode(secret.removeprefix("whsec_"))
    digest = hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
    expected = ("v1," + base64.b64encode(digest).decode()).encode()
    # Compare bytes: compare_digest refuses a str with non-ASCII characters.
    return any(hmac.compare_digest(sig.encode(), expected) for sig in header.split(" "))
```

Compare in constant time, never with `===`. Reject a `webhook-timestamp` more
than 5 minutes old or ahead. The `standardwebhooks` and `svix` libraries
verify the same scheme.

## Handle events

- **Answer 2xx within 10 seconds**, then do the work. Anything else is
  retried.
- **Answer 2xx to a `type` you do not handle**, and ignore fields you do not
  know: new ones are added.
- **Drop repeats by the envelope `id`** (the `webhook-id` header). Each
  change is one event, delivered at least once.
- **Order by `seq`**, the event's position in your workspace's log.
  Deliveries can arrive out of order; one with a lower `seq` than you already
  applied for that object is older news.
- **Treat events as a prompt.** When it matters, read the truth with
  `GET /v1/messages/{id}`, `GET /v1/calls/{id}` or `GET /v1/campaigns/{id}`.

What each event promises: [the event rules](/platform-api/reference/events/#rules).

## Payload versions

Every endpoint has an `api_version`, the format it receives. **`v2`** uses
the words and ids of the `/v1` objects. **`2026-10-01`** is the legacy format
([reference](/platform-api/reference/events-2026-10-01/)).
Signing, retries, ordering and `webhook-id` are the same in both.

| Where | Default | Choose with |
|---|---|---|
| A new endpoint | `v2` | `api_version` on `POST /v1/webhooks` or `PATCH /v1/webhooks/{id}` |
| An endpoint made before v1.84.0.0 | `2026-10-01` | `PATCH` with `{ "api_version": "v2" }` |
| The event stream | `2026-10-01` | `?api_version=v2`; the `Octo-Event-Version` header names it |

Use v2 in new code. A `2026-10-01` body has no `api_version` field.

<a id="event-words-and-v1-words"></a>

The v2 envelope is `id`, `object` (`"event"`), `type`, `api_version`
(`"v2"`), `livemode`, `workspace_id`, `created_at`, `seq` and `data`.
`livemode` is `true` until test mode exists; check it rather than assume it.

**The id rule.** In v2, a field named `<thing>_id` holds what
`GET /v1/<things>/{id}` takes, and the other form sits beside it:
`campaign_id` is the UUID with `campaign_short_id` beside it, `call_id` the
UUID with `call_short_id`, `agent_id` the `ag-…` with `agent_uuid`,
`channel_id` the `ch-…` with `channel_uuid`. `sender_id` is the sender's
`GET /v1/senders` id.

An AI agent's reply that went out as two texts:

```json
{
  "id": "evt_00000000000000000000000000000009",
  "object": "event",
  "type": "message.sent",
  "api_version": "v2",
  "livemode": true,
  "workspace_id": "00000000-0000-4000-8000-000000001113",
  "created_at": "2026-10-01T12:00:00.123Z",
  "seq": 704,
  "data": {
    "message_id": "00000000-0000-4000-8000-000000000009",
    "message_ids": ["00000000-0000-4000-8000-000000000009", "00000000-0000-4000-8000-00000000000a"],
    "channel": "sms",
    "conversation_id": "00000000-0000-4000-8000-00000000000b",
    "author": "ai_agent",
    "agent_id": "ag-0000000002",
    "agent_uuid": "00000000-0000-4000-8000-00000000000c",
    "from": "+14155550199",
    "to": "+14155550100",
    "channel_id": "ch-0000000001",
    "channel_uuid": "00000000-0000-4000-8000-00000000000d",
    "sender_id": "pn-0000000001",
    "body": "A long reply that went out in two parts.",
    "sent_at": "2026-10-01T12:00:00.000Z",
    "metadata": { "order_id": "A-1001" }
  }
}
```

Its second part was not delivered:

```json
{
  "id": "evt_0000000000000000000000000000000d",
  "object": "event",
  "type": "message.status_updated",
  "api_version": "v2",
  "livemode": true,
  "workspace_id": "00000000-0000-4000-8000-000000001113",
  "created_at": "2026-10-01T12:00:00.123Z",
  "seq": 708,
  "data": {
    "message_id": "00000000-0000-4000-8000-00000000000a",
    "channel": "sms",
    "status": "undelivered",
    "from": "+14155550199",
    "to": "+14155550100",
    "conversation_id": "00000000-0000-4000-8000-00000000000b",
    "campaign_id": "00000000-0000-4000-8000-00000000000e",
    "campaign_short_id": "ca-0000000002",
    "sender_id": "pn-0000000001",
    "segments": 1,
    "billed_credits": 0.83,
    "error": {
      "code": "30007",
      "category": "carrier_filtered",
      "message": "The carrier filtered the message as spam.",
      "action": "Change the wording or register the number, then send again."
    },
    "template": null,
    "sent_at": "2026-10-01T12:00:01.000Z",
    "delivered_at": null,
    "read_at": null,
    "updated_at": "2026-10-01T12:00:05.000Z",
    "metadata": { "order_id": "A-1001" }
  }
}
```

A campaign finished:

```json
{
  "id": "evt_00000000000000000000000000000013",
  "object": "event",
  "type": "campaign.completed",
  "api_version": "v2",
  "livemode": true,
  "workspace_id": "00000000-0000-4000-8000-000000001113",
  "created_at": "2026-10-01T12:00:00.123Z",
  "seq": 714,
  "data": {
    "campaign_id": "00000000-0000-4000-8000-00000000000e",
    "campaign_short_id": "ca-0000000002",
    "name": "October promo",
    "channel": "sms",
    "status": "completed",
    "completed_at": "2026-10-01T12:00:00.000Z",
    "counts": { "recipients": 100, "pending": 0, "sent": 90, "delivered": 80, "failed": 9, "skipped": 5, "replied": 7, "opted_out": 1 },
    "billed_credits": 74.7,
    "metadata": { "order_id": "A-1001" }
  }
}
```

### Upgrade an endpoint

Two ways, both reversible:

1. **Side by side.** Create a second endpoint on `v2` with the same
   `event_types`, at a new URL of yours (or the same URL, with a handler that
   tells the two apart by `api_version`). Switch to it, then delete the old
   one. While
   both exist each event arrives twice with the same `webhook-id`: dedupe on
   it.
2. **In place.** `PATCH /v1/webhooks/{id}` with `{ "api_version": "v2" }`.
   A delivery already in retry keeps its first format, so for up to about 28
   hours (the retry schedule) accept both (`api_version` is present only in
   v2).

## Retries

Deliveries follow the Standard Webhooks retry schedule: an immediate attempt,
then retries after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours,
10 hours and 10 hours: 8 attempts over about 27.6 hours. After the last one
the delivery is given up. An endpoint whose deliveries are given up 20 times
in a row is disabled, and its pending deliveries are dropped: re-enabling it
covers new events only. For a longer outage, replay from
[the event stream](#the-event-stream). To see which deliveries failed and
what your server answered, [list them](#see-what-was-delivered).

## Manage endpoints

| request | does |
|---|---|
| `GET /v1/webhooks` | lists your endpoints, newest first, without their secrets; page with `limit` and `after` |
| `GET /v1/webhooks/{id}` | reads one |
| `PATCH /v1/webhooks/{id}` | changes `url`, `event_types`, `enabled`, `description` or `api_version` |
| `DELETE /v1/webhooks/{id}` | removes it (`204`) |
| `POST /v1/webhooks/{id}/rotate-secret` | a new `secret`, shown once; the old one stops working at once |
| `POST /v1/webhooks/{id}/test` | sends one signed `webhook.test` event and answers what your server said |
| `GET /v1/webhooks/{id}/deliveries` | its deliveries of the last 7 days ([see what was delivered](#see-what-was-delivered)) |

- `event_types` **replaces** the list, it does not add to it: send the whole
  list you want. On `PATCH` an empty list subscribes to nothing; on create
  an empty or missing list means `["*"]`.
- `{ "enabled": false }` pauses an endpoint: it receives nothing meanwhile,
  and deliveries already queued for it are dropped. `{ "enabled": true }`
  turns it back on for new events and resets its failure count; replay the
  gap from the event stream.
- List before you create, so a restart does not subscribe the same URL
  twice. A workspace can have 500 endpoints.

The dashboard shows the same endpoints, with their recent deliveries, under
[Settings → API Keys & Webhooks](https://agent.omniocto.com/settings/api-keys).
Codes: [Errors → Webhooks](/platform-api/errors/#webhooks).

## See what was delivered

Each event sent to an endpoint is a delivery, kept **7 days**.
`status=problems` lists those retrying (`failed`) or `exhausted`, newest
first:

```bash
curl "https://api.omniocto.com/v1/webhooks/whe-0000000001/deliveries?status=problems" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

```json
{
  "object": "list",
  "data": [
    {
      "id": "00000000-0000-4000-8000-000000000001",
      "object": "webhook_delivery",
      "endpoint_id": "whe-0000000001",
      "event_id": "evt_7d1f0c2b9a8e4f6d",
      "event_type": "message.received",
      "seq": 1041,
      "status": "failed",
      "attempts": 3,
      "next_attempt_at": "2026-10-19T12:29:35.000Z",
      "response_status": 502,
      "error": { "code": "http", "message": "HTTP 502" },
      "api_version": "v2",
      "created_at": "2026-10-19T11:59:00.000Z",
      "updated_at": "2026-10-19T11:59:35.000Z",
      "last_attempt_at": "2026-10-19T11:59:35.000Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

- `?status=` also takes `pending`, `succeeded`, `failed` or `exhausted`.
  `problems` leaves out deliveries dropped untried (`endpoint_disabled`,
  `retention`). Page with `limit` (up to 100) and `after`.
- `GET /v1/webhooks/{id}/deliveries/{delivery_id}` adds `payload` (the body
  sent, `null` once the event left the log), the unsigned headers, and
  `response_excerpt` (the start of your reply).
- Nothing is re-sent on request: replay from
  [the event stream](#the-event-stream) with `?after=<seq - 1>` (add
  `&api_version=v2` for the v2 body).

Every field and error code:
[Webhooks reference](/platform-api/reference/webhooks/).

## The event stream

`GET /v1/events` is a Server-Sent Events stream of the same events, for a
client that cannot host an endpoint, and the way to replay what an endpoint
missed. It sends the `2026-10-01` format unless you ask for v2 with
`?api_version=v2`, whatever your endpoints' `api_version`
([Payload versions](#payload-versions)): v2 frames carry exactly the body a v2
endpoint receives.

### Connect

```bash
curl -N "https://api.omniocto.com/v1/events?api_version=v2&types=message.received,message.status_updated" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
```

```
id: 1041
event: message.received
data: {"id":"evt_…","object":"event","type":"message.received","api_version":"v2","livemode":true,"workspace_id":"…","created_at":"…","seq":1041,"data":{…}}
```

Each event frame's `id:` is the event's `seq` and `event:` its type.
Without `?types=`, campaign texts' status events and campaign calls' events
are left out, as with `"*"`. `?campaign=<id>`: one campaign's events only.

### Resume

- Reconnect with the `Last-Event-ID` header: the stream replays everything
  after it, in order, then tails new events. **`?after=<seq>` wins over
  `Last-Event-ID`**, so use it only on the first connect: an `EventSource`
  opened with `?after=` would replay from there on every reconnect.
- **With no cursor, the stream starts at the oldest event kept**, not at now:
  the first connect replays up to 7 days of events, and so does `?after=0`.
  An `after` that is not a whole number of 0 or more is
  `400 invalid_cursor`. To start from now, [poll](#poll-instead-of-stream)
  once.
- The log keeps **7 days**. An older cursor gets one `replay_truncated`
  frame, with no `id:` and no envelope, then the replay from the oldest event
  kept:

  ```
  event: replay_truncated
  data: {"after":1041,"oldest_retained_seq":52310,"retention_days":7}
  ```

  The events between `after` and `oldest_retained_seq` are gone: reconcile
  that gap with `GET` reads.

### Frames

- Three frames are not events and have no `id:`: `replay_truncated` (above),
  `ping` (empty `data`, about every 15 seconds: skip it, do not parse it)
  and `error` (the key's owner is no longer an owner or admin: `data` is the
  [error envelope](/platform-api/errors/) with `code: "insufficient_role"`,
  and the stream ends).
- On `?api_version=v2` only, an event that could not be rendered as v2 comes
  as a `render_error` frame, with the event's `seq` as its `id:` (a
  reconnect resumes after it) and `data` `{"event_id","type","seq"}` plus
  the ids the event had (`message_id`, `send_id`, `call_id`, `campaign_id`,
  `conversation_id`). Recover it with that resource's `GET`, or by opening
  the stream without `api_version` with `?after=<seq-1>&types=<type>`.

### Limits

- `?api_version` other than `2026-10-01` or `v2` is `400 invalid_request`;
  another query parameter is `400 unknown_parameter`, and one sent twice
  `400 invalid_request`
  ([codes](/platform-api/errors/#events-media-and-recordings)).
- A stream ends after an hour and when its key stops working; reconnect with
  `Last-Event-ID`. At most 5 open streams per key and 20 per workspace
  (`429 too_many_streams`).

### Poll instead of stream

`?wait=25` answers one JSON page, as soon as an event arrives:

```bash
curl -s "https://api.omniocto.com/v1/events?wait=25&api_version=v2&campaign=ca-0000000002&types=campaign.completed,campaign.paused" \
  -H "Authorization: Bearer $OMNI_OCTO_API_KEY"
# → {"object":"list","data":[…],"has_more":false,"next_cursor":"1041","replay_truncated":null,"render_errors":[]}
```

Without `after` it starts from now. Pass each `next_cursor` back as `after`.
More, with a Monitor recipe: [reference](/platform-api/reference/events/#poll-and-monitor).

<a id="message-status-events"></a><a id="call-events"></a><a id="campaign-events"></a><a id="what-each-event-promises"></a><a id="inbound-pictures-mms"></a>

What each type promises (statuses, calls, campaign pauses, inbound
pictures): [the event rules](/platform-api/reference/events/#rules). Every
field: [Events](/platform-api/reference/events/).

## Next

- [Test without spending](/platform-api/testing/): a test event and a text to yourself.
- [Send a text](/platform-api/sms/) and follow it to `delivered`.