Skip to content

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"
  }'
201 Created
{
  "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 (prefix whsec_) 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.

Webhook body
{
  "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
    }
  }
}
FieldDescription
idUnique event ID, prefixed evt_. The same for every retry of the event: use it to deduplicate.
typeEvent type, for example message.delivered.
modelive or test.
created_atWhen the event occurred, not when it was delivered to you.
data.objectA message for message.*, a channel for channel.*, the balance for balance.low, a package for package.*.

Event catalogue#

EventSent whendata.object
message.sentThe provider accepted an outbound message.Message
message.deliveredAn outbound message reached the recipient device.Message
message.readThe recipient opened an outbound message.Message
message.failedAn outbound message could not be delivered.Message
message.receivedA message arrived on one of your channels.Message
channel.connectedA channel reached connection_status: "connected", after being created or after a reconnect.Channel
channel.disconnectedA channel lost its link to the provider, for example because a token was revoked.Channel
balance.lowThe wallet dropped below the low-balance threshold set in the console and no package credits remain.Balance
package.exhaustedThe last credit of a package was consumed.Package
package.expiringA package with unused credits expires within 7 days.Package
campaign.startedA campaign began sending: its audience snapshot is complete, or it was resumed after a pause.Webhook endpoint
campaign.pausedA campaign stopped sending.Webhook endpoint
campaign.completedEvery recipient of a campaign was processed.Webhook endpoint
campaign.failedA campaign could not continue, for example because its channel was deleted.Webhook endpoint
webhook.testSent 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.

message.failed
{
  "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.

channel.disconnected
{
  "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.

balance.low
{
  "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#

HeaderValue
OmniMessage-Signaturet=<unix seconds>,v1=<hex signature>. See below.
OmniMessage-Event-IdThe event ID, same as id in the body.
OmniMessage-Event-TypeThe event type, same as type in the body.
User-AgentOmniMessage-Webhooks/1.0
Content-Typeapplication/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.

Header
OmniMessage-Signature: t=1791192602,v1=5f8a1c0e9b7d4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f
  1. Split the header on , and read t (a Unix timestamp in seconds) and v1 (the signature).
  2. Build the signed payload: the value of t, a full stop, and the raw request body, exactly as received.
  3. Compute an HMAC-SHA256 of the signed payload with the endpoint secret as the key, and hex-encode it.
  4. Compare the result with v1 using a constant-time comparison.
  5. Reject the request if t is 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.

AttemptDelay after the previous attemptTime since the first attempt
First attemptImmediately0
Retry 130 seconds30 s
Retry 22 minutes2 min 30 s
Retry 310 minutes12 min 30 s
Retry 430 minutes42 min 30 s
Retry 52 hours2 h 42 min 30 s
Retry 66 hours8 h 42 min 30 s
Retry 712 hours20 h 42 min 30 s
Retry 824 hours44 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.delivered can arrive before message.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.test event from the console or with POST /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.

    Loading