G-SMS.

Statuts de livraison et webhook DLR

Un 202 signifie « accepté et payé », pas « reçu ». Entre l'acceptation et le téléphone du destinataire, un message traverse plusieurs états, et c'est le dernier qui détermine s'il est facturé ou remboursé. Deux façons de les suivre : interroger l'API, ou recevoir un webhook.

Cycle de vie d'un message

QUEUED → SENT → DELIVERED, ou bien FAILED à n'importe quel moment avant l'accusé de réception. DELIVERED et FAILED sont terminaux : une fois atteints, plus rien ne bouge. Un statut ne régresse jamais — un SENT qui arriverait après un DELIVERED est ignoré.

StatutSignificationFacturationTerminal
QUEUEDMessage accepté et payé, en attente de remise à l'opérateur.DébitéNon
SENTMessage remis à l'opérateur. Le terminal ne l'a pas encore confirmé.DébitéNon
DELIVEREDAccusé de réception du terminal reçu. Le message est arrivé.DébitéOui
FAILEDÉchec définitif. La cause est dans failureReason.RembourséOui

Un FAILED est recrédité automatiquement, pour le montant exact du débit, et une seule fois : le remboursement ne peut pas être compté deux fois, même si plusieurs composants signalent l'échec.

failureReason : pourquoi un message a échoué

ValeurSignification
TIMEOUTLe message est resté en QUEUED au-delà de sa durée de vie (24 heures par défaut). Il ne partira plus.
SUBMIT_FAILEDLa passerelle n'a pas réussi à prendre le message en charge, malgré ses réessais successifs.
(cause opérateur)La cause telle que l'opérateur l'a remontée : numéro injoignable, terminal éteint pendant toute la période de validité, abonné inconnu…

Un message sans accusé de réception n'est pas éternellement en attente : au bout de sa durée de vie il bascule en FAILED (TIMEOUT) et le crédit revient. Voir quotas et limites.

Lire l'état d'un message

GET /v1/sms/{messageId} renvoie la même structure que l'envoi, enrichie des horodatages atteints.

curl 'https://api.sms.gworldship.com/v1/sms/0d0a6f4e-1c3b-4f6a-9a1e-52a0f6d2b311' \
  -H 'Authorization: Bearer gsk_votre_cle_api'

Un message qui appartient à une autre application renvoie 404, jamais 403 : l'API ne révèle pas l'existence de ce qui ne t'appartient pas.

Journal des messages

GET /v1/sms liste les messages de l'application, du plus récent au plus ancien. Filtres disponibles : status, to, from, until, plus page (0 par défaut) et size (20 par défaut, 100 au maximum — une valeur supérieure est ramenée à 100).

curl 'https://api.sms.gworldship.com/v1/sms?status=FAILED&from=2026-09-01T00:00:00Z&size=50' \
  -H 'Authorization: Bearer gsk_votre_cle_api'
{
  "content": [ /* … tableau de messages, même forme que GET /v1/sms/{messageId} … */ ],
  "page": 0,
  "size": 50,
  "totalElements": 137
}
Interroger l'API en boucle pour guetter un changement de statut gaspille ton quota de requêtes et retarde la détection. Pour du temps réel, le webhook est la bonne réponse ; le journal sert au rattrapage et à la réconciliation.

Le webhook sms.sent

Un endpoint se déclare dans la console, pour une application donnée. À sa création, G-SMS génère un secret — c'est lui qui sert à signer chaque envoi.

Il n'existe qu'un seul type d'événement, sms.sent, et il est émis à chaque changement de statut, pas seulement à l'expédition. Son nom est donc trompeur : il faut le lire comme « nouvelle d'un message » et se fier au champ data.status, jamais au type d'événement.

En-têteSignification
X-GSms-TimestampInstant de la tentative, en secondes depuis l'époque Unix. Entre dans le calcul de la signature.
X-GSms-Signaturesha256= suivi de la signature hexadécimale en minuscules.
X-GSms-EventType d'événement. Il n'en existe qu'un : sms.sent.
X-GSms-DeliveryIdentifiant de la tentative. Il change à chaque réessai, contrairement à eventId.
Content-Typeapplication/json. Le corps est brut, sans encodage de formulaire.

Le corps exact, en-têtes compris :

POST /votre-endpoint HTTP/1.1
Content-Type: application/json
X-GSms-Timestamp: 1789041300
X-GSms-Signature: sha256=6f1b2c…
X-GSms-Event: sms.sent
X-GSms-Delivery: 3f2a91c4-5d77-4f0a-9f1b-2c8d4e6a7b01

{
  "eventId": "8b7d2c11-9f04-4a55-9d2e-1c0a6f3b4d77",
  "type": "sms.sent",
  "occurredAt": "2026-09-12T10:15:07Z",
  "applicationId": "boutique-en-ligne",
  "data": {
    "messageId": "0d0a6f4e-1c3b-4f6a-9a1e-52a0f6d2b311",
    "clientReference": "commande-10427-confirmation",
    "to": "+237677123456",
    "operator": "MTN",
    "segments": 1,
    "priceXaf": 5,
    "status": "DELIVERED",
    "failureReason": null
  }
}
Piège à connaître : applicationId contient le slug de l'application, pas son identifiant technique, malgré son nom. Si ton code range cette valeur dans une colonne UUID, il échouera au premier événement reçu.

