Aller au contenu

Guides

Webhooks

Recevez les résultats de livraison sur votre propre serveur, et vérifiez qu’ils viennent bien de SMSend.

Événements

Quatre types d’événements sont publiés. Deux d’entre eux se déclenchent exactement une fois et peuvent porter sans risque votre rapprochement de facturation ; les deux autres sont sur abonnement explicite.

ValeurSignification
MESSAGE_TERMINAL
Un message a atteint un état terminal. Se déclenche exactement une fois par message, et une seule.
BATCH_SETTLED
Un lot est terminé et les comptes sont soldés. Se déclenche une fois par lot, et porte le seul chiffre de remboursement qui existe.
BATCH_ACCEPTED
Un lot a été accepté, tarifé et réservé. Se déclenche une fois par lot, après la validation de la transaction.
MESSAGE_STATUS_CHANGED
Chaque transition de statut, y compris les intermédiaires. Volume élevé — abonnez-vous en connaissance de cause.

La charge utile

Chaque événement a la même enveloppe. L’identifiant est celui de la livraison elle-même et correspond à l’en-tête de livraison, ce qui vous permet de relier une reprise à la tentative qui a échoué.

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
  }
}

Les événements par message ne portent aucun montant

L’événement de règlement est l’endroit où vivent les chiffres définitifs, remboursement compris.

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"
  }
}

En-têtes de requête

Chaque livraison porte le type d’événement, l’identifiant de livraison et la 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

Vérifier la signature

Chaque livraison est signée par un HMAC calculé sur l’horodatage et le corps brut, avec le secret propre à votre point de terminaison. Vérifiez-le à chaque requête — un point de terminaison webhook non vérifié est une voie d’écriture non authentifiée vers votre système.

La chaîne signée
HMAC-SHA256( secret, "{t}." + raw_request_body )

Signez les octets que vous avez reçus, pas l’analyse que vous en faites

  1. Lisez le corps brut de la requête sous forme d’octets, avant toute analyse.
  2. Extrayez les valeurs t et v1 de l’en-tête de signature. Rejetez si l’une des deux est absente ou vide, ou si t ne fait pas de 1 à 12 chiffres.
  3. Rejetez si l’horodatage s’écarte de plus de 300 secondes de votre propre horloge, dans un sens comme dans l’autre. C’est ce qui empêche le rejeu ultérieur d’une requête capturée.
  4. Calculez le HMAC et comparez-le en temps constant. Une comparaison de chaînes ordinaire fuite la bonne signature octet par octet.
  5. Pendant une fenêtre de rotation, acceptez le secret actuel ou le précédent. SMSend signe toujours avec le plus récent.
<?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/^.*= //'

Ordre

Chaque livraison porte un numéro de séquence qui croît de façon monotone pour votre point de terminaison. Le compteur est propre au point de terminaison plutôt que global : il ne vous apprend donc rien sur le volume total de SMSend.

L’ordre d’arrivée n’est pas garanti

Livraison et reprises

Tout code 2xx compte comme un succès, et rien d’autre. Les redirections ne sont pas suivies : un 3xx est enregistré comme un échec, parce que le suivre permettrait à un enregistrement DNS compromis de détourner silencieusement votre trafic webhook ailleurs.

Calendrier des reprises
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
Le succès, c’est
2xx
Délai d’expiration de la requête
10 s
Nombre maximal de tentatives
6
Redirections
not followed
Tolérance de signature
±300 s
Désactivation automatique après N échecs consécutifs
20
Délai de grâce lors du renouvellement du secret
24 h
Historique des livraisons conservé
30 days

Après six tentatives, l’événement est perdu

Désactivation automatique

Un point de terminaison qui échoue 20 fois d’affilée est coupé et le compte reçoit un courriel. Le compteur revient à zéro au moindre succès : il mesure donc une panne durable plutôt qu’une accumulation de malchance sur plusieurs mois.

Renouveler un secret

Le renouvellement émet un nouveau secret et garde l’ancien valide pendant 24 heures. Les deux permettent la vérification pendant la fenêtre, tandis que SMSend signe avec le nouveau : vous pouvez donc déployer à votre rythme — mais l’ancien secret expire à une heure fixe, et la réponse vous donne l’échéance exacte à respecter.

Exigences du point de terminaison

L’URL doit utiliser HTTPS et ne doit pas se résoudre vers une adresse privée, de bouclage ou de lien local. Un nom d’hôte dont le DNS n’a pas encore propagé est accepté, car un nouveau point de terminaison est souvent enregistré avant d’être en ligne.