Skip to main content

WhatsApp Webhooks

MALIPOPAY forwards two kinds of event to your server: inbound messages from customers, and delivery statuses for messages you sent.

Set the webhook URL per connected number in the dashboard under WhatsApp → Numbers, or with PATCH /api/v1/waba/phone-numbers/{id}.

These are separate from the payment webhooks, use a different signature scheme, and are configured per WhatsApp number rather than per project. Do not reuse your payment verification code here.

Inbound message

{
"type": "message",
"wabaId": "109294241982321",
"phoneNumberId": "108843815363925",
"contactWaId": "255712345678",
"profileName": "John Haule",
"message": {
"id": "wamid.HBgMMjU1NzEyMzQ1Njc4FQIAEhgU...",
"from": "255712345678",
"timestamp": "1755504964",
"type": "text",
"text": { "body": "Has my order shipped?" }
}
}

message is Meta's own object, forwarded as-is. message.type tells you what to read: text.body for text, and a media id for image, document, audio or video, which you fetch with GET /api/v1/waba/messages/{id}/media.

An inbound message opens or extends the 24-hour window with that customer. See Conversations.

Delivery status

{
"type": "status",
"wabaId": "109294241982321",
"phoneNumberId": "108843815363925",
"status": {
"id": "wamid.HBgMMjU1NzEyMzQ1Njc4FQIAERgS...",
"status": "delivered",
"timestamp": "1755504970",
"recipient_id": "255712345678"
}
}

status.status moves through sentdeliveredread, or lands on failed. Match it to your send by status.id, the wamid returned when you sent. A failed status carries errors[0].title and errors[0].code, which is where you find out a template was paused or a number is unreachable.

Branch on the top-level type field first: message or status.

Verify the signature

Every delivery carries a timestamped HMAC, in the Stripe style, so you can reject replays:

X-Malipopay-Signature: t=<unix-seconds>,v1=<sha256-hex>

where

v1 = HMAC_SHA256(webhookSecret, `${t}.${rawBody}`)

The secret is per phone number, issued when the number is connected. It is not your API key and not your payment webhook secret.

const crypto = require('crypto');

function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');

const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 || '', 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Compute the HMAC over the exact raw bytes you received. Re-serialising the parsed JSON changes key order and spacing, and the signature will not match.

Reject anything older than about 5 minutes.

Other headers

HeaderUse
X-Malipopay-Delivery-IdStable id for this event. Deduplicate on it
X-Malipopay-AttemptWhich retry this is, starting at 1
User-Agentmalipopay-waba-webhook/1.0

Retries

Respond 2xx within 15 seconds. Anything else is retried at 1m, 5m, 30m, 2h, 12h, then dead-lettered after 5 attempts.

Acknowledge first and process asynchronously. A slow handler becomes a failed delivery, and a failed delivery becomes a retry storm.

Because events can be redelivered, make your handler idempotent — key on X-Malipopay-Delivery-Id, or on message.id / status.id.

If a number has no webhook URL configured, its events are recorded as dead deliveries and never sent. They are not queued for later: configure the URL before you go live, not after.

Rotate the secret

curl -X POST https://core-prod.malipopay.co.tz/api/v1/waba/webhook-secrets/{phoneNumberId}/rotate \
-H "apiToken: YOUR_API_KEY"

The new secret is returned once. Store it immediately; it cannot be read back. Deliveries already queued were signed with the old secret, so accept both for a few minutes after rotating.