Dans le corps du webhook, contrairement aux réponses de l'API, clientReference et failureReason sont toujours présents : ils valent null quand il n'y a rien à dire.

Tout code 2xx vaut accusé de réception. Le délai d'attente est de 10 secondes : réponds d'abord, traite ensuite. Un endpoint qui écrit en base, appelle un tiers puis répond finit par dépasser ce délai et déclenche des réessais inutiles.

Vérifier la signature

L'URL d'un webhook est publique : sans vérification, n'importe qui peut y poster un faux DELIVERED. La signature se recalcule ainsi, avec le secret de l'endpoint :

signature = "sha256=" + hex_minuscule( HMAC_SHA256( secret, corps_brut + "." + timestamp ) )
  • Le corps brut, exactement tel qu'il est reçu. Décoder puis ré-encoder le JSON change un espace ou un ordre de clés, donc change la signature.
  • Le timestamp est celui de l'en-tête, concaténé après un point. Refuse un horodatage qui s'écarte de plus de 300 secondes de l'heure courante.
  • La comparaison doit être à temps constant (hash_equals, timingSafeEqual). Une comparaison ordinaire s'arrête au premier caractère différent, et cette différence de durée suffit à reconstituer une signature valide, caractère par caractère.

En PHP

<?php
// Le corps BRUT est indispensable : re-sérialiser le JSON change la signature.
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_GSMS_TIMESTAMP'] ?? '';
$received = $_SERVER['HTTP_X_GSMS_SIGNATURE'] ?? '';

// Fenêtre de 5 minutes : au-delà, on refuse (protection contre le rejeu).
if ($timestamp === '' || abs(time() - (int) $timestamp) > 300) {
    http_response_code(400);
    exit;
}

$expected = 'sha256=' . hash_hmac(
    'sha256',
    $raw . '.' . $timestamp,
    getenv('GSMS_WEBHOOK_SECRET')
);

// hash_equals : comparaison à temps constant, jamais === sur une signature.
if (!hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);

// Livraison « au moins une fois » : le même eventId peut revenir.
if (evenement_deja_traite($event['eventId'])) {
    http_response_code(200);
    exit;
}

enregistrer_statut($event['data']['messageId'], $event['data']['status']);
marquer_evenement_traite($event['eventId']);
http_response_code(200);

En Node.js

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();

// express.raw : on a besoin du corps BRUT, pas d'un objet déjà reparsé.
app.post('/webhooks/gsms', express.raw({ type: 'application/json' }), (req, res) => {
    const raw = req.body.toString('utf8');
    const timestamp = req.get('X-GSms-Timestamp') ?? '';
    const received = req.get('X-GSms-Signature') ?? '';

    // Fenêtre de 5 minutes autour de l'horodatage signé.
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
        return res.sendStatus(400);
    }

    const expected =
        'sha256=' +
        createHmac('sha256', process.env.GSMS_WEBHOOK_SECRET)
            .update(raw + '.' + timestamp)
            .digest('hex');

    // timingSafeEqual exige deux tampons de même longueur : on teste avant.
    const a = Buffer.from(expected);
    const b = Buffer.from(received);
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
        return res.sendStatus(401);
    }

    const event = JSON.parse(raw);

    // Livraison « au moins une fois » : dédupliquer sur eventId, pas sur messageId.
    if (dejaTraite(event.eventId)) {
        return res.sendStatus(200);
    }

    enregistrerStatut(event.data.messageId, event.data.status);
    marquerTraite(event.eventId);
    res.sendStatus(200);
});

Réessais et déduplication

Une livraison en échec — code non 2xx, délai dépassé, serveur injoignable — est reprogrammée selon un intervalle croissant :

TentativeDélai
1re tentativedans les secondes qui suivent le changement de statut
2e1 minute après le premier échec
3e5 minutes
4e30 minutes
5e2 heures
6e12 heures
suivantestoutes les 12 heures
abandon48 heures après la création de l'événement : la livraison passe EXHAUSTED

Une livraison abandonnée reste visible dans la console et peut être rejouée manuellement. Une panne de quelques heures côté client ne fait donc rien perdre.

La livraison est garantie « au moins une fois », pas « exactement une fois » : un même événement peut arriver deux fois, typiquement si ta réponse s'est perdue en chemin. La déduplication se fait sur eventId, qui reste identique d'une tentative à l'autre — jamais sur X-GSms-Delivery, qui change à chaque essai, ni sur messageId, qui revient à chaque changement de statut.

Dernier point d'ordre : rien ne garantit que les événements arrivent dans l'ordre des statuts. Compare avant d'écrire, et ignore une nouvelle qui ferait reculer l'état d'un message déjà terminal.

Prêt à envoyer ton premier SMS ?

Crée un compte, reçois 100 F de crédit et une clé API immédiate.

Créer un compte