G-SMS.

Envoyer un SMS au Cameroun depuis Node.js

Votre service Node doit envoyer un code de connexion, une confirmation de paiement ou une alerte à un numéro Orange, MTN, Camtel ou Nexttel. Depuis Node 18, fetch est dans la plateforme : il n'y a aucun paquet à installer, et le client tient en une quarantaine de lignes. Cette page les donne, jusqu'à la réception des accusés de remise.

Prérequis

  • Node.js 18 ou plus récent — fetch, AbortSignal.timeout et node:crypto sont natifs.
  • Les modules ES ("type": "module" dans le package.json, ou des fichiers .mjs).
  • 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.

La clé donne le droit d'envoyer et de dépenser : elle reste côté serveur, dans une variable d'environnement. Un appel depuis le navigateur l'exposerait à tous les visiteurs.

Le client

Deux codes de réponse ne sont pas des erreurs et méritent d'être renvoyés au lieu d'être jetés : le 202, qui accepte l'envoi, et le 409, qui signale que ce message existe déjà. Tout le reste part en exception typée, avec le code d'erreur et le délai d'attente conseillé.

gsms.mjs

// gsms.mjs — client G-SMS pour Node.js 18 ou plus récent.
// Aucune dépendance : fetch et AbortSignal.timeout sont dans la plateforme.

const BASE_URL = process.env.GSMS_URL ?? 'https://api.sms.gworldship.com';
const API_KEY = process.env.GSMS_API_KEY ?? 'gsk_votre_cle_api';

export class GSmsError extends Error {
  constructor(status, body, retryAfterSeconds) {
    super(`${body.code ?? status} — ${body.message ?? 'erreur inattendue'}`);
    this.name = 'GSmsError';
    this.status = status;
    this.code = body.code;
    this.retryAfterSeconds = retryAfterSeconds;
  }
}

/**
 * POST /v1/sms.
 * Renvoie { duplicate, message }. duplicate vaut true sur un 409, qui n'est pas
 * une erreur : c'est la confirmation que ce message a déjà été accepté.
 */
export async function sendSms({ to, text, clientReference }) {
  const response = await fetch(`${BASE_URL}/v1/sms`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify(clientReference ? { to, text, clientReference } : { to, text }),
    signal: AbortSignal.timeout(15_000),
  });

  const body = await response.json();

  if (response.status === 202) return { duplicate: false, message: body };
  if (response.status === 409) return { duplicate: true, message: body };

  const retryAfter = Number(response.headers.get('retry-after'));
  throw new GSmsError(response.status, body, Number.isFinite(retryAfter) ? retryAfter : undefined);
}

/** GET /v1/balance — solde du compte, en francs CFA. */
export async function getBalanceXaf() {
  const response = await fetch(`${BASE_URL}/v1/balance`, {
    headers: { Authorization: `Bearer ${API_KEY}`, Accept: 'application/json' },
    signal: AbortSignal.timeout(10_000),
  });

  const body = await response.json();
  if (!response.ok) throw new GSmsError(response.status, body);
  return body.balanceXaf;
}

gsms.mjs (suite)

// gsms.mjs (suite)

/**
 * Ne réessaie que le 429 : c'est le seul cas qu'une seconde d'attente règle.
 * Un 503 OPERATOR_DISABLED demande une heure — remettez l'envoi en file plutôt
 * que d'immobiliser le processus. Un 402 ne se règle qu'en rechargeant.
 * La clientReference rend la reprise sûre : au pire, l'API répondra 409.
 */
export async function sendSmsWithRetry(input, attempts = 3) {
  for (let attempt = 1; ; attempt += 1) {
    try {
      return await sendSms(input);
    } catch (error) {
      const retryable = error instanceof GSmsError && error.status === 429;
      if (!retryable || attempt === attempts) throw error;
      const waitSeconds = error.retryAfterSeconds ?? 1;
      await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
    }
  }
}

Premier envoi

clientReference est facultatif, mais c'est ce qui rend un envoi rejouable : au second appel avec la même valeur, l'API renvoie le message d'origine avec un 409 au lieu d'envoyer et de facturer un deuxième SMS. Un identifiant de commande, de session ou de tentative de connexion fait très bien l'affaire, dans la limite de 64 caractères.

envoyer.mjs

// envoyer.mjs
import { GSmsError, sendSmsWithRetry } from './gsms.mjs';

try {
  const { duplicate, message } = await sendSmsWithRetry({
    to: '+237690000000',
    text: 'Votre code de connexion est 4821.',
    // Identifiant stable de votre côté : il rend l'appel rejouable. 64 caractères max.
    clientReference: 'login-2026-09-12-4821',
  });

  if (duplicate) {
    console.log('Déjà envoyé :', message.messageId, message.status);
  } else {
    console.log('Accepté :', message.messageId, message.operator,
      message.segments, 'segment(s),', message.priceXaf, 'XAF,',
      'solde restant', message.balanceAfterXaf, 'XAF');
  }
} catch (error) {
  if (error instanceof GSmsError && error.code === 'INSUFFICIENT_BALANCE') {
    console.error('Crédit épuisé : rechargez, puis rejouez le même appel.');
  } else if (error instanceof GSmsError && error.code === 'OPERATOR_DISABLED') {
    console.error('Opérateur suspendu, aucun débit. Reprogrammez dans une heure.');
  } else {
    throw error;
  }
}

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 : sur un message tout juste accepté, ni failureReason ni deliveredAt n'apparaissent. Et balanceAfterXaf n'est présent qu'ici, sur la réponse de création.

