Integration
Delivery webhooks
Give a callback_url with a send, or set a webhook URL in the portal, and the delivery service POSTs each message's status to it. Payloads for SMS, email and WhatsApp, inbound WhatsApp messages, signatures, retries, and how to handle them safely.
How it works
Include a callback_url with a send (SMS, email, WhatsApp or the legacy XML endpoint) and the delivery service sends an HTTP POST to it, with a JSON body and Content-Type: application/json, each time a message from that request reaches a reported status. One request to many recipients produces one callback per recipient per status change. The URL is stored with each message, so different sends can use different URLs.
For WhatsApp you can also set one URL for the whole account in the portal under Developer, Webhooks, choose which events it receives, and read the signing secret there. A per-request callback_url takes precedence; a WhatsApp message sent without one reports to the portal URL, and inbound customer messages always go there. SMS and email callbacks use the per-request URL only.
SMS callback
{
"message_id": "dff31690-b450-4000-a6b7-141d8523ef10",
"status": "DELIVERED",
"mobile_number": "260971234567"
}message_id is the ID returned in data when you sent the message; mobile_number is the normalised recipient.
| status | Meaning |
|---|---|
| SENT | The carrier accepted the message. A DELIVERED or FAILED callback may follow. |
| DELIVERED | The carrier confirmed delivery to the handset. |
| FAILED | Delivery failed after the gateway's own retries (three attempts). |
| UNKNOWN_ERROR | The carrier answered with something the gateway could not classify, after three attempts. |
SENT
- Meaning
- The carrier accepted the message. A DELIVERED or FAILED callback may follow.
DELIVERED
- Meaning
- The carrier confirmed delivery to the handset.
FAILED
- Meaning
- Delivery failed after the gateway's own retries (three attempts).
UNKNOWN_ERROR
- Meaning
- The carrier answered with something the gateway could not classify, after three attempts.
A message can produce more than one callback: SENT when the carrier accepts it, then DELIVERED or FAILED when the carrier reports the outcome. Treat the later status as final.
Email callback
{
"message_id": 48213,
"status": "DELIVERED",
"email_address": "[email protected]",
"message": "success"
}message_id is the send response's message_id as a number (the response gives it as a string: compare them as strings). message is "success" on SENT and DELIVERED and the provider's reason on FAILED.
| status | Meaning |
|---|---|
| SENT | The provider accepted the email; the final outcome arrives in a later callback. |
| DELIVERED | The provider confirmed delivery. |
| FAILED | The provider refused it; message holds the reason. |
SENT
- Meaning
- The provider accepted the email; the final outcome arrives in a later callback.
DELIVERED
- Meaning
- The provider confirmed delivery.
FAILED
- Meaning
- The provider refused it; message holds the reason.
WhatsApp callbacks
WhatsApp callbacks carry an event name, are signed, and are retried until your endpoint answers 2xx. There are two events about messages: message.status for each status change of a message you sent, and message.received for each message a customer sends you. A third, webhook.test, is sent only when you ask the portal for one. All three have the same outer shape: event, created_at (when the callback was queued) and data.
message.status
{
"event": "message.status",
"created_at": "2026-10-24T08:16:02Z",
"data": {
"message_id": "7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e5f",
"user_reference_id": "inv-42",
"channel": "whatsapp",
"to": "260971234567",
"from": "260960123456",
"status": "delivered",
"timestamp": "2026-10-24T08:16:01Z",
"error": null,
"category": "UTILITY",
"billable": true
}
}message_id and user_reference_id are the ones the send returned; to is the customer and from your sender's number. status is lower case here. timestamp is when the status was reached. category and billable say what the message was billed as. error is null except on failed, where it is { "code": 131026, "message": "Message undeliverable" }: code is Meta's error code when Meta reported one, and null for a failure before the message reached Meta.
| status | Meaning |
|---|---|
| sent | Meta accepted the message. delivered, read or failed may follow. |
| delivered | Meta reported the message delivered to the handset. |
| read | Meta reported it read. A read message was delivered, even when no delivered callback arrived first. |
| failed | The message could not be sent or delivered: error holds Meta's code (when Meta gave one) and a reason, and any charge was refunded. A message your balance could not cover arrives as failed too; the status endpoint calls that one REJECTED. |
sent
- Meaning
- Meta accepted the message. delivered, read or failed may follow.
delivered
- Meaning
- Meta reported the message delivered to the handset.
read
- Meaning
- Meta reported it read. A read message was delivered, even when no delivered callback arrived first.
failed
- Meaning
- The message could not be sent or delivered: error holds Meta's code (when Meta gave one) and a reason, and any charge was refunded. A message your balance could not cover arrives as failed too; the status endpoint calls that one REJECTED.
Callbacks for one message are queued in the order the statuses happen, but a retry can land after a later status has already arrived. Treat read as implying delivered, and never move a message backwards.
message.received
{
"event": "message.received",
"created_at": "2026-10-24T09:02:11Z",
"data": {
"message_id": "wamid.HBgMMjYwOTcxMjM0NTY3FQIAEhggQTVCMEM3Qjc5OUZCOEU5NEM3REMxMjI5NzJBODJEQTVEAA==",
"channel": "whatsapp",
"from": "260971234567",
"to": "260960123456",
"type": "text",
"text": "Hi, is my order ready?",
"media": null,
"payload": null,
"reply_to": null,
"timestamp": "2026-10-24T09:02:10Z",
"window_expires_at": "2026-10-25T09:02:10Z"
}
}message_id is Meta's id for the customer's message (it starts with wamid.), which you can pass as reply_to on the session endpoint to quote it. from is the customer and to the number they wrote to. type is text, image, document, audio, video or another WhatsApp type such as interactive, button or location. text holds the body of a text message, the caption of a media message (null when it has none), or, for an interactive or button reply, the title of the button or list row the customer chose. media holds the attachment:
{
"id": "1234567890123456",
"mime_type": "application/pdf",
"filename": "receipt-ZM4821.pdf"
}media carries Meta's media id, the MIME type and filename, the name a document was sent with (null for other media). The callback does not include the file, and the caption is in text, not here. reply_to is the WhatsApp id (wamid.) of the message the customer replied to, or null. It is not the message_id your send returned, but you can pass it as reply_to on the session endpoint. window_expires_at is when the customer's 24-hour window closes: until then you can answer with a free session message.
payload is Meta's own object for the types that have no text of their own: location, contacts (the cards, wrapped as {"contacts": [...]}), reaction (the message_id reacted to and the emoji, empty or absent when a reaction is removed), sticker (which also fills media) and unsupported, a message WhatsApp cannot pass on, such as a poll. For these, text holds a readable summary such as Location: Cairo Road branch, Cairo Road, Lusaka. payload is null for text and media messages, and for interactive and button replies: only the chosen title is passed on, in text, not the id you gave the button or list row. A shared location:
{
"latitude": -15.4167,
"longitude": 28.2833,
"name": "Cairo Road branch",
"address": "Cairo Road, Lusaka"
}webhook.test
{
"event": "webhook.test",
"created_at": "2026-10-24T09:30:00Z",
"data": {
"message": "This is a test event from Unicom360."
}
}Sent when you press Send test event under Developer, Webhooks in the portal, so you can check your receiver and its signature check without sending a message. It goes to the URL saved there, even while that endpoint is paused or not subscribed to any event, and is signed and retried like message.status and message.received. data holds only the fixed message shown; answer 2xx and do nothing else with it.
Headers and signature
| Header | Meaning |
|---|---|
| Content-Type | application/json. |
| X-Unicom360-Event | message.status, message.received or webhook.test. |
| X-Unicom360-Delivery | The delivery's id, the same on every retry of one callback. Store it and drop a repeat. |
| X-Unicom360-Timestamp | When this attempt was signed, in Unix seconds. Reject one more than five minutes from your clock. |
| X-Unicom360-Signature | sha256= followed by the lowercase hex HMAC-SHA256 of "<timestamp>.<raw body>", keyed with your webhook secret. Absent while no secret exists for your account. |
Content-Type
- Meaning
- application/json.
X-Unicom360-Event
- Meaning
- message.status, message.received or webhook.test.
X-Unicom360-Delivery
- Meaning
- The delivery's id, the same on every retry of one callback. Store it and drop a repeat.
X-Unicom360-Timestamp
- Meaning
- When this attempt was signed, in Unix seconds. Reject one more than five minutes from your clock.
X-Unicom360-Signature
- Meaning
- sha256= followed by the lowercase hex HMAC-SHA256 of "<timestamp>.<raw body>", keyed with your webhook secret. Absent while no secret exists for your account.
The secret is shown under Developer, Webhooks in the portal, where you can also rotate it. To verify a callback: take the raw request body as bytes, before any JSON parsing; compute HMAC-SHA256, keyed with the secret string, over the timestamp header, a full stop and the body; hex-encode the result in lower case and compare it in constant time with the header's value after sha256=; and reject a timestamp more than five minutes from your clock, so a captured request cannot be replayed later. If you have never opened the Webhooks page, no secret exists yet and callbacks to a per-request callback_url arrive without a signature header.
// Express (Node.js). Verify before parsing: the signature covers the raw bytes.
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.UNICOM360_WEBHOOK_SECRET; // Developer > Webhooks in the portal
app.post(
"/webhooks/unicom360/whatsapp",
express.raw({ type: "application/json" }),
(req, res) => {
const timestamp = req.get("X-Unicom360-Timestamp") ?? "";
const signature = req.get("X-Unicom360-Signature") ?? "";
// HMAC-SHA256 over "<timestamp>.<raw body>", keyed with the secret string.
const expected =
"sha256=" +
createHmac("sha256", SECRET).update(timestamp + ".").update(req.body).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
const valid =
fresh &&
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return res.sendStatus(401);
// Answer first: anything but 2xx is retried. Then drop a retry you already handled.
res.sendStatus(200);
const deliveryId = req.get("X-Unicom360-Delivery");
if (alreadyHandled(deliveryId)) return;
const { event, data } = JSON.parse(req.body.toString("utf8"));
if (event === "message.status") applyWhatsAppStatus(data);
if (event === "message.received") handleInboundWhatsApp(data);
}
);Retries and pausing
Any answer other than 2xx, including a timeout (5 seconds to connect, 10 to answer), is retried after 30s, 2m, 10m, 30m, 1h, 3h, 6h and 12h, then dropped. The X-Unicom360-Delivery header is the same on every attempt, so a receiver that handled the first attempt but answered late can drop the retries. After 50 consecutive failures to the URL set in the portal, that endpoint is paused and stops receiving callbacks until you resume it there; callbacks to a per-request callback_url are not affected by the pause.
Allowed URLs are https only, on a public address. A URL that resolves to a private, loopback or link-local address is refused and the callback is not sent.
Delivery guarantees
SMS and email: best effort, unsigned, no retries
An SMS or email callback is one POST, made in the background as soon as the status is known. The delivery service does not check your response and does not retry: if your endpoint is down or slow at that moment, that callback is lost. The request carries no signature, secret or authentication header, and anyone who learns the URL can POST to it.
WhatsApp callbacks are the exception: they are signed, retried on the schedule above, and queued together with the status they report, so a status the portal shows always has its callback on the way. On every channel the portal remains the record: it shows the status of every message, and nothing in it depends on your endpoint having been reachable.
Handling callbacks safely
- Use an HTTPS URL that is hard to guess (a long random path segment is enough), and only act on a
message_idyou actually sent, to the recipient you sent it to. For WhatsApp, verify the signature as well. - Return
200at once and do the work afterwards. An SMS or email callback gets no retry; a slow WhatsApp handler earns a retry it then has to recognise. - Make the handler idempotent: the same status can arrive twice, and a later status supersedes an earlier one. Never move a message backwards from DELIVERED to SENT. For WhatsApp, store
X-Unicom360-Deliveryand drop a repeat. - Treat a message with no callback as unknown, not failed, and reconcile against the portal (or, for WhatsApp, the status endpoint) if you need certainty.
// Express (Node.js). The same shape applies in any framework.
app.post("/webhooks/unicom360/sms", express.json(), (req, res) => {
const { message_id, status, mobile_number } = req.body;
// Answer first: the delivery service does not wait for your processing.
res.sendStatus(200);
// Only act on IDs you sent. Anyone can POST to a public URL.
const message = findMessageById(String(message_id));
if (!message || message.mobile !== mobile_number) return;
// Apply idempotently: SENT then DELIVERED can both arrive, and a repeat is harmless.
applyDeliveryStatus(message, status);
});