G-SMS.

Envoyer un SMS au Cameroun en Python

Un script d'astreinte, une tâche Celery, une vue Django : dès qu'il faut prévenir quelqu'un au Cameroun, il faut un opérateur local. L'API G-SMS est une requête HTTP, donc une trentaine de lignes de requests suffisent. Cette page les donne, du premier envoi jusqu'à l'envoi en série et à la réception des accusés de remise.

Prérequis

  • Python 3.9 ou plus récent, et requests (pip install requests).
  • Une clé API G-SMS commençant par gsk_, créée dans la console pour une application donnée.
  • Un numéro camerounais. Trois écritures sont acceptées : +2376XXXXXXXX, 2376XXXXXXXX ou 6XXXXXXXX.
  • Du crédit sur le compte : chaque envoi est débité à l'acceptation.

La clé autorise à envoyer, donc à dépenser. Elle se lit dans l'environnement, jamais dans un settings.py versionné ni dans un carnet Jupyter partagé.

Le client

Deux codes de réponse sont des réponses normales et non des pannes : le 202, qui accepte l'envoi, et le 409, qui signale que ce message existe déjà. Ils sont donc retournés, pas levés. Tout le reste devient une exception qui porte le code d'erreur et le délai d'attente conseillé.

gsms.py

"""gsms.py — client G-SMS. Une seule dépendance : requests."""

import os

import requests

BASE_URL = os.environ.get("GSMS_URL", "https://api.sms.gworldship.com")
API_KEY = os.environ.get("GSMS_API_KEY", "gsk_votre_cle_api")

# Une Session réutilise la connexion TLS : sur une série d'envois, cela se voit.
session = requests.Session()
session.headers.update(
    {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
        "Accept": "application/json",
    }
)


class GSmsError(RuntimeError):
    """Erreur renvoyée par l'API — le 409, lui, n'en est pas une."""

    def __init__(self, status, code, message, retry_after=None):
        super().__init__(f"{code} ({status}) : {message}")
        self.status = status
        self.code = code
        self.retry_after = retry_after


def send_sms(to, text, client_reference=None):
    """POST /v1/sms.

    Renvoie (message, duplicate). duplicate vaut True sur un 409 : ce n'est pas
    une erreur, mais la confirmation que ce message a déjà été accepté.
    """
    payload = {"to": to, "text": text}
    if client_reference:
        payload["clientReference"] = client_reference

    response = session.post(f"{BASE_URL}/v1/sms", json=payload, timeout=15)
    body = response.json()

    if response.status_code == 202:
        return body, False
    if response.status_code == 409:
        return body, True

    retry_after = response.headers.get("Retry-After")
    raise GSmsError(
        response.status_code,
        body.get("code", "UNKNOWN"),
        body.get("message", ""),
        int(retry_after) if retry_after else None,
    )


def balance_xaf():
    """GET /v1/balance — solde du compte, en francs CFA."""
    response = session.get(f"{BASE_URL}/v1/balance", timeout=10)
    response.raise_for_status()
    return response.json()["balanceXaf"]

Premier envoi

client_reference est facultatif, mais c'est lui qui rend l'appel rejouable : au deuxième appel avec la même valeur, l'API renvoie le message d'origine avec un 409 au lieu d'envoyer et de facturer un second SMS. Prenez un identifiant stable de votre côté — commande, session, tentative de connexion — de 64 caractères au maximum.

envoyer.py

# envoyer.py
from gsms import GSmsError, send_sms

try:
    message, duplicate = send_sms(
        to="+237690000000",
        text="Votre code de connexion est 4821.",
        # Identifiant stable de votre côté : il rend l'appel rejouable. 64 caractères au plus.
        client_reference="login-2026-09-12-4821",
    )
except GSmsError as error:
    if error.code == "INSUFFICIENT_BALANCE":
        print("Crédit épuisé : rechargez, puis rejouez le même appel.")
    elif error.code == "RATE_LIMITED":
        print(f"Trop vite — réessayer dans {error.retry_after or 1} s")
    elif error.code == "OPERATOR_DISABLED":
        print("Opérateur suspendu, aucun débit. Reprogrammez dans une heure.")
    else:
        raise
else:
    if duplicate:
        print("Déjà envoyé :", message["messageId"], message["status"])
    else:
        print(
            "Accepté :",
            message["messageId"],
            message["operator"],
            message["segments"],
            "segment(s),",
            message["priceXaf"],
            "XAF, solde restant",
            message["balanceAfterXaf"],
            "XAF",
        )

Corps du 202 :

