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:
- Register an endpoint in the dashboard (recommended) — subscribe once and receive events for every message, signed and retried.
- Attach a
webhook_urlto 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.
- Enter the HTTPS URL Zend should
POSTto, and an optional name. - Pick the events you want to subscribe to.
- 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
Events
Subscribe to any combination of these events. They're channel-agnostic — SMS, WhatsApp, Voice, and Email all map into the same set.
error field.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
Delivery headers
Every delivery carries these headers:
message.delivered.sha256=<hex> — the HMAC signature. See below.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
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"
}
POST to it on each status change.Note