Webhooks

Webhooks push real-time delivery status updates to your server. Instead of polling for status, Zend POSTs to your endpoint as each message moves from sent to delivered — or fails.

There are two ways to receive events:

  1. Register an endpoint in the dashboard (recommended) — subscribe once and receive events for every message, signed and retried.
  2. Attach a webhook_url to a single message — get callbacks for just that message.

Registering an endpoint

Register endpoints from the dashboard under Settings → Webhooks → Add endpoint. No code required.

  1. Enter the HTTPS URL Zend should POST to, and an optional name.
  2. Pick the events you want to subscribe to.
  3. Click Create webhook and copy your signing secret — it's shown only once.

From then on, Zend fans every subscribed event out to each of your active endpoints, signs it with that endpoint's secret, and retries automatically if your server doesn't return a 2xx.

You can toggle an endpoint active/inactive, send a test delivery, and inspect every attempt (status, response code, latency) under the Delivery logs tab.

Note

The signing secret is displayed once at creation. If you lose it, rotate it from the dashboard — the old secret stops working immediately.

Events

Subscribe to any combination of these events. They're channel-agnostic — SMS, WhatsApp, Voice, and Email all map into the same set.

message.sentevent
The message was accepted by the channel provider.
message.deliveredevent
The message was delivered to the recipient.
message.failedevent
Delivery permanently failed. The payload includes an error field.
message.readevent
The recipient read the message. WhatsApp only.

Payload shape

Each webhook is a JSON POST describing one event. The event and status fields tell you what happened.

Message delivered:

{
  "event": "message.delivered",
  "message_id": "6884da240f0e633b7b979bff",
  "external_id": "wamid.HBgMMjMz...",
  "status": "delivered",
  "channel": "whatsapp",
  "to": "+233593152134",
  "cost": 0.008,
  "timestamp": "2024-01-15T10:30:05Z",
  "delivered_at": "2024-01-15T10:30:05Z"
}

Message failed:

{
  "event": "message.failed",
  "message_id": "6884da240f0e633b7b979bff",
  "status": "failed",
  "channel": "whatsapp",
  "to": "+233593152134",
  "error": "Recipient not available on WhatsApp",
  "timestamp": "2024-01-15T10:30:00Z"
}

Note

See Status & priority for the full list of status values.

Delivery headers

Every delivery carries these headers:

X-Zend-Eventstring
The event name, e.g. message.delivered.
X-Zend-Signature-256string
sha256=<hex> — the HMAC signature. See below.
X-Zend-Timestampstring
Unix milliseconds, included in the signed payload.
X-Zend-Webhook-Idstring
The id of the endpoint that received this delivery.

Verifying signatures

Deliveries to a registered endpoint are signed so you can confirm they came from Zend and weren't tampered with. The signature is an HMAC-SHA256 of ${timestamp}.${rawBody} using your endpoint's signing secret, sent in the X-Zend-Signature-256 header as sha256=<hex>.

Verify against the raw request body — re-serializing the parsed JSON can change the bytes and break the check.

const crypto = require('crypto');

// Zend signs `${timestamp}.${rawBody}` with your endpoint's signing secret.
function verify(rawBody, headers, secret) {
  const timestamp = headers['x-zend-timestamp'];
  const signature = headers['x-zend-signature-256']; // "sha256=<hex>"
  const expected =
    'sha256=' +
    crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected),
  );
}

// Express — capture the raw body, not the parsed object.
app.post(
  '/webhooks/zend',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body.toString('utf8');

    if (!verify(rawBody, req.headers, process.env.ZEND_WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(rawBody);
    console.log('Received', event.event);
    res.status(200).send('OK');
  },
);

Note

Respond with a 200 as soon as you've accepted the event, then process it asynchronously. Zend retries deliveries that don't return a 2xx. Use crypto.timingSafeEqual to avoid timing attacks.

Per-message webhooks

To get callbacks for a single message without registering an endpoint, include a webhook_url in the request. Zend POSTs the same status events to that URL for that message only.

{
  "to": "+233593152134",
  "body": "Your order has been shipped!",
  "preferred_channels": ["whatsapp"],
  "template_id": "12345678912345",
  "template_params": {
    "order_number": "ORD-2024-001"
  },
  "webhook_url": "https://yourapp.com/webhooks/message-status"
}
webhook_urlstring
HTTPS URL that receives status updates for this message. Zend delivers a POST to it on each status change.

Note

Per-message callbacks are convenient for one-off sends, but registered endpoints are the better default — they're signed, retried, and logged, and apply to every message automatically.