Les types TypeScript

Le contrat, écrit une fois. Les propriétés facultatives ne sont pas de la prudence : ce sont exactement les champs que l'API omet quand ils sont nuls.

// gsms.d.ts — si votre projet est en TypeScript.

export type Operator = 'ORANGE' | 'MTN' | 'CAMTEL' | 'NEXTTEL' | 'DEFAULT';
export type MessageStatus = 'QUEUED' | 'SENT' | 'DELIVERED' | 'FAILED';

/**
 * Réponse de POST /v1/sms et de GET /v1/sms/{messageId}.
 * Les champs nuls sont OMIS : d'où les propriétés facultatives.
 */
export interface SmsMessage {
  messageId: string;
  to: string;                    // toujours normalisé en E.164, même si vous avez envoyé 6XXXXXXXX
  operator: Operator;
  segments: number;
  priceXaf: number;
  status: MessageStatus;
  createdAt: string;
  clientReference?: string;
  balanceAfterXaf?: number;      // uniquement sur le 202 de création
  failureReason?: string;
  sentAt?: string;
  deliveredAt?: string;
}

/** Corps des erreurs — sauf le 409, qui renvoie un SmsMessage. */
export interface GSmsErrorBody {
  code: string;
  message: string;
}

Les réponses qu'il faut savoir traiter

StatutCodeSignificationQue faire
202—Accepté, débité, mis en file.Garder messageId pour rapprocher le webhook.
402INSUFFICIENT_BALANCELe 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 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 ; un nouvel essai identique échouera pareil.
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 if (!response.ok) throw new Error(body.code) écrit trop vite lèvera donc une exception avec undefined pour message, alors que le SMS est parti.

Recevoir les accusés de remise

Le 202 veut dire « accepté », pas « reçu ». La suite arrive en push sur une URL déclarée 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 alors la cause remontée par l'opérateur, ou TIMEOUT si rien n'a été confirmé en 24 h, ou SUBMIT_FAILED.

webhook.mjs

// webhook.mjs — récepteur de statuts, sans dépendance.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';

const SECRET = process.env.GSMS_WEBHOOK_SECRET;
const handled = new Set(); // en production : Redis ou une table, pas la mémoire du processus

function isSignatureValid(raw, timestamp, signature) {
  const expected = `sha256=${createHmac('sha256', SECRET).update(`${raw}.${timestamp}`).digest('hex')}`;
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  // timingSafeEqual exige deux tampons de même longueur, et compare à temps constant.
  return a.length === b.length && timingSafeEqual(a, b);
}

createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/webhooks/gsms') {
    res.writeHead(404).end();
    return;
  }

  // Le corps BRUT : la signature porte sur les octets reçus, jamais sur un
  // JSON re-sérialisé. Avec Express, il faut express.raw(), pas express.json().
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const raw = Buffer.concat(chunks).toString('utf8');

  const timestamp = Number(req.headers['x-gsms-timestamp']);
  const signature = req.headers['x-gsms-signature'] ?? '';

  if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
    res.writeHead(400).end(); // hors de la fenêtre de tolérance de ±5 minutes
    return;
  }

  if (!isSignatureValid(raw, timestamp, signature)) {
    res.writeHead(401).end();
    return;
  }

  const event = JSON.parse(raw);

  // Livraison « au moins une fois » : le même eventId peut arriver plusieurs fois.
  if (!handled.has(event.eventId)) {
    handled.add(event.eventId);
    const { messageId, status, failureReason, clientReference } = event.data;
    // event.applicationId contient le SLUG de l'application, pas son UUID.
    console.log(messageId, clientReference, status, failureReason ?? '');
  }

  // 204 : tout code 2xx vaut accusé de réception. Répondez en moins de 10 s.
  res.writeHead(204).end();
}).listen(3000);
  • 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.
  • Comparez la signature avec timingSafeEqual. Un === sur deux chaînes compare caractère par caractère et fuit, par le temps de réponse, la position de la première différence.
  • 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, à null 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.

Si vous ne pouvez pas exposer d'URL publique :

// gsms.mjs (suite) — consulter un message à la demande. À réserver aux
// traitements différés : ce n'est pas fait pour boucler toutes les secondes.
const response = await fetch(`${BASE_URL}/v1/sms/${messageId}`, {
  headers: { Authorization: `Bearer ${API_KEY}`, Accept: 'application/json' },
});

// 404 si le message relève d'une autre application : il n'y a jamais de 403.
const message = await response.json();
console.log(message.status, message.failureReason ?? '');

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. Le crédit offert à l'inscription couvre une vingtaine d'envois de ce type.

Ce qui va vous surprendre

  • Un émoji coûte cher. Tant que le texte tient dans l'alphabet GSM-7, un segment vaut 160 caractères ; un seul caractère hors alphabet le ramène à 70. Un émoji, une apostrophe typographique « ’ », un « ç » minuscule ou des guillemets « » suffisent. 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 : 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 clientReference. Le quota journalier est désactivé par défaut.
  • Pas de Promise.all sur mille numéros. Au-delà de dix requêtes par seconde, vous récoltez des 429. Cadencez vos envois, ou passez par une file d'attente.

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