Reliability
Webhooks
Register an HTTPS endpoint with POST /v1/webhooks/endpoints and Sendozi POSTs each event to it as JSON, signed with HMAC-SHA256 using a secret shown once at creation. A failed delivery is retried for about 46 hours, and an endpoint that keeps failing is paused with its events held until you resume it.
Managing endpoints
| Endpoint | Scope | Purpose |
|---|---|---|
| GET /v1/webhooks/endpoints | webhooks:read | List your endpoints with their status and last failure |
| POST /v1/webhooks/endpoints | webhooks:manage | Create one. The signing secret is returned once |
| PATCH /v1/webhooks/endpoints/{id} | webhooks:manage | Change the url or events, disable it, or resume it |
| DELETE /v1/webhooks/endpoints/{id} | webhooks:manage | Remove it |
| POST /v1/webhooks/endpoints/{id}/test | webhooks:manage | Send a signed test.ping to it now |
Endpoints can also be managed in the Console under Developers > Webhooks, which shows each delivery attempt and its response code. Creating, changing and deleting endpoints is limited to 20 changes an hour per workspace.
curl -X POST https://api.sendozi.com/v1/webhooks/endpoints \
-H "Authorization: Bearer $SENDOZI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourapp.com/webhooks/sendozi",
"events": ["message.delivered", "message.failed", "batch.completed"]
}'{
"success": true,
"data": {
"id": "wh_6d8f0a2c4e6b8d0f",
"url": "https://api.yourapp.com/webhooks/sendozi",
"events": ["message.delivered", "message.failed", "batch.completed"],
"status": "active",
"signing_secret_masked": "whsec_3b...9f2a",
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"last_failure_reason": null,
"paused_at": null,
"created_at": "2026-09-24T10:00:00.000Z",
"updated_at": "2026-09-24T10:00:00.000Z",
"signing_secret": "whsec_3b1f...",
"note": "Store this webhook secret now. It will not be shown again."
},
"request_id": "req_9c0d1e2f3a4b5c6d"
}urlmust behttps://on a public domain. Local, private and reserved addresses, and URLs with a username or password, are refused with 400 invalid_request. Redirects are not followed, so register the final URL.eventsnot recognised are ignored. If none is given, or none is recognised, the endpoint getsmessage.deliveredandmessage.failed.- The signing secret is returned once and stored encrypted. If it is lost, delete the endpoint and create a new one.
Events
| Event | Sent when |
|---|---|
| message.sent | A message is handed to the network (or recorded, in sandbox) |
| message.delivered | The network confirms delivery |
| message.failed | A message fails before reaching the network, or the network reports it failed, rejected or expired |
| batch.completed | Every recipient of a batch has been sent, failed or skipped, or the batch was cancelled |
| sender_id.approved | Sendozi approves one of your Sender IDs |
| sender_id.rejected | Sendozi rejects one of your Sender IDs |
| wallet.funded | A card payment or bank transfer credits the wallet |
| wallet.low_balance | The balance falls below your low-balance threshold (at most once a day) |
Subscribe to message.failed alongside message.delivered; without it a failure looks like silence. message.sent is one event per recipient, so on a large campaign it doubles your webhook traffic; subscribe to it only if you use it.
The request Sendozi sends
| Header | Value |
|---|---|
| content-type | application/json |
| user-agent | Sendozi-Webhooks/1.0 |
| x-sendozi-event | The event name, such as message.delivered |
| x-sendozi-timestamp | Unix time in seconds when this attempt was signed |
| x-sendozi-signature | Hex HMAC-SHA256 of timestamp + "." + raw body |
{
"id": "evt_msg_7c1e0f9a2b3d4e5f_delivered",
"event": "message.delivered",
"created_at": "2026-09-24T10:15:09.870Z",
"data": {
"message_id": "msg_7c1e0f9a2b3d4e5f",
"status": "delivered",
"recipient": "+2348012345678",
"sender": "YourBrand",
"request_id": "req_9d8e7f6a5b4c3d2e",
"delivered_at": "2026-09-24T10:15:09.870Z",
"error": null
}
}{
"id": "evt_msg_7c1e0f9a2b3d4e5f_failed",
"event": "message.failed",
"created_at": "2026-09-24T10:15:10.020Z",
"data": {
"message_id": "msg_7c1e0f9a2b3d4e5f",
"status": "failed",
"recipient": "+2348012345678",
"sender": "YourBrand",
"request_id": "req_9d8e7f6a5b4c3d2e",
"delivered_at": null,
"error": { "code": "delivery_failed", "message": "The network could not deliver the message." }
}
}{
"id": "evt_msg_7c1e0f9a2b3d4e5f_sent",
"event": "message.sent",
"created_at": "2026-09-24T10:15:02.114Z",
"data": {
"message_id": "msg_7c1e0f9a2b3d4e5f",
"batch_id": "bat_5f45b8c07bd844c8",
"recipient": "+2348012345678",
"sender": "YourBrand",
"status": "accepted",
"sms_route": "promotional",
"request_id": "req_9d8e7f6a5b4c3d2e"
}
}{
"id": "evt_bat_5f45b8c07bd844c8_completed",
"event": "batch.completed",
"created_at": "2026-09-24T09:41:12.300Z",
"data": {
"batch_id": "bat_5f45b8c07bd844c8",
"status": "partially_completed",
"requested_count": 50000,
"accepted_count": 49812,
"delivered_count": 47120,
"failed_count": 1203,
"estimated_cost_kobo": 34868400,
"actual_cost_kobo": 34026300
}
}{ "event": "sender_id.approved",
"data": { "sender_id": "sid_2c4e6a8b0d1f3e5a", "sender": "AcmeAlerts", "status": "active", "sms_route": "transactional" } }
{ "event": "sender_id.rejected",
"data": { "sender_id": "sid_2c4e6a8b0d1f3e5a", "sender": "AcmeAlerts", "status": "rejected", "reason": "The CAC certificate does not match the brand name." } }{ "event": "wallet.funded",
"data": { "amount_kobo": 5000000, "amount": "NGN 50,000.00", "balance_after_kobo": 7500000,
"reference": "...", "source": "virtual_account", "ledger_id": "..." } }
{ "event": "wallet.low_balance",
"data": { "balance_kobo": 180000, "threshold_kobo": 500000, "balance": "NGN 1,800.00", "threshold": "NGN 5,000.00" } }{ "id": "evt_test_req_...", "event": "test.ping", "created_at": "...",
"data": { "message": "This is a Sendozi test event.", "request_id": "req_..." } }error.codeonmessage.failedis one of the codes in why a message failed, includingRECIPIENT_DNDfor a Do Not Disturb block andDELIVERY_UNCONFIRMEDwhen the network never answered.batch_idis onmessage.sent, and onmessage.failedwhen the message failed before reaching the network. A delivery report event carries themessage_idonly; map it to the batch through your own records or the batch's recipients.- A cancelled batch sends
batch.completedwith"status": "cancelled"and acancelled_count. wallet.fundedsourceispaystackfor a card payment andvirtual_accountfor a bank transfer.- Expect new fields to be added to
data. Ignore fields you do not use.
Verifying the signature
Compute HMAC-SHA256 of the x-sendozi-timestamp header, a full stop, and the raw request body, keyed with the endpoint's signing secret, as lowercase hex. Compare it with x-sendozi-signature in constant time, and reject the request before parsing it if they differ.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.SENDOZI_WEBHOOK_SECRET;
app.post("/webhooks/sendozi", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("x-sendozi-timestamp") ?? "";
const signature = req.get("x-sendozi-signature") ?? "";
const expected = crypto.createHmac("sha256", SECRET).update(`${timestamp}.${req.body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!fresh || !valid) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
queue.add(event); // hand it off...
res.status(200).end(); // ...and answer at once
});import hashlib, hmac, json, os, time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["SENDOZI_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/sendozi")
def sendozi_webhook():
timestamp = request.headers.get("x-sendozi-timestamp", "")
signature = request.headers.get("x-sendozi-signature", "")
raw = request.get_data() # bytes, before any parsing
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
abort(401)
if not hmac.compare_digest(signature, expected):
abort(401)
enqueue(json.loads(raw))
return "", 200<?php
// Exclude this route from CSRF verification.
Route::post('/webhooks/sendozi', function (Illuminate\Http\Request $request) {
$raw = $request->getContent();
$timestamp = (string) $request->header('x-sendozi-timestamp');
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, config('services.sendozi.webhook_secret'));
if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) abort(401);
if (! hash_equals($expected, (string) $request->header('x-sendozi-signature'))) abort(401);
ProcessSendoziEvent::dispatch(json_decode($raw, true));
return response()->noContent();
});Delivery, retries and pausing
- Success
- Any 2xx response within 8 seconds. The body of your response is ignored. If your endpoint is slow while a large campaign is sending, Sendozi opens at most 16 requests to it at once and queues the rest, so events can arrive later than they happened; answer quickly to keep up.
- Retried
- A timeout, a connection error, a 5xx, 408 or 429. Sendozi tries three times in the first few seconds, then again after 1, 5, 15 and 60 minutes, 3, 6 and 12 hours and 1 day: about 46 hours in all, after which the event is abandoned.
- Not retried
- Any other 4xx, and any 3xx (redirects are not followed). Sendozi treats these as your endpoint refusing the event, so return 5xx, not 4xx, when you want an event sent again.
- Paused
- After three events in a row fail, the endpoint's status becomes paused and the workspace owner is emailed. New events are held, not dropped, for up to 72 hours.
- Resumed
- Send PATCH with {"status": "active"}, or resume it in the Console. The failure count is cleared and the held events are delivered straight away.
- Disabled
- PATCH with {"status": "disabled"} switches an endpoint off on purpose. Nothing is held for a disabled endpoint.
curl -X PATCH https://api.sendozi.com/v1/webhooks/endpoints/wh_6d8f0a2c4e6b8d0f \
-H "Authorization: Bearer $SENDOZI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "active" }'Rules for a receiver that holds up
- Answer first, work later
- Verify, queue the event and return 200. A campaign can report deliveries at up to the sending rate, and a receiver doing database work inline will time out and be retried.
- Deduplicate on id
- An event can arrive more than once; a retry of an event you did receive is normal. The
idis the same every time for the same event, so store it and ignore repeats. - Do not rely on order
- message.delivered can arrive before message.sent. Treat delivered, failed and expired as final, and never move a message from a final status back to accepted.
- Keep a reconciliation path
- For anything you bill or act on, confirm with GET /v1/messages/{id} or the batch's recipients. Webhooks are the fast path; the API is the record.
Testing
POST /v1/webhooks/endpoints/{id}/test sends a signed test.ping to that endpoint at once and returns 202 with the result of each attempt: delivered, attempts, status and error. A test never counts towards pausing and is never queued for retry. Sandbox sends also produce message.sent and batch.completed, but never message.delivered.
Frequently asked questions
- How do I verify a Sendozi webhook?
- Compute HMAC-SHA256 of the x-sendozi-timestamp header, a full stop and the raw body, keyed with the endpoint's signing secret, as hex. Compare it with x-sendozi-signature in constant time and reject any mismatch before parsing.
- Which events does Sendozi send?
- message.sent, message.delivered, message.failed, batch.completed, sender_id.approved, sender_id.rejected, wallet.funded and wallet.low_balance, plus test.ping from the test endpoint.
- Will the same webhook event arrive twice?
- It can, when a delivery is retried. The event id is the same each time, so store it and ignore repeats.
- What happens if my webhook endpoint is down?
- Failed deliveries are retried for about 46 hours. After three events in a row fail the endpoint is paused, the owner is emailed, and new events are held for up to 72 hours until the endpoint is resumed.
- I lost my webhook signing secret. Can I get it back?
- No. It is shown once. Delete the endpoint, create a new one, and store the new secret.
Related reading
Sending
Messages, batches and delivery status
Every send creates a batch of recipients, each becoming a message. How to look them up, what each status means, why a message failed, how to page through lists, and how to cancel a send.
Sending
Sending SMS
POST /v1/sms/send and /v1/sms/bulk: every request field, the response, how recipients are cleaned, limits, the delivery window, and exactly how a send is priced and charged.
Reliability
Errors and the response envelope
The Sendozi response envelope, every error code the API returns with its HTTP status and fix, which errors are safe to retry, Retry-After, and how request_id traces a request.
Reliability
Idempotency and safe retries
How the Idempotency-Key header works on Sendozi send endpoints, what a repeat returns, why the body must be byte-for-byte identical, how long keys last, and how to choose one.