Skip to content
Sendozi

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.

By SendoziUpdated 5 min read

Managing endpoints

EndpointScopePurpose
GET /v1/webhooks/endpointswebhooks:readList your endpoints with their status and last failure
POST /v1/webhooks/endpointswebhooks:manageCreate one. The signing secret is returned once
PATCH /v1/webhooks/endpoints/{id}webhooks:manageChange the url or events, disable it, or resume it
DELETE /v1/webhooks/endpoints/{id}webhooks:manageRemove it
POST /v1/webhooks/endpoints/{id}/testwebhooks:manageSend 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.

Create an endpoint
cURL
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"]
  }'
201 Created
{
  "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"
}
  • url must be https:// 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.
  • events not recognised are ignored. If none is given, or none is recognised, the endpoint gets message.delivered and message.failed.
  • The signing secret is returned once and stored encrypted. If it is lost, delete the endpoint and create a new one.

Events

EventSent when
message.sentA message is handed to the network (or recorded, in sandbox)
message.deliveredThe network confirms delivery
message.failedA message fails before reaching the network, or the network reports it failed, rejected or expired
batch.completedEvery recipient of a batch has been sent, failed or skipped, or the batch was cancelled
sender_id.approvedSendozi approves one of your Sender IDs
sender_id.rejectedSendozi rejects one of your Sender IDs
wallet.fundedA card payment or bank transfer credits the wallet
wallet.low_balanceThe 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

HeaderValue
content-typeapplication/json
user-agentSendozi-Webhooks/1.0
x-sendozi-eventThe event name, such as message.delivered
x-sendozi-timestampUnix time in seconds when this attempt was signed
x-sendozi-signatureHex HMAC-SHA256 of timestamp + "." + raw body
Every body has id, event, created_at and data
message.delivered
{
  "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
  }
}
message.failed
{
  "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." }
  }
}
message.sent
{
  "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"
  }
}
batch.completed
{
  "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
  }
}
The other events
sender_id.*
{ "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." } }
wallet.*
{ "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" } }
test.ping
{ "id": "evt_test_req_...", "event": "test.ping", "created_at": "...",
  "data": { "message": "This is a Sendozi test event.", "request_id": "req_..." } }
  • error.code on message.failed is one of the codes in why a message failed, including RECIPIENT_DND for a Do Not Disturb block and DELIVERY_UNCONFIRMED when the network never answered.
  • batch_id is on message.sent, and on message.failed when the message failed before reaching the network. A delivery report event carries the message_id only; map it to the batch through your own records or the batch's recipients.
  • A cancelled batch sends batch.completed with "status": "cancelled" and a cancelled_count.
  • wallet.funded source is paystack for a card payment and virtual_account for 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.

Verification
Node.js (Express)
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
});
Python (Flask)
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 (Laravel)
<?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.
Resume a paused endpoint
cURL
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 id is 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.

Track every message

See how message and batch statuses fit together.