Receive webhooks
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 and
Events.
Register an endpoint
Section titled “Register an endpoint”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:
{ "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.
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
Section titled “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.
| channel | what you get |
|---|---|
| SMS | message.status_updated, message.received, message.sent |
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.
Verify the signature
Section titled “Verify the signature”Webhooks use the Standard Webhooks 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.
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()):
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));});import base64import hashlibimport 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
Section titled “Handle events”- Answer 2xx within 10 seconds, then do the work. Anything else is retried.
- Answer 2xx to a
typeyou do not handle, and ignore fields you do not know: new ones are added. - Drop repeats by the envelope
id(thewebhook-idheader). 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 lowerseqthan 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}orGET /v1/campaigns/{id}.
What each event promises: the event rules.
Payload versions
Section titled “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).
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.
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:
{ "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:
{ "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:
{ "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
Section titled “Upgrade an endpoint”Two ways, both reversible:
- Side by side. Create a second endpoint on
v2with the sameevent_types, at a new URL of yours (or the same URL, with a handler that tells the two apart byapi_version). Switch to it, then delete the old one. While both exist each event arrives twice with the samewebhook-id: dedupe on it. - 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_versionis present only in v2).
Retries
Section titled “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. To see which deliveries failed and what your server answered, list them.
Manage endpoints
Section titled “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) |
event_typesreplaces the list, it does not add to it: send the whole list you want. OnPATCHan 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. Codes: Errors → Webhooks.
See what was delivered
Section titled “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:
curl "https://api.omniocto.com/v1/webhooks/whe-0000000001/deliveries?status=problems" \ -H "Authorization: Bearer $OMNI_OCTO_API_KEY"{ "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 takespending,succeeded,failedorexhausted.problemsleaves out deliveries dropped untried (endpoint_disabled,retention). Page withlimit(up to 100) andafter.GET /v1/webhooks/{id}/deliveries/{delivery_id}addspayload(the body sent,nullonce the event left the log), the unsigned headers, andresponse_excerpt(the start of your reply).- Nothing is re-sent on request: replay from
the event stream with
?after=<seq - 1>(add&api_version=v2for the v2 body).
Every field and error code: Webhooks reference.
The event stream
Section titled “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): v2 frames carry exactly the body a v2
endpoint receives.
Connect
Section titled “Connect”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: 1041event: message.receiveddata: {"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
Section titled “Resume”-
Reconnect with the
Last-Event-IDheader: the stream replays everything after it, in order, then tails new events.?after=<seq>wins overLast-Event-ID, so use it only on the first connect: anEventSourceopened 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. Anafterthat is not a whole number of 0 or more is400 invalid_cursor. To start from now, poll once. -
The log keeps 7 days. An older cursor gets one
replay_truncatedframe, with noid:and no envelope, then the replay from the oldest event kept:event: replay_truncateddata: {"after":1041,"oldest_retained_seq":52310,"retention_days":7}The events between
afterandoldest_retained_seqare gone: reconcile that gap withGETreads.
Frames
Section titled “Frames”- Three frames are not events and have no
id::replay_truncated(above),ping(emptydata, about every 15 seconds: skip it, do not parse it) anderror(the key’s owner is no longer an owner or admin:datais the error envelope withcode: "insufficient_role", and the stream ends). - On
?api_version=v2only, an event that could not be rendered as v2 comes as arender_errorframe, with the event’sseqas itsid:(a reconnect resumes after it) anddata{"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’sGET, or by opening the stream withoutapi_versionwith?after=<seq-1>&types=<type>.
Limits
Section titled “Limits”?api_versionother than2026-10-01orv2is400 invalid_request; another query parameter is400 unknown_parameter, and one sent twice400 invalid_request(codes).- 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
Section titled “Poll instead of stream”?wait=25 answers one JSON page, as soon as an event arrives:
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.
What each type promises (statuses, calls, campaign pauses, inbound pictures): the event rules. Every field: Events.
- Test without spending: a test event and a text to yourself.
- Send a text and follow it to
delivered.