{
  "messageId": "8f14e45f-ceea-467a-9c1e-1a4f1c3b0d21",
  "to": "+237690000000",
  "operator": "ORANGE",
  "segments": 1,
  "priceXaf": 5,
  "status": "QUEUED",
  "clientReference": "login-2026-09-12-4821",
  "balanceAfterXaf": 95,
  "createdAt": "2026-09-12T08:41:07Z"
}

Les champs nuls sont omis : utilisez message.get("failureReason") plutôt que message["failureReason"], qui lèvera un KeyError sur un message qui n'a pas encore échoué. balanceAfterXaf n'existe que sur cette réponse de création.

Les réponses qu'il faut savoir traiter

StatutCodeSignificationQue faire
202—Accepté, débité, mis en file.Conserver messageId pour rapprocher le webhook.
402INSUFFICIENT_BALANCELe solde ne couvre pas l'envoi. Aucun débit.Recharger, puis rejouer avec la même référence.
409—Cette clientReference a déjà servi.Lire le message renvoyé ; ne rien renvoyer.
429RATE_LIMITEDPlus de 10 requêtes par seconde sur la clé.Attendre Retry-After (1 s), réessayer.
429QUOTA_EXCEEDEDQuota journalier atteint (désactivé par défaut).Reporter au lendemain ou demander un relèvement.
400INVALID_NUMBER, TEXT_EMPTY, TEXT_TOO_LONGRequête mal formée. Aucun débit.Corriger l'appel ; réessayer à l'identique ne changera rien.
503OPERATOR_DISABLEDEnvois suspendus vers cet opérateur. Aucun débit.Reprogrammer après Retry-After (une heure).
Le 409 est le piège de cette API. Son corps n'est pas un objet { code, message } comme les autres erreurs : c'est le message déjà enregistré, au format du 202 et sans balanceAfterXaf. Un response.raise_for_status() posé sur l'envoi transforme donc un SMS bel et bien parti en exception — et vous le renverrez.

Envoyer une liste sans se faire couper

La limite est de dix requêtes par seconde et par clé. Une boucle serrée sur mille numéros récoltera des 429 dès la première seconde. Cadencez, et donnez à chaque destinataire sa propre référence : une reprise après incident ne renverra alors rien deux fois.

lot.py

# lot.py — envoyer une liste sans se faire couper par la limite de débit.
import time

from gsms import GSmsError, send_sms

MAX_PER_SECOND = 10  # limite de l'API, comptée par clé


def send_batch(recipients, text):
    """recipients : une suite de couples (numéro, référence unique)."""
    started = time.monotonic()

    for index, (to, reference) in enumerate(recipients, start=1):
        try:
            send_sms(to, text, client_reference=reference)
        except GSmsError as error:
            if error.code == "RATE_LIMITED":
                time.sleep(error.retry_after or 1)
                # La référence rend ce second essai sûr : au pire, l'API répond 409.
                send_sms(to, text, client_reference=reference)
            elif error.code == "INSUFFICIENT_BALANCE":
                raise  # inutile de continuer, plus rien ne partira
            else:
                print("Échec définitif pour", to, ":", error)

        # Cadence : au plus MAX_PER_SECOND requêtes par seconde.
        time.sleep(max(0.0, started + index / MAX_PER_SECOND - time.monotonic()))

G-SMS est une API transactionnelle : codes de connexion, confirmations, rappels. Un envoi en série sert à prévenir une liste de clients d'un événement qui les concerne, pas à diffuser de la publicité à un fichier acheté.

Relire ce qui est parti

GET /v1/sms renvoie une page de messages, triée par date de création décroissante. Les filtres disponibles sont status, to, from, until, page (0 par défaut) et size (20 par défaut, 100 au maximum).

# gsms.py (suite)

def list_messages(status=None, since=None, page=0, size=50):
    """GET /v1/sms — tri createdAt décroissant, 100 éléments par page au maximum."""
    params = {"page": page, "size": size}
    if status:
        params["status"] = status  # QUEUED, SENT, DELIVERED ou FAILED
    if since:
        # « from » est un mot-clé Python : il passe forcément par le dictionnaire.
        params["from"] = since  # date ISO 8601, par exemple 2026-09-01T00:00:00Z

    response = session.get(f"{BASE_URL}/v1/sms", params=params, timeout=15)
    response.raise_for_status()
    body = response.json()
    return body["content"], body["totalElements"]


# Combien d'échecs aujourd'hui ?
echecs, total = list_messages(status="FAILED", since="2026-09-12T00:00:00Z")
print(total, "échec(s) — tous remboursés automatiquement")

Recevoir les accusés de remise

Le 202 dit « accepté », pas « reçu par le destinataire ». La suite arrive en push sur une URL que vous déclarez dans la console. Un seul événement existe, sms.sent, émis à chaque changement de statut : SENT, puis DELIVERED ou FAILED, ces deux derniers étant définitifs. Un FAILED est remboursé automatiquement, une seule fois ; failureReason porte la cause remontée par l'opérateur, ou TIMEOUT si rien n'a été confirmé en 24 h, ou SUBMIT_FAILED.

webhook.py

"""webhook.py — réception des statuts.

Exemple en Flask ; le principe est identique en Django ou FastAPI, à une
condition : lire le corps BRUT de la requête, jamais un JSON re-sérialisé.
"""

import hashlib
import hmac
import os
import time

from flask import Flask, request

SECRET = os.environ["GSMS_WEBHOOK_SECRET"].encode()

app = Flask(__name__)
handled = set()  # en production : Redis ou une table, pas la mémoire du processus


@app.post("/webhooks/gsms")
def gsms_webhook():
    raw = request.get_data()  # les octets reçus, tels quels
    timestamp = request.headers.get("X-GSms-Timestamp", "")
    signature = request.headers.get("X-GSms-Signature", "")

    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return "", 400  # hors de la fenêtre de tolérance de ±5 minutes

    expected = "sha256=" + hmac.new(
        SECRET, raw + b"." + timestamp.encode(), hashlib.sha256
    ).hexdigest()

    # compare_digest : comparaison à temps constant. Un == ici est une faille.
    if not hmac.compare_digest(expected, signature):
        return "", 401

    event = request.get_json()

    # Livraison « au moins une fois » : le même eventId peut arriver plusieurs fois.
    if event["eventId"] in handled:
        return "", 204
    handled.add(event["eventId"])

    data = event["data"]
    # event["applicationId"] contient le SLUG de l'application, pas son UUID.
    mettre_a_jour_statut(
        data["messageId"],
        data["status"],             # SENT, DELIVERED ou FAILED
        data.get("failureReason"),  # None tant qu'il n'y a pas d'échec
    )

    # 204 : tout code 2xx vaut accusé de réception. Répondez en moins de 10 s.
    return "", 204
  • La signature vaut HMAC_SHA256(secret, corps_brut + "." + timestamp), en hexadécimal minuscule, préfixée de sha256=.
  • Refusez un horodatage qui s'écarte de plus de cinq minutes de votre horloge : c'est la protection contre le rejeu.
  • Dédupliquez sur eventId, pas sur X-GSms-Delivery, qui change à chaque tentative.
  • applicationId contient le slug de l'application, pas son identifiant technique, malgré son nom.
  • À la différence des réponses de l'API, le corps du webhook conserve ses champs nuls : clientReference et failureReason sont toujours présents, à None le cas échéant.
  • En cas d'échec, les tentatives s'espacent : 1 min, 5 min, 30 min, 2 h, 12 h, puis toutes les 12 h, et cessent après 48 h. La livraison passe en EXHAUSTED et reste rejouable depuis la console.

Combien coûte l'exemple de cette page

Texte envoyé« Votre code de connexion est 4821. » — 33 caractères
EncodageGSM-7 : tous les caractères sont dans l'alphabet standard
Segments1 (jusqu'à 160 caractères en GSM-7)
Destinataire+237690000000 — préfixe 69, donc Orange
Coût5 XAF

5 XAF également vers MTN, 7 XAF vers Camtel et Nexttel, 10 XAF sur la route par défaut, celle des préfixes absents du plan de numérotation de l'ART. Un envoi en série de 200 destinataires Orange avec ce texte coûte donc 1 000 XAF.

Ce qui va vous surprendre

  • Un accent mal choisi double la facture. Tant que le texte tient dans l'alphabet GSM-7, un segment vaut 160 caractères ; sinon il tombe à 70. Le « é » et le « à » y sont, mais pas le « ç » minuscule, ni le « ê », ni l'apostrophe typographique « ’ », ni les guillemets « ». Un seul de ces caractères bascule le message entier. Au-delà d'un segment, la capacité descend à 153 caractères en GSM-7 et 67 en UCS-2. Ces tailles sont administrables : lisez le segments renvoyé plutôt que de le recalculer.
  • Le destinataire voit un numéro, pas un nom. Il n'existe pas d'expéditeur personnalisé : G-SMS achemine du transactionnel depuis une SIM camerounaise, et c'est ce numéro qui s'affiche. Nommez votre service dans le texte du message.
  • Les limites. 10 requêtes par seconde et par clé, 1 600 caractères par message, 64 caractères pour client_reference. Le quota journalier est désactivé par défaut.
  • Un message n'appartient qu'à son application.GET /v1/sms/{messageId} renvoie 404, et non 403, si la clé relève d'une autre application.

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

À lire ensuite