Webhooks
Configure callbacks in the Portal, test delivery, verify signatures, and process WhatsApp events safely.
Developer webhooks deliver normalized WhatsApp events from Notify Africa to your HTTPS endpoint.
Configuration Boundary
Configure webhooks in the Notify Africa Portal. The Portal owns:
- endpoint creation and URL changes.
- event subscriptions.
- pause/reactivation and deletion.
- signing-secret creation and rotation.
- delivery-attempt inspection.
- queuing a test delivery.
Webhook management has no Developer API (API-key) surface. Every webhook action, including queuing a test delivery, requires an authenticated Portal session.
Configure in the Portal
- Add an HTTPS callback URL.
- Select one or more supported event types.
- Save the generated
whsec_...secret immediately. - Keep the endpoint active.
- Queue a test delivery directly in the Portal.
- Inspect failures and endpoint health in the Portal.
An empty subscription receives no production events. Creating or reactivating an endpoint requires a ready WABA connection.
Supported Events
| Event | Aggregate | Trigger |
|---|---|---|
message.received | message | Inbound customer message |
message.status.updated | message | Outbound/inbound message status change |
template.status.updated | template | Provider template lifecycle change |
Test Webhook
Once an endpoint is active, queue a test delivery from the Portal to confirm your receiver is reachable and verifying signatures correctly, before relying on it for real WhatsApp events.
- Open the webhook endpoint in the Portal.
- Trigger a test delivery for that endpoint.
- Notify Africa sends one sample
message.receivedevent to your callback URL, signed exactly like a real event. - Confirm your receiver logged the incoming request.
- Confirm the signature verified — the same check your receiver runs on real events.
- Check the Portal for the attempt's outcome (delivered, failed, or retrying) and the response your endpoint returned.
The endpoint must be active before you can queue a test.
Your receiver gets the same envelope as a real callback, with fabricated content — no real message exists behind aggregateId: "test":
{
"eventId": "7c61520b-7c77-4df6-989a-3b784d246f66",
"authUserId": 12345,
"eventType": "message.received",
"aggregateType": "message",
"aggregateId": "test",
"dedupeKey": "test:2a3fbc46-5589-4f91-9e4b-31ed5c89f141:7c61520b-7c77-4df6-989a-3b784d246f66",
"occurredAt": "2026-09-09T10:40:00.000Z",
"data": {
"test": true,
"message": "This is a test delivery from Notify."
}
}Next: return HTTP 2xx after durably accepting the callback.
Callback Request
Notify Africa sends:
POST /your-callback-path HTTP/1.1
Content-Type: application/json
x-notify-webhook-timestamp: 1788942600
x-notify-webhook-signature: sha256=...The body uses this envelope:
| Field | Type | Meaning |
|---|---|---|
eventId | UUID string | Unique event identifier; primary idempotency key for receivers |
authUserId | number | Notify Africa account-owner identifier |
eventType | string | Subscribed event name |
aggregateType | message or template | Resource family |
aggregateId | string | Identifier chosen for the affected resource |
dedupeKey | string | Notify Africa event-production deduplication key |
occurredAt | ISO-8601 string | Domain-event occurrence time |
data | object | Event-specific data |
Message Received Payload
{
"eventId": "bd8e8c07-3630-4d28-8660-82cf5cb1a5e6",
"authUserId": 12345,
"eventType": "message.received",
"aggregateType": "message",
"aggregateId": "501",
"dedupeKey": "developer-webhook:waba:message:received:wamid-example",
"occurredAt": "2026-09-09T10:45:00.000Z",
"data": {
"conversationId": 77,
"messageId": 501,
"wamid": "wamid.HBgLMjU1NzAwMDAwMDAxFQIAERgS...",
"customerPhoneNumber": "255700000001",
"customerName": "Jane",
"direction": "inbound",
"messageType": "text",
"sentAt": "2026-09-09T10:44:59.000Z",
"content": {
"text": "Hello"
}
}
}Message-received data can include conversationId, messageId, wamid, customerPhoneNumber, customerName, direction, messageType, sentAt, and normalized content.
Message Status Payload
{
"eventId": "7c61520b-7c77-4df6-989a-3b784d246f66",
"authUserId": 12345,
"eventType": "message.status.updated",
"aggregateType": "message",
"aggregateId": "d9b54c80-8a63-4f8b-83c8-71f2439f5429",
"dedupeKey": "developer-webhook:waba:message:status:example:delivered",
"occurredAt": "2026-09-09T10:46:00.000Z",
"data": {
"conversationId": 77,
"messageId": 501,
"wamid": "wamid.HBgLMjU1NzAwMDAwMDAxFQIAERgS...",
"customerPhoneNumber": "255700000001",
"customerName": "Jane",
"direction": "outbound",
"messageType": "template",
"status": "delivered",
"recipientId": "255700000001",
"providerTimestamp": "1788942760",
"errors": [],
"errorCode": null,
"errorMessage": null,
"retryable": false
}
}Status data can include provider errors and normalized errorCode, errorCategory, errorMessage, and retryable fields.
Template Status Payload
{
"eventId": "4ac9ed86-8f75-4b02-bf89-a357d66ab193",
"authUserId": 12345,
"eventType": "template.status.updated",
"aggregateType": "template",
"aggregateId": "order_update:en",
"dedupeKey": "developer-webhook:waba:template:status:provider-event-1",
"occurredAt": "2026-09-09T10:47:00.000Z",
"data": {
"wabaId": "9876543210",
"authUserId": 12345,
"externalTemplateId": "7788990011",
"externalTemplateName": "order_update",
"externalTemplateLanguage": "en",
"providerLanguage": "en",
"externalTemplateCategory": "UTILITY",
"parameterFormat": "positional",
"providerStatus": "APPROVED",
"status": "approved",
"providerComponents": [],
"rejectionReason": null,
"rejectionInfo": null,
"disableInfo": null,
"lockInfo": null,
"syncStatus": "synced",
"syncedAt": "2026-09-09T10:47:00.000Z",
"lastSyncError": null,
"deletedAt": null,
"revision": 3
}
}The normalized template event status can be pending, approved, rejected, or deleted. The template REST status vocabulary excludes deleted because deleted records are no longer an active retrievable resource.
Verify Signatures
Signature base string:
<x-notify-webhook-timestamp>.<raw-json-body>Algorithm: HMAC-SHA256 using the Portal-generated webhook secret. Compare against the hexadecimal digest in x-notify-webhook-signature after the sha256= prefix.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyNotifyWebhook(input: {
rawBody: Buffer;
timestamp: string;
signature: string;
secret: string;
}): boolean {
const expected = Buffer.from(
'sha256=' +
createHmac('sha256', input.secret)
.update(input.timestamp)
.update('.')
.update(input.rawBody)
.digest('hex'),
);
const received = Buffer.from(input.signature);
return (
expected.length === received.length &&
timingSafeEqual(expected, received)
);
}Verify before parsing JSON. Reject missing signatures and timestamps. Enforce an application replay window, such as five minutes, before processing the event.
Idempotent Processing
Delivery is at least once. Duplicate and out-of-order callbacks are possible.
- Verify the signature and timestamp.
- Start a database transaction.
- Insert
eventIdinto a table with a unique constraint. - If the insert conflicts, return 2xx without repeating side effects.
- Apply the event state transition.
- Commit.
- Return 2xx.
Do not rely on arrival order. Compare occurredAt and domain state before replacing a newer local status.
Retry Behavior
| Outcome | Delivery behavior |
|---|---|
| HTTP 2xx | Delivered; no retry |
| Network error or timeout | Retry |
408, 409, 425, 429 | Retry |
5xx | Retry |
| Other non-2xx | Discard |
Current defaults:
- callback timeout: 5 seconds.
- retry window: 24 hours.
- maximum attempts: 8.
- full-jitter exponential delay from 1 second, capped at 5 minutes.
- automatic pause after 10 consecutive failures.
Inspect pending, delivering, delivered, retrying, failed, and discarded attempts in the Portal.
URL Security
Webhook URLs must use HTTPS. Notify Africa rejects private, loopback, link-local, metadata, carrier-grade NAT, benchmark, multicast, and reserved destinations, including unsafe IPv6 ranges and IPv4-mapped IPv6 addresses.
The dispatcher resolves DNS before sending, validates the resolved destination, pins that address for the request, and does not follow redirects.
