Skip to content
Sendozi

Reliability

Delivery webhooks

Register an endpoint with POST /v1/webhooks/endpoints and Sendozi posts delivery events to it as networks report them. Each endpoint has a signing secret, shown once at creation, which you use to verify every payload before acting on it.

By SendoziUpdated 3 min read

Managing endpoints

Method and pathPurpose
GET /v1/webhooks/endpointsList your endpoints
POST /v1/webhooks/endpointsCreate one. The signing secret is returned once.
PATCH /v1/webhooks/endpoints/{id}Update the URL or the subscribed events
DELETE /v1/webhooks/endpoints/{id}Remove it
POST /v1/webhooks/endpoints/{id}/testSend a test payload to it
Create an endpoint and test it
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://yourapp.com/hooks/sendozi",
    "events": ["message.delivered", "message.failed"]
  }'

curl -X POST https://api.sendozi.com/v1/webhooks/endpoints/whk_example/test \
  -H "Authorization: Bearer $SENDOZI_API_KEY"

Events

EventMeaning
message.deliveredA network confirmed the handset received the message
message.failedThe route or the network rejected it, or it expired undelivered

Subscribe to both. An integration that listens only for success cannot distinguish a failure from an event that has not arrived yet.

Verifying a payload

Your endpoint is a public URL that anyone can POST to. Compute HMAC-SHA256 over x-sendozi-timestamp + "." + raw request body using the endpoint's signing secret, compare it in constant time with x-sendozi-signature, and reject anything that does not match before parsing the body.

Verification, in the two frameworks people ask about most
Node.js (Express)
import express from "express";
import crypto from "node:crypto";

const app = express();
const SECRET = process.env.SENDOZI_WEBHOOK_SECRET;

app.post("/hooks/sendozi", express.raw({ type: "application/json" }), (req, res) => {
  const timestamp = req.get("x-sendozi-timestamp") ?? "";
  const provided = Buffer.from(req.get("x-sendozi-signature") ?? "");
  const computed = Buffer.from(crypto.createHmac("sha256", SECRET).update(timestamp + "." + req.body).digest("hex"));

  if (provided.length !== computed.length || !crypto.timingSafeEqual(provided, computed)) {
    return res.status(401).end();
  }

  const event = JSON.parse(req.body.toString("utf8"));

  res.status(200).end();          // acknowledge fast
  void handleAsync(event);        // then do the work
});
PHP (Laravel)
<?php
// routes/web.php - exclude this route from CSRF verification.

Route::post('/hooks/sendozi', function (Illuminate\Http\Request $request) {
    $raw = $request->getContent();
    $signed = $request->header('x-sendozi-timestamp') . '.' . $raw;
    $expected = hash_hmac('sha256', $signed, config('services.sendozi.webhook_secret'));

    if (! hash_equals($expected, (string) $request->header('x-sendozi-signature'))) {
        abort(401);
    }

    ProcessSendoziDelivery::dispatch(json_decode($raw, true));

    return response()->noContent();
});

Rules for a receiver that holds up

Respond in milliseconds
Acknowledge with 200, queue the event, and process out of band. A receiver that does database work inline will time out during a campaign.
Be idempotent
Treat an event id as unique and make a repeat a no-op. This lets you safely retry test deliveries and any event your own receiver replays.
Tolerate reordering
A delivered event can arrive before the sent event that preceded it. Order by the timestamps in the payload, not by arrival.
Never trust the payload as authorisation
A valid signature proves the event came from Sendozi. It does not authorise action on an account - look the message up against your own records first.
Return 5xx to ask for a retry
If you cannot process an event, fail loudly. A 200 tells Sendozi the event was handled, and it will not come back.
Log the request_id
It ties the event to the original send, which is what support needs when a delivery is disputed.

If you cannot receive webhooks

Some environments cannot expose a public endpoint. In that case poll GET /v1/messages with a cursor, keyed on created_at, and store the last cursor you processed. Poll on a schedule measured in minutes rather than seconds: delivery reports take as long as they take, and a tight loop only consumes your rate limit.

Cursor paging. limit defaults to 50 and is capped at 100.
cURL
curl "https://api.sendozi.com/v1/messages?limit=100&cursor=2026-08-21T09:00:00.000Z" \
  -H "Authorization: Bearer $SENDOZI_API_KEY"

Frequently asked questions

How do I verify a Sendozi webhook?
Compute an HMAC-SHA256 over the raw request body using the endpoint's signing secret, and compare it in constant time with the signature header. Reject mismatches before parsing the body. Use the endpoint's test call to confirm your receiver works.
Which events does Sendozi send?
message.delivered and message.failed. Subscribe to both, so a failure is not indistinguishable from silence.
Will the same webhook event arrive twice?
It can. Retries exist so a temporary outage on your side does not lose an event, which means your handler must be idempotent - key on message id and event type.
What should my endpoint return?
200 as soon as you have accepted the event, then process asynchronously. Return 5xx if you genuinely could not accept it, so it is retried.
Can I test a webhook before going live?
Yes. POST /v1/webhooks/endpoints/{id}/test sends a payload to your endpoint so you can confirm signature verification and handling before real traffic arrives.

Read the delivery guide

What each status means, and how to reconcile a campaign once it has run.