Skip to content

Guides

Webhooks

Receive delivery outcomes on your own server, and verify that they really came from SMSend.

Events

Four event types are published. Two of them fire exactly once and are safe to hang billing reconciliation on; the other two are opt-in.

ValueMeaning
MESSAGE_TERMINAL
A message reached a terminal state. Fires exactly once per message, ever.
BATCH_SETTLED
A batch finished and the money is squared. Fires once per batch, and carries the only refund figure that exists.
BATCH_ACCEPTED
A batch was accepted, priced and reserved. Fires once per batch, after the transaction commits.
MESSAGE_STATUS_CHANGED
Every status transition, including intermediate ones. High volume — subscribe deliberately.

The payload

Every event has the same envelope. The id is the delivery's own identifier and matches the delivery header, so you can correlate a retry with the attempt that failed.

MESSAGE_TERMINAL
{
  "id": "01K3F9DELIVERYULID00000000",
  "type": "MESSAGE_TERMINAL",
  "sequence": 4711,
  "created_at": "2026-08-23T14:05:22+00:00",
  "api_version": "v1",
  "data": {
    "message_id": "01K3F7Y1A4B8C2D6E0F4G8H2JK",
    "batch_id": "01K3F7XQZ8V2N4M6P8R0T5CJWE",
    "msisdn": "+22670000001",
    "status": "DELIVERED",
    "previous_status": "SENT",
    "channel": "SMS",
    "message_class": "TRANSACTIONAL",
    "segments": 1,
    "encoding": "GSM7",
    "submitted_at": "2026-08-23T14:05:13+00:00",
    "delivered_at": "2026-08-23T14:05:19+00:00",
    "failed_reason": null
  }
}

Per-message events carry no money

The settlement event is where the final figures live, including the refund.

BATCH_SETTLED
{
  "id": "01K3F9DELIVERYULID00000003",
  "type": "BATCH_SETTLED",
  "sequence": 5210,
  "created_at": "2026-08-23T16:41:55+00:00",
  "api_version": "v1",
  "data": {
    "batch_id": "01K3F7XQZ8V2N4M6P8R0T5CJWE",
    "status": "SETTLED",
    "trigger": "COMPLETE",
    "total": 948,
    "delivered": 900,
    "failed": 35,
    "cancelled": 13,
    "dropped": 52,
    "refunded_amount": 1200,
    "currency": "XOF"
  }
}

Request headers

Each delivery carries the event type, the delivery id and the signature.

Content-Type: application/json
User-Agent: SMSend-Webhooks/1.0
X-SMSend-Event: MESSAGE_TERMINAL
X-SMSend-Delivery: 01K3F9DELIVERYULID00000000
X-SMSend-Signature: t=1787654461,v1=8f3a2c91b7e0d4562a8fc10e93b7d5426af08c13d9e27540ba61c8fd3e094a72

Verifying the signature

Every delivery is signed with an HMAC over the timestamp and the raw body, using your endpoint's own secret. Verify it on every request — an unverified webhook endpoint is an unauthenticated write path into your system.

The signed string
HMAC-SHA256( secret, "{t}." + raw_request_body )

Sign the bytes you received, not your parse of them

  1. Read the raw request body as bytes, before parsing.
  2. Parse the t and v1 values from the signature header. Reject if either is missing or empty, or if t is not 1 to 12 digits.
  3. Reject if the timestamp is more than 300 seconds away from your own clock, in either direction. This is what stops a captured request being replayed later.
  4. Compute the HMAC and compare it in constant time. A plain string comparison leaks the correct signature one byte at a time.
  5. During a rotation window, accept either the current or the previous secret. SMSend always signs with the newer one.
<?php

