Webhooks
SendAfrica POSTs normalized delivery events to your HTTPS endpoint. Webhooks are the only reliable way to know whether a message reached a handset — the send response only confirms provider submission.
How it works
- Register a webhook endpoint (an HTTPS URL you control) against one of your API keys — in the developer portal or via the API below.
- SendAfrica stores a per-endpoint webhook secret and returns it to you once.
- When a message reaches a terminal network state, SendAfrica POSTs an
sms.deliveredorsms.failedevent to every active endpoint on that API key. - Your server verifies the signature, processes the event durably, and returns
2xx. - If your endpoint
5xxs, times out, or is unreachable, SendAfrica retries with backoff. After 10 consecutive failures the endpoint is disabled.
Events and payload
| Event | Meaning |
|---|---|
sms.delivered | The message reached (or otherwise left) the carrier in a non-failure state. The status field carries the precise state. |
sms.failed | The message failed — provider rejection, expired, blacklisted subscriber, and so on. |
{
"event": "sms.delivered",
"message_id": "ATXid0daa0a0a0a0a0a0a0",
"to": "0712345678",
"status": "delivered",
"sender_id": "SENDAFRICA",
"error": null,
"timestamp": 1726070400
}{
"event": "sms.failed",
"message_id": "ATXid0daa0a0a0a0a0a0a0",
"to": "0712345678",
"status": "failed",
"sender_id": "SENDAFRICA",
"error": "UnknownSubscriber",
"timestamp": 1726070400
}| Field | Type | Description |
|---|---|---|
event | string | sms.delivered or sms.failed |
message_id | string | The send’s message ID — correlate it with the message_id returned by the send endpoints |
to | string | The recipient phone number from the original send |
status | string | Normalized network state: delivered, sent, pending, or failed |
sender_id | string | The sender ID the message went out on |
error | string | null | Failure reason when the delivery failed, otherwise null |
timestamp | int | Unix epoch seconds the event was created |
Set up an endpoint
Endpoint management is authenticated with your developer portal session token and scoped to the API key ID you address. The URL must be https://.
Create
curl -X POST https://api.sendafrica.online/v1/api-keys/{keyId}/webhooks/ \
-H "Authorization: Bearer $SENDAFRICA_PORTAL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/sms"}'The response includes the webhook secret exactly once. Store it securely — it is never returned again, not even by list operations.
{
"success": true,
"data": {
"id": "a1b2c3d4-...",
"api_key_id": "f3b1c2d4-...",
"url": "https://example.com/hooks/sms",
"is_active": true,
"created_at": "2026-09-12T10:00:00Z",
"updated_at": "2026-09-12T10:00:00Z",
"secret": "RND6kH...subscriber-secret"
},
"request_id": "dfffa252-4781-43ff-8e1a-bf01a754d66a"
}Manage
| Method | Path | Description |
|---|---|---|
GET | /v1/api-keys/\{keyId\}/webhooks/ | List endpoints (no secret) |
PATCH | /v1/api-keys/\{keyId\}/webhooks/\{webhookId\} | Enable/disable — body \{"is_active": true|false\} |
POST | /v1/api-keys/\{keyId\}/webhooks/\{webhookId\}/regenerate-secret | Rotate the secret (returned once; also re-enables a disabled endpoint) |
DELETE | /v1/api-keys/\{keyId\}/webhooks/\{webhookId\} | Delete the endpoint |
GET | /v1/api-keys/\{keyId\}/webhooks/summary | Aggregate delivered/failed/disabled counts |
GET | /v1/api-keys/\{keyId\}/webhooks/deliveries | Paginated delivery log — ?page=1&per_page=25&status=delivered (all, delivered, or failed) |
Verify every event
Every delivery carries an X-SendAfrica-Signature header: an HMAC-SHA256 hex digest of the exact raw request body, keyed with your webhook secret. Verify the raw body before parsing JSON, and use a constant-time comparison.
Node.js
import crypto from 'node:crypto'
export async function handleWebhook(request) {
const rawBody = await request.text()
const expected = crypto
.createHmac('sha256', process.env.SENDAFRICA_WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')
const actual = request.headers.get('x-sendafrica-signature') ?? ''
if (
actual.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(actual))
) {
return new Response('invalid signature', { status: 401 })
}
const event = JSON.parse(rawBody)
await processEvent(event) // durable work: DB writes, queue, etc.
return new Response('ok', { status: 200 })
}Python (Flask)
import hashlib, hmac
from flask import request, jsonify
def webhook():
raw_body = request.get_data()
expected = hmac.new(WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
actual = request.headers.get("X-SendAfrica-Signature", "")
if not hmac.compare_digest(expected, actual):
return jsonify(error="invalid signature"), 401
event = json.loads(raw_body)
process_event(event) # durable work
return jsonify(ok=True), 200Go
body, _ := io.ReadAll(r.Body)
mac := hmac.New(sha256.New, []byte(os.Getenv("SENDAFRICA_WEBHOOK_SECRET")))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-SendAfrica-Signature"))) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)Respond correctly
- Return a
2xxresponse only after the event has been durably accepted — written to a database, published to a queue, or applied to your state. A fast200that does no work will lose events. - Never block on slow downstream work (long network calls, outbound email). Acknowledge quickly and process asynchronously.
- Non-
2xxresponses, timeouts, and connection errors are retried, so make processing idempotent.message_idpluseventis a stable deduplication key: keep a small set of recently processed IDs and skip duplicates.
Retries
A failed delivery is retried with exponential backoff: 1, 2, 4, then 8 minutes apart. An endpoint with 10 consecutive failures is automatically disabled; requests are then dropped until you re-enable it (or regenerate its secret), from the portal or the API. Watch the delivery log and the endpoint summary to catch integration problems early.
Best practices
- HTTPS only. Endpoint URLs must be
https://and must acceptPOST. HTTP URLs are rejected. - Store the secret once. Create and secret-regeneration responses are the only time the secret is shown. List operations never return it.
- Keep the response fast. Process asynchronously and return
2xxas soon as the event is durably queued. - Handle idempotency. Network retries can duplicate events. Deduplicate on
message_id+event. - Log without secrets. Log message IDs and event types, never the raw secret or signature.
- Correlate with sends. Store the
message_idfrom your send responses so you can join webhook events back to your own records usingmessage_idandto. - Never expose the secret in source control, logs, client-side code, or screenshots.
Troubleshooting
- 401 invalid signature — you are not hashing the exact raw body, or the secret does not match the endpoint. Regenerate the secret if it was lost.
- No events arriving — confirm the endpoint is active (
is_active: true), the API key had a successful send, and the URL is publicly reachable over HTTPS. - Endpoint disabled — 10 consecutive failures. Fix the receiver, then re-enable or rotate the secret.
- Duplicate events — expected under retry; make your handler idempotent.
SDK helpers
Instead of hand-rolling HMAC, use the typed parsers: