Envoyer un SMS au Cameroun en PHP
Vous avez une application PHP à envoyer un code de connexion, une confirmation de commande ou un rappel à un numéro Orange, MTN, Camtel ou Nexttel. Il n'y a rien à installer : l'API G-SMS est une requête HTTP, et l'extension cURL livrée avec PHP suffit. Cette page donne le code complet, du premier envoi jusqu'à la réception des accusés de remise.
Prérequis
- PHP 8.0 ou plus récent, avec l'extension
curl(elle est activée par défaut chez la quasi-totalité des hébergeurs). - Un compte G-SMS et une clé API, qui commence par
gsk_. Elle est créée depuis la console, pour une application donnée. - Un numéro de destination camerounais. Trois écritures sont acceptées :
+2376XXXXXXXX,2376XXXXXXXXou6XXXXXXXX. - Du crédit sur le compte. L'inscription en offre de quoi faire les premiers essais.
La clé porte tous les droits d'envoi de l'application : elle n'a rien à faire dans un dépôt Git ni dans du JavaScript de navigateur. Chargez-la depuis une variable d'environnement.
Le client, en une trentaine de lignes
Une seule fonction de transport, réutilisée pour tous les appels. Elle ne lève d'exception que si le serveur est injoignable : les codes 4xx sont des réponses, pas des accidents, et deux d'entre eux se traitent normalement.
gsms.php
<?php
/**
* gsms.php — client G-SMS minimal. Dépendances : PHP 8.0 et l'extension cURL.
*/
const GSMS_BASE_URL = 'https://api.sms.gworldship.com';
const GSMS_API_KEY = 'gsk_votre_cle_api'; // en production : getenv('GSMS_API_KEY')
/**
* Renvoie le code HTTP, le corps décodé et les en-têtes de réponse.
* Aucune exception sur un code 4xx : ici, un 409 n'est pas une panne.
*
* @return array{status:int, body:array, headers:array<string,string>}
*/
function gsms_request(string $method, string $path, ?array $payload = null): array
{
$headers = [];
$ch = curl_init(GSMS_BASE_URL . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GSMS_API_KEY,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_HEADERFUNCTION => function ($curl, string $line) use (&$headers): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line); // cURL exige la longueur de la ligne brute
},
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
}
$raw = curl_exec($ch);
if ($raw === false) {
$message = curl_error($ch);
curl_close($ch);
throw new RuntimeException('G-SMS injoignable : ' . $message);
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return ['status' => $status, 'body' => json_decode($raw, true) ?? [], 'headers' => $headers];
}
/** POST /v1/sms — clientReference facultative, mais c'est elle qui protège du doublon. */
function gsms_send_sms(string $to, string $text, ?string $clientReference = null): array
{
$payload = ['to' => $to, 'text' => $text];
if ($clientReference !== null) {
$payload['clientReference'] = $clientReference;
}
return gsms_request('POST', '/v1/sms', $payload);
}Premier envoi
Le champ clientReference est facultatif, mais c'est lui qui rend l'appel rejouable : si votre script est relancé, le second appel renvoie le message d'origine au lieu d'envoyer un deuxième SMS. Choisissez une valeur stable et unique de votre côté — un identifiant de commande, de session ou de tentative de connexion — de 64 caractères au maximum.
envoyer.php
<?php
require __DIR__ . '/gsms.php';
$result = gsms_send_sms(
to: '+237690000000',
text: 'Votre code de connexion est 4821.',
clientReference: 'login-2026-09-12-4821',
);
$body = $result['body'];
switch ($result['status']) {
case 202:
// Accepté et débité. Le SMS n'est pas encore remis : status vaut QUEUED.
printf(
"Accepté %s — %s, %d segment(s), %d XAF, solde restant %d XAF\n",
$body['messageId'], $body['operator'], $body['segments'],
$body['priceXaf'], $body['balanceAfterXaf']
);
break;
case 409:
// Le corps n'est PAS {code, message} : c'est le message déjà enregistré
// sous cette clientReference. Rien n'a été débité une seconde fois.
printf("Doublon : le message %s existe déjà (statut %s)\n",
$body['messageId'], $body['status']);
break;
case 402:
// INSUFFICIENT_BALANCE : aucun débit. Rechargez, puis rejouez le même appel.
fwrite(STDERR, "Solde insuffisant.\n");
break;
case 429:
// RATE_LIMITED (plus de 10 requêtes/seconde) ou QUOTA_EXCEEDED (quota du jour).
$wait = (int) ($result['headers']['retry-after'] ?? 1);
fwrite(STDERR, "Trop vite ({$body['code']}) — réessayer dans {$wait} s\n");
break;
case 503:
// OPERATOR_DISABLED : les envois vers cet opérateur sont coupés, aucun débit.
fwrite(STDERR, "Opérateur indisponible, reprogrammez l'envoi.\n");
break;
default:
fwrite(STDERR, sprintf("Erreur %d : %s — %s\n", $result['status'],
$body['code'] ?? '?', $body['message'] ?? ''));
}Réponse attendue :
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"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 de la réponse : sur un message qui vient d'être accepté, ni failureReason ni deliveredAt n'apparaissent. Testez leur présence avec ?? plutôt que de supposer la clé présente. À l'inverse, balanceAfterXaf n'est renvoyé qu'ici, sur le 202 de création.
Les réponses qu'il faut savoir traiter
| Statut | Code | Signification | Que faire |
|---|---|---|---|
| 202 | — | Accepté, débité, mis en file. | Enregistrer 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 clientReference. |
| 409 | — | Cette clientReference a déjà servi. | Lire le message renvoyé, ne pas 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). | Réessayer le lendemain ou demander un relèvement. |
| 400 | INVALID_NUMBER, TEXT_EMPTY, TEXT_TOO_LONG | La requête est mal formée. Aucun débit. | Corriger l'appel ; réessayer à l'identique ne servira à rien. |
| 401 | INVALID_API_KEY | Clé absente, invalide ou révoquée. | Vérifier l'en-tête Authorization. |
| 503 | OPERATOR_DISABLED | Les envois vers cet opérateur sont suspendus. Aucun débit. | Reprogrammer après Retry-After (une heure). |
{ code, message } : c'est le message déjà enregistré, au même format que le 202 — sans balanceAfterXaf, puisque rien n'a été débité. Un client qui traite uniformément tous les 4xx lit $body['code'], trouve null, et conclut à une panne alors que son SMS est bel et bien parti. Recevoir les accusés de remise
Le 202 signifie « 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, et il est émis à chaque changement de statut : d'abord SENT, puis DELIVERED, ou FAILED. DELIVERED et FAILED sont définitifs.
webhook-gsms.php
<?php
/**
* webhook-gsms.php — réception des changements de statut (QUEUED → SENT →
* DELIVERED, ou FAILED). À publier en https, l'URL est déclarée dans la console.
*/
const GSMS_WEBHOOK_SECRET = 'whsec_votre_secret';
// Le corps BRUT, jamais le tableau re-sérialisé : la signature porte sur les octets reçus.
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_GSMS_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_GSMS_SIGNATURE'] ?? '';
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
http_response_code(400); // hors de la fenêtre de tolérance de ±5 minutes
exit;
}
$expected = 'sha256=' . hash_hmac('sha256', $raw . '.' . $timestamp, GSMS_WEBHOOK_SECRET);
// hash_equals : comparaison à temps constant. Un === ici est une faille.
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// Livraison « au moins une fois » : le même eventId peut arriver plusieurs fois.
if (gsms_event_deja_traite($event['eventId'])) {
http_response_code(200);
exit;
}
$data = $event['data'];
// Attention : $event['applicationId'] contient le SLUG de l'application, pas son UUID.
gsms_mettre_a_jour_statut(
$data['messageId'],
$data['status'], // SENT, DELIVERED ou FAILED
$data['failureReason'], // null tant qu'il n'y a pas d'échec
$data['clientReference'] // votre propre identifiant, s'il a été fourni
);
gsms_marquer_traite($event['eventId']);
// Tout code 2xx vaut accusé de réception. Au-delà de 10 s, G-SMS considère l'échec.
http_response_code(200);Ce que vous recevez :
POST /webhook-gsms.php
X-GSms-Event: sms.sent
X-GSms-Timestamp: 1789041667
X-GSms-Signature: sha256=3f1c…
X-GSms-Delivery: 0d4e0b56-1f1f-4a5e-9cf1-3a2c11d0b8e2
{
"eventId": "b2b0a1f4-84a3-4a59-9c4e-25ec6c0f2d31",
"type": "sms.sent",
"occurredAt": "2026-09-12T08:41:12Z",
"applicationId": "mon-application",
"data": {
"messageId": "8f14e45f-ceea-467a-9c1e-1a4f1c3b0d21",
"clientReference": "login-2026-09-12-4821",
"to": "+237690000000",
"operator": "ORANGE",
"segments": 1,
"priceXaf": 5,
"status": "DELIVERED",
"failureReason": null
}
}- La signature vaut
HMAC_SHA256(secret, corps_brut + "." + timestamp), en hexadécimal minuscule, préfixée desha256=. Le corps doit être celui reçu, octet pour octet. - Un horodatage qui s'écarte de plus de cinq minutes de l'heure de votre serveur doit être refusé : c'est ce qui empêche de rejouer une requête interceptée.
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, ànullle cas échéant. - Un même événement peut être livré plusieurs fois. Dédupliquez sur
eventId, jamais surX-GSms-Delivery, qui change à chaque tentative. - En cas d'échec, G-SMS réessaie à 1 min, 5 min, 30 min, 2 h, 12 h, puis toutes les 12 h, et abandonne au bout de 48 h. La livraison est alors marquée
EXHAUSTEDet peut être rejouée depuis la console. - Un
FAILEDest remboursé automatiquement en crédits, une seule fois.failureReasonporte la cause remontée par l'opérateur, ouTIMEOUTsi le réseau n'a rien confirmé en 24 h, ouSUBMIT_FAILEDsi la remise à l'opérateur a échoué.
Surveiller le solde
Un envoi refusé pour solde insuffisant est un SMS qui ne part pas, et vous ne l'apprendrez qu'au moment où il comptait. Lisez le solde avant vos traitements par lots, ou surveillez balanceAfterXaf à chaque envoi.
<?php
require __DIR__ . '/gsms.php';
$solde = gsms_request('GET', '/v1/balance')['body']['balanceXaf'];
if ($solde < 500) {
// Rien ne prévient d'un solde qui s'épuise : c'est à l'appelant de surveiller.
alerter_equipe("Solde G-SMS bas : {$solde} XAF");
}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 |
Le même message coûterait 5 XAF vers MTN, 7 XAF vers Camtel ou Nexttel, et 10 XAF sur la route par défaut, celle des préfixes absents du plan de numérotation de l'ART. Le crédit offert à l'inscription couvre une vingtaine d'envois de ce type.
Ce qui va vous surprendre
- Un caractère suffit à doubler la facture. Un message est découpé en segments de 160 caractères tant qu'il tient dans l'alphabet GSM-7, et de 70 caractères sinon. Or le « ç » minuscule, le « ê », l'apostrophe typographique « ’ » et les guillemets « » n'en font pas partie. Un seul de ces caractères bascule le message entier en UCS-2. Au-delà d'un segment, la capacité tombe à 153 caractères en GSM-7 et 67 en UCS-2. Ces quatre tailles sont des paramètres administrables, pas des constantes : vérifiez le
segmentsrenvoyé plutôt que de le recalculer. - Le destinataire voit un numéro, pas un nom. Il n'y a pas d'expéditeur personnalisé : G-SMS achemine des SMS transactionnels depuis une SIM camerounaise, et c'est ce numéro qui s'affiche. Mettez donc le nom de 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
clientReference. 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é utilisée relève d'une autre application. Ne cherchez pas une erreur de droits qui n'existe pas.
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