function verifySmsendSignature(
    string $header,
    string $rawBody,
    array $secrets,
    int $toleranceSeconds = 300,
): bool {
    $parts = [];

    foreach (explode(',', $header) as $pair) {
        [$key, $value] = array_pad(explode('=', trim($pair), 2), 2, null);
        $parts[$key] = $value;
    }

    $timestamp = $parts['t'] ?? null;
    $signature = $parts['v1'] ?? null;

    if ($timestamp === null || $signature === null || $signature === '') {
        return false;
    }

    if (! preg_match('/^\d{1,12}$/', $timestamp)) {
        return false;
    }

    if (abs(time() - (int) $timestamp) > $toleranceSeconds) {
        return false;
    }

    // Accept either secret during a rotation window.
    foreach ($secrets as $secret) {
        $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

// Read the RAW body — never a re-encoded parse.
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_SMSEND_SIGNATURE'] ?? '';

if (! verifySmsendSignature($header, $rawBody, [$currentSecret, $previousSecret])) {
    http_response_code(400);
    exit;
}

$event = json_decode($rawBody, true);
import crypto from 'node:crypto'

function verifySmsendSignature(header, rawBody, secrets, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((pair) => pair.trim().split('=', 2)),
  )

  const timestamp = parts.t
  const signature = parts.v1

  if (!timestamp || !signature) return false
  if (!/^\d{1,12}$/.test(timestamp)) return false

  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (skew > toleranceSeconds) return false

  // Accept either secret during a rotation window.
  return secrets.some((secret) => {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex')

    return (
      expected.length === signature.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
    )
  })
}

// Express: mount express.raw() so req.body is the exact bytes received.
app.post('/smsend', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8')

  if (!verifySmsendSignature(req.get('X-SMSend-Signature') ?? '', rawBody, secrets)) {
    return res.sendStatus(400)
  }

  const event = JSON.parse(rawBody)
  res.sendStatus(200)
})
import hashlib
import hmac
import re
import time


def verify_smsend_signature(header, raw_body, secrets, tolerance_seconds=300):
    parts = dict(
        pair.strip().split("=", 1) for pair in header.split(",") if "=" in pair
    )

    timestamp = parts.get("t")
    signature = parts.get("v1")

    if not timestamp or not signature:
        return False

    if not re.fullmatch(r"\d{1,12}", timestamp):
        return False

    if abs(int(time.time()) - int(timestamp)) > tolerance_seconds:
        return False

    # Accept either secret during a rotation window.
    signed = f"{timestamp}.{raw_body}".encode()

    return any(
        hmac.compare_digest(
            hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest(),
            signature,
        )
        for secret in secrets
    )


# Flask: request.get_data() returns the exact bytes received.
@app.post("/smsend")
def smsend_webhook():
    raw_body = request.get_data(as_text=True)
    header = request.headers.get("X-SMSend-Signature", "")

    if not verify_smsend_signature(header, raw_body, secrets):
        return "", 400

    event = request.get_json()
    return "", 200
# Reproduce a signature by hand to debug a mismatch.
TIMESTAMP=1787654461
SECRET="whsec_3f8a1c9e0b7d452a6e18cf30b95d7e421ac6f08b3d92e574a10c8fb26d94e753"
BODY='{"id":"01K3F9DELIVERYULID00000000","type":"MESSAGE_TERMINAL"}'

printf '%s.%s' "$TIMESTAMP" "$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex \
  | sed 's/^.*= //'

Ordering

Each delivery carries a sequence number that increases monotonically for your endpoint. The counter is per endpoint rather than global, so it tells you nothing about SMSend's total volume.

Order of arrival is not guaranteed

Delivery and retries

Any 2xx counts as success and nothing else does. Redirects are not followed: a 3xx is recorded as a failure, because following one would let a compromised DNS record silently move your webhook traffic elsewhere.

Retry schedule
attempt 1  →  immediately
attempt 2  →  after 1 minute
attempt 3  →  after 5 minutes
attempt 4  →  after 15 minutes
attempt 5  →  after 1 hour
attempt 6  →  after 4 hours
Success is
2xx
Request timeout
10 s
Maximum attempts
6
Redirects
not followed
Signature tolerance
±300 s
Auto-disable after N consecutive failures
20
Secret rotation grace period
24 h
Delivery history retained
30 days

After six attempts the event is gone

Auto-disabling

An endpoint that fails 20 times in a row is switched off and the account is emailed. The counter resets to zero on any success, so it measures a sustained outage rather than accumulated bad luck over months.

Rotating a secret

Rotating issues a new secret and keeps the old one valid for 24 hours. Both verify during the window while SMSend signs with the new one, so you can deploy at your own pace — but the old secret expires on a clock, and the response tells you the exact deadline you are working to.

Endpoint requirements

The URL must use HTTPS and must not resolve to a private, loopback or link-local address. A hostname whose DNS has not propagated yet is accepted, since a new endpoint is often registered before it is live.