Skip to Content

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

  1. Register a webhook endpoint (an HTTPS URL you control) against one of your API keys — in the developer portal or via the API below.
  2. SendAfrica stores a per-endpoint webhook secret and returns it to you once.
  3. When a message reaches a terminal network state, SendAfrica POSTs an sms.delivered or sms.failed event to every active endpoint on that API key.
  4. Your server verifies the signature, processes the event durably, and returns 2xx.
  5. If your endpoint 5xxs, times out, or is unreachable, SendAfrica retries with backoff. After 10 consecutive failures the endpoint is disabled.

Events and payload

EventMeaning
sms.deliveredThe message reached (or otherwise left) the carrier in a non-failure state. The status field carries the precise state.
sms.failedThe 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 }
FieldTypeDescription
eventstringsms.delivered or sms.failed
message_idstringThe send’s message ID — correlate it with the message_id returned by the send endpoints
tostringThe recipient phone number from the original send
statusstringNormalized network state: delivered, sent, pending, or failed
sender_idstringThe sender ID the message went out on
errorstring | nullFailure reason when the delivery failed, otherwise null
timestampintUnix 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

MethodPathDescription
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-secretRotate 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/summaryAggregate delivered/failed/disabled counts
GET/v1/api-keys/\{keyId\}/webhooks/deliveriesPaginated 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), 200

Go

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 2xx response only after the event has been durably accepted — written to a database, published to a queue, or applied to your state. A fast 200 that does no work will lose events.
  • Never block on slow downstream work (long network calls, outbound email). Acknowledge quickly and process asynchronously.
  • Non-2xx responses, timeouts, and connection errors are retried, so make processing idempotent. message_id plus event is 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 accept POST. 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 2xx as 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_id from your send responses so you can join webhook events back to your own records using message_id and to.
  • 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:

  • Go — client.Webhooks.Parse(body, signature, secret)Webhooks
  • Python — client.webhooks.parse(payload, signature=...)Webhooks
  • TypeScript — verify with node:crypto in a route handler — Webhooks
Last updated on