Skip to content

Receive webhooks

Download .md

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.

Terminal window
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.

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 wantsubscribe to
delivery receiptsmessage.status_updated
replies and inbound messages (SMS and WhatsApp)message.received
what your agent or team texted (SMS only)message.sent
how a call wentcall.ringing, call.answered, call.completed, call.failed
a campaign that started, paused or finishedcampaign.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.

channelwhat you get
SMSmessage.status_updated, message.received, message.sent
WhatsAppmessage.status_updated, message.received (not message.sent)
callscall.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.

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()):

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
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.

  • 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.

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.

WhereDefaultChoose with
A new endpointv2api_version on POST /v1/webhooks or PATCH /v1/webhooks/{id}
An endpoint made before v1.84.0.02026-10-01PATCH with { "api_version": "v2" }
The event stream2026-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" }
}
}

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).

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.

requestdoes
GET /v1/webhookslists 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-secreta new secret, shown once; the old one stops working at once
POST /v1/webhooks/{id}/testsends one signed webhook.test event and answers what your server said
GET /v1/webhooks/{id}/deliveriesits deliveries of the last 7 days (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. Codes: Errors → Webhooks.

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

Terminal window
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 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 with ?after=<seq - 1> (add &api_version=v2 for the v2 body).

Every field and error code: Webhooks reference.

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.

Terminal window
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.

  • 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 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.

  • 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 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>.
  • ?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).
  • 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).

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

Terminal window
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.