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,2376XXXXXXXXou6XXXXXXXX. - 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
| Statut | Code | Signification | Que faire |
|---|---|---|---|
| 202 | — | Accepté, débité, mis en file. | Conserver messageId pour rapprocher le webhook. |
| 402 | INSUFFICIENT_BALANCE | Le 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. |
| 429 | RATE_LIMITED | Plus de 10 requêtes par seconde sur la clé. | Attendre Retry-After (1 s), réessayer. |
| 429 | QUOTA_EXCEEDED | Quota journalier atteint (désactivé par défaut). | Reporter au lendemain ou demander un relèvement. |
| 400 | INVALID_NUMBER, TEXT_EMPTY, TEXT_TOO_LONG | Requête mal formée. Aucun débit. | Corriger l'appel ; réessayer à l'identique ne changera rien. |
| 503 | OPERATOR_DISABLED | Envois suspendus vers cet opérateur. Aucun débit. | Reprogrammer après Retry-After (une heure). |
{ 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 desha256=. - 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 surX-GSms-Delivery, qui change à chaque tentative. applicationIdcontient 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 :
clientReferenceetfailureReasonsont toujours présents, àNonele 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
EXHAUSTEDet reste rejouable depuis la console.
Combien coûte l'exemple de cette page
| Texte envoyé | « Votre code de connexion est 4821. » — 33 caractères |
| Encodage | GSM-7 : tous les caractères sont dans l'alphabet standard |
| Segments | 1 (jusqu'à 160 caractères en GSM-7) |
| Destinataire | +237690000000 — préfixe 69, donc Orange |
| Coût | 5 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
segmentsrenvoyé 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