Messaging
Webhooks
Receive delivery receipts, inbound messages and account events as signed HTTPS requests to your server.
How webhooks work#
A webhook endpoint is an HTTPS URL on your server that you register with OmniMessage. When something happens, such as a message being delivered, an event object is sent to that URL as a POST request with a JSON body. Your server verifies the signature, stores or queues the event and answers with a 2xx status.
Webhooks are the recommended way to track messages. They arrive within moments of the status change and remove the need to poll.
Set up an endpoint#
Create endpoints in the console under Webhooks, or through the API with a key that has the webhooks:write scope. Choose the event types to receive, or pass ["*"] for all of them.
curl https://api.omnimessage.co/v1/webhook_endpoints \
-H "Authorization: Bearer om_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/omni",
"events": [
"message.delivered",
"message.failed",
"message.received"
],
"description": "Production delivery receipts"
}'{
"id": "we_3kL9pQ2wE5rT8yU1iO4a",
"object": "webhook_endpoint",
"mode": "live",
"url": "https://example.com/hooks/omni",
"description": "Production delivery receipts",
"events": [
"message.delivered",
"message.failed",
"message.received"
],
"status": "active",
"filters": {},
"metadata": {},
"source": null,
"created_by": {
"type": "api_key",
"id": "key_1qW4eR7tY0uI3oP6aS9d"
},
"has_verification_token": false,
"created_at": "2026-10-02T11:15:00.000Z",
"secret": "whsec_Zk8vQ2mX5cB7nL0pR3tY6wA9dF1gH4jK"
}- The
secret(prefixwhsec_) is returned only here and when you roll it. Store it as a secret next to your API key. - The URL must be public HTTPS. Hosts that resolve to private or loopback addresses are rejected.
- An endpoint belongs to the mode of the key that created it: live endpoints receive live events, test endpoints receive test events.
- An account can have up to 10 endpoints per mode.
The event object#
Every webhook body is an event. type says what happened and data.object is the resource it happened to, as it was at that moment.
{
"id": "evt_6hJ9kL2zX5cV8bN1mQ4w",
"object": "event",
"type": "message.delivered",
"mode": "live",
"created_at": "2026-10-05T09:30:02.900Z",
"data": {
"object": {
"id": "msg_2b1Xw9aQ3rT8yU0pL4kZ",
"object": "message",
"mode": "live",
"channel_id": "ch_7Hq2mN5vB8cX1zL0pK3j",
"channel_type": "whatsapp",
"direction": "outbound",
"to": "+971501234567",
"from": "+971800123456",
"type": "text",
"content": {
"text": {
"body": "Your code is 482910"
}
},
"status": "delivered",
"error": null,
"reference": "order-1042",
"metadata": {
"user_id": "u_17"
},
"billing": {
"source": "package",
"amount_micros": 0,
"package_grant_id": "grant_5tGh2Kp9LmQ4xW7nB1cD",
"refunded": false
},
"sender": null,
"contact_id": null,
"created_at": "2026-10-05T09:30:00.000Z",
"updated_at": "2026-10-05T09:30:02.871Z",
"sent_at": "2026-10-05T09:30:01.210Z",
"delivered_at": "2026-10-05T09:30:02.871Z",
"read_at": null,
"failed_at": null
}
}
}| Field | Description |
|---|---|
id | Unique event ID, prefixed evt_. The same for every retry of the event: use it to deduplicate. |
type | Event type, for example message.delivered. |
mode | live or test. |
created_at | When the event occurred, not when it was delivered to you. |
data.object | A message for message.*, a channel for channel.*, the balance for balance.low, a package for package.*. |
Event catalogue#
| Event | Sent when | data.object |
|---|---|---|
message.sent | The provider accepted an outbound message. | Message |
message.delivered | An outbound message reached the recipient device. | Message |
message.read | The recipient opened an outbound message. | Message |
message.failed | An outbound message could not be delivered. | Message |
message.received | A message arrived on one of your channels. | Message |
channel.connected | A channel reached connection_status: "connected", after being created or after a reconnect. | Channel |
channel.disconnected | A channel lost its link to the provider, for example because a token was revoked. | Channel |
balance.low | The wallet dropped below the low-balance threshold set in the console and no package credits remain. | Balance |
package.exhausted | The last credit of a package was consumed. | Package |
package.expiring | A package with unused credits expires within 7 days. | Package |
campaign.started | A campaign began sending: its audience snapshot is complete, or it was resumed after a pause. | Webhook endpoint |
campaign.paused | A campaign stopped sending. | Webhook endpoint |
campaign.completed | Every recipient of a campaign was processed. | Webhook endpoint |
campaign.failed | A campaign could not continue, for example because its channel was deleted. | Webhook endpoint |
webhook.test | Sent only when you call POST /v1/webhook_endpoints/{id}/test or press "Send test event" in the console. | Webhook endpoint |
The events reference shows a full payload for each type. Three that most integrations handle:
message.failed#
The message could not be delivered. error carries the reason and the provider code, and billing.refunded is already true.
{
"id": "evt_6hJ9kL2zX5cV8bN1mQ4w",
"object": "event",
"type": "message.failed",
"mode": "live",
"created_at": "2026-10-05T09:30:02.900Z",
"data": {
"object": {
"id": "msg_9cV4nH7jK2mP5qR8sT1w",
"object": "message",
"mode": "live",
"channel_id": "ch_7Hq2mN5vB8cX1zL0pK3j",
"channel_type": "whatsapp",
"direction": "outbound",
"to": "+971501234567",
"from": "+971800123456",
"type": "text",
"content": {
"text": {
"body": "Your code is 482910"
}
},
"status": "failed",
"error": {
"code": "provider_error",
"message": "Message undeliverable.",
"provider_code": "131026"
},
"reference": "order-1042",
"metadata": {
"user_id": "u_17"
},
"billing": {
"source": "wallet",
"amount_micros": 1000,
"package_grant_id": null,
"refunded": true
},
"sender": null,
"contact_id": null,
"created_at": "2026-10-05T09:30:00.000Z",
"updated_at": "2026-10-05T09:30:02.871Z",
"sent_at": null,
"delivered_at": null,
"read_at": null,
"failed_at": "2026-10-05T09:30:03.118Z"
}
}
}channel.disconnected#
A channel lost its link to the provider, for example because an access token was revoked. Sends on it fail with channel_not_connected until you reconnect it.
{
"id": "evt_6hJ9kL2zX5cV8bN1mQ4w",
"object": "event",
"type": "channel.disconnected",
"mode": "live",
"created_at": "2026-10-05T09:30:02.900Z",
"data": {
"object": {
"id": "ch_7Hq2mN5vB8cX1zL0pK3j",
"object": "channel",
"mode": "live",
"type": "whatsapp",
"name": "Support line",
"identifier": "+971800123456",
"status": "active",
"connection_status": "disconnected",
"capabilities": [
"text",
"attachments",
"template",
"button",
"list",
"cta_url",
"location",
"contacts",
"flow",
"product",
"product_list",
"catalog",
"carousel",
"location_request"
],
"created_at": "2026-10-01T08:00:00.000Z"
}
}
}balance.low#
The wallet is below the threshold set in the console and no package credits remain. Sent at most once every 24 hours.
{
"id": "evt_6hJ9kL2zX5cV8bN1mQ4w",
"object": "event",
"type": "balance.low",
"mode": "live",
"created_at": "2026-10-05T09:30:02.900Z",
"data": {
"object": {
"object": "balance",
"currency": "USD",
"wallet_micros": 1870000,
"packages": [],
"credits_remaining": 0
}
}
}Request headers#
| Header | Value |
|---|---|
OmniMessage-Signature | t=<unix seconds>,v1=<hex signature>. See below. |
OmniMessage-Event-Id | The event ID, same as id in the body. |
OmniMessage-Event-Type | The event type, same as type in the body. |
User-Agent | OmniMessage-Webhooks/1.0 |
Content-Type | application/json |
Verify signatures#
Anyone who learns your endpoint URL can send requests to it. The signature proves a request came from OmniMessage and that the body was not altered. Verify it on every request, before parsing the body.
OmniMessage-Signature: t=1791192602,v1=5f8a1c0e9b7d4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f- Split the header on
,and readt(a Unix timestamp in seconds) andv1(the signature). - Build the signed payload: the value of
t, a full stop, and the raw request body, exactly as received. - Compute an HMAC-SHA256 of the signed payload with the endpoint secret as the key, and hex-encode it.
- Compare the result with
v1using a constant-time comparison. - Reject the request if
tis more than five minutes from the current time. This limits replay of a captured request.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.OMNIMESSAGE_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
const parts = Object.fromEntries(
String(header ?? '').split(',').map((part) => part.split('=', 2)),
);
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.`)
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
// express.raw keeps the body as a Buffer: the signature covers the exact bytes sent.
app.post('/hooks/omni', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('OmniMessage-Signature'))) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// Hand the event to a queue here, then acknowledge.
console.log(event.id, event.type);
res.sendStatus(200);
});
app.listen(3000);The Node.js sample uses Express and the Python sample uses Flask; the verify function in each is framework-independent. To check your implementation, call POST /v1/webhook_endpoints/{id}/test: it delivers a signed webhook.test event to the endpoint.
Respond quickly#
Any 2xx status received within 10 seconds counts as success. Any other status, a timeout or a connection error counts as a failure and schedules a retry. The response body is ignored.
- Acknowledge first, work later. Verify the signature, write the event to a queue or table, return
200, and process it in a background job. - Do not return a 4xx or 5xx status for events you choose to ignore. Acknowledge them with 2xx, otherwise they are retried.
- Redirects are not followed. Register the final URL.
Retries#
A failed delivery is retried up to eight times with increasing delays. After the last retry the delivery is marked as failed and is not attempted again automatically; you can retry it manually from the delivery log in the console.
| Attempt | Delay after the previous attempt | Time since the first attempt |
|---|---|---|
| First attempt | Immediately | 0 |
| Retry 1 | 30 seconds | 30 s |
| Retry 2 | 2 minutes | 2 min 30 s |
| Retry 3 | 10 minutes | 12 min 30 s |
| Retry 4 | 30 minutes | 42 min 30 s |
| Retry 5 | 2 hours | 2 h 42 min 30 s |
| Retry 6 | 6 hours | 8 h 42 min 30 s |
| Retry 7 | 12 hours | 20 h 42 min 30 s |
| Retry 8 | 24 hours | 44 h 42 min 30 s |
Each retry sends the same body with the same event ID and a fresh OmniMessage-Signature timestamp.
Automatic disabling#
An endpoint that fails 50 consecutive deliveries is disabled: its status becomes disabled, nothing more is sent to it and the account owner is notified by email. A single successful delivery resets the counter.
After fixing the problem, re-enable the endpoint in the console or with PATCH /v1/webhook_endpoints/{id}. Events that occurred while it was disabled are not sent afterwards; recover them by listing messages for the period.
curl -X PATCH https://api.omnimessage.co/v1/webhook_endpoints/we_3kL9pQ2wE5rT8yU1iO4a \
-H "Authorization: Bearer om_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"status": "active"
}'Handle events idempotently#
Delivery is at least once. A timeout on your side can lead to the same event arriving twice, and events can arrive out of order.
- Deduplicate on the event
id. Record processed IDs (a unique index is enough) and acknowledge duplicates without acting on them again. - Do not assume order.
message.deliveredcan arrive beforemessage.sent. A status never moves backwards, so keep the furthest status seen:queued,sending,sent,delivered,read. - Use the timestamps on the message (
sent_at,delivered_at,read_at) rather than the arrival time of the event. - If in doubt, retrieve the message.
GET /v1/messages/{id}always returns the current state.
Roll the secret#
Roll a signing secret if it may have been exposed, or on a schedule. POST /v1/webhook_endpoints/{id}/roll_secret returns the endpoint with a new secret. The previous secret stops being used immediately, so deploy the new one straight away. Deliveries that your server rejects in the meantime are retried on the normal schedule and succeed once the new secret is in place.
curl -X POST https://api.omnimessage.co/v1/webhook_endpoints/we_3kL9pQ2wE5rT8yU1iO4a/roll_secret \
-H "Authorization: Bearer om_live_xxxxxxxxxxxxxxxxxxxxxxxx"Test your endpoint#
- Send a
webhook.testevent from the console or withPOST /v1/webhook_endpoints/{id}/test. It is delivered whatever the endpoint is subscribed to. - Create an endpoint with a test key and send messages to magic recipients to receive real
message.*events with predictable outcomes. - During local development, expose your machine through an HTTPS tunnel and register the tunnel URL as a test endpoint.
- The console lists every delivery attempt with its response status, which is the first place to look when events do not arrive.