G-SMS.

Envoyer un SMS depuis WordPress et WooCommerce

Au Cameroun, un client qui commande en ligne attend une confirmation, et le courriel n'est pas lu. Le SMS, si. Cette page monte une petite extension WordPress qui prévient l'acheteur dès que sa commande est payée, puis consigne l'accusé de remise dans la commande. Une centaine de lignes de PHP, aucune bibliothèque : wp_remote_post fait tout le travail.

Prérequis

  • WordPress 6 et PHP 8.0 ou plus récent. WooCommerce pour la partie commande — le client HTTP, lui, sert à n'importe quel usage.
  • Un accès aux fichiers du site (FTP, SSH ou gestionnaire de fichiers de l'hébergeur) pour déposer l'extension.
  • Une clé API G-SMS commençant par gsk_, créée dans la console pour une application donnée.
  • Un site en HTTPS si vous voulez recevoir les accusés de remise.

Une extension trouvée sur un marketplace stocke souvent la clé en base. C'est une mauvaise idée : la base part avec chaque sauvegarde, chaque export, chaque copie de préproduction.

Où mettre la clé

<?php
// wp-config.php — au-dessus de la ligne « That's all, stop editing! ».
// Les secrets ne vont pas en base de données : ils seraient exportés avec elle.

define('GSMS_API_KEY', 'gsk_votre_cle_api');
define('GSMS_WEBHOOK_SECRET', 'whsec_votre_secret');

Si la clé fuit, révoquez-la depuis la console : une nouvelle est générée en quelques secondes et l'ancienne cesse immédiatement d'être acceptée.

L'extension

À déposer dans wp-content/plugins/gsms-commande/gsms-commande.php, puis à activer depuis l'écran des extensions. Le client HTTP ne lève aucune exception : une boutique ne doit pas tomber parce qu'un SMS n'est pas parti.

wp-content/plugins/gsms-commande/gsms-commande.php

<?php
/**
 * Plugin Name: SMS de commande (G-SMS)
 * Description: Prévient le client par SMS quand sa commande WooCommerce est payée.
 * Version: 1.0.0
 * Requires PHP: 8.0
 */

defined('ABSPATH') || exit;

const GSMS_BASE_URL = 'https://api.sms.gworldship.com';

/**
 * Appelle l'API. Ne lève rien : un 409 n'est pas une panne, et une boutique ne
 * doit jamais tomber parce qu'un SMS n'est pas parti.
 *
 * @return array{status:int, body:array, error:?string}
 */
function gsms_request(string $path, ?array $payload = null): array
{
    $args = [
        'timeout' => 15,
        'headers' => [
            'Authorization' => 'Bearer ' . (defined('GSMS_API_KEY') ? GSMS_API_KEY : ''),
            'Content-Type'  => 'application/json',
            'Accept'        => 'application/json',
        ],
    ];

    if ($payload === null) {
        $response = wp_remote_get(GSMS_BASE_URL . $path, $args);
    } else {
        $args['body'] = wp_json_encode($payload);
        $response = wp_remote_post(GSMS_BASE_URL . $path, $args);
    }

    if (is_wp_error($response)) {
        return ['status' => 0, 'body' => [], 'error' => $response->get_error_message()];
    }

    return [
        'status' => wp_remote_retrieve_response_code($response),
        'body'   => json_decode(wp_remote_retrieve_body($response), true) ?: [],
        'error'  => null,
    ];
}

/** POST /v1/sms. La référence protège du doublon si le hook est rejoué. */
function gsms_send(string $to, string $text, ?string $reference = null): array
{
    $payload = ['to' => $to, 'text' => $text];

    if ($reference !== null) {
        $payload['clientReference'] = $reference;
    }

    return gsms_request('/v1/sms', $payload);
}

/**
 * Met le numéro saisi à la caisse au format attendu : +2376XXXXXXXX.
 * Renvoie null si ce n'est visiblement pas un mobile camerounais — autant ne
 * pas dépenser un appel réseau pour se faire répondre INVALID_NUMBER.
 */
function gsms_normalize_cm_phone(?string $raw): ?string
{
    $digits = preg_replace('/\D+/', '', (string) $raw);

    if (str_starts_with($digits, '237')) {
        $digits = substr($digits, 3);
    }

    return preg_match('/^6\d{8}$/', $digits) === 1 ? '+237' . $digits : null;
}

Prévenir le client quand la commande est payée

WooCommerce déclenche une action à chaque changement de statut de commande. woocommerce_order_status_processing correspond au paiement accepté ; woocommerce_order_status_completed à l'expédition. Le reste est du texte et un appel.

<?php
// Suite de l'extension : le branchement WooCommerce.

// woocommerce_order_status_processing : la commande vient d'être payée.
// Pour un envoi à l'expédition, utilisez woocommerce_order_status_completed.
add_action('woocommerce_order_status_processing', 'gsms_notify_order_paid', 10, 1);

function gsms_notify_order_paid(int $order_id): void
{
    $order = wc_get_order($order_id);

    if (! $order instanceof WC_Order) {
        return;
    }

    $to = gsms_normalize_cm_phone($order->get_billing_phone());

    if ($to === null) {
        $order->add_order_note('SMS non envoyé : numéro de facturation inexploitable.');
        return;
    }

    // Le destinataire voit le numéro de la SIM, pas un nom d'expéditeur :
    // annoncez la boutique dès le premier mot.
    $text = sprintf(
        'Boutique Awa : votre commande %s est confirmée. Total : %s FCFA.',
        $order->get_order_number(),
        number_format((float) $order->get_total(), 0, ',', ' ')
    );

    // Une référence par commande ET par étape : WooCommerce peut rejouer un hook.
    $result = gsms_send($to, $text, 'wc-' . $order_id . '-paid');
    $body = $result['body'];

    switch ($result['status']) {
        case 202:
            $order->add_order_note(sprintf(
                'SMS envoyé (%s, %d segment(s), %d FCFA).',
                $body['messageId'], $body['segments'], $body['priceXaf']
            ));
            break;

        case 409:
            // Déjà envoyé pour cette commande : rien à faire, rien de facturé.
            break;

        case 402:
            $order->add_order_note('SMS non envoyé : crédit G-SMS épuisé.');
            break;

        case 429:
            $order->add_order_note('SMS non envoyé : trop d\'envois par seconde.');
            break;

        default:
            $order->add_order_note(sprintf(
                'SMS non envoyé (HTTP %d, %s).',
                $result['status'], $body['code'] ?? (string) $result['error']
            ));
    }
}
Ce hook s'exécute pendant la validation du paiement. Un appel réseau de quelques centaines de millisecondes s'ajoute donc au temps d'attente du client, et une lenteur de l'API deviendrait une lenteur de votre caisse. En production, différez l'envoi.
<?php
// Variante recommandée en production : ne pas faire attendre la caisse.
// Action Scheduler est livré avec WooCommerce, il n'y a rien à installer.

add_action('woocommerce_order_status_processing', function (int $order_id): void {
    as_enqueue_async_action('gsms_send_order_sms', ['order_id' => $order_id], 'gsms');
}, 10, 1);

add_action('gsms_send_order_sms', 'gsms_notify_order_paid', 10, 1);

// La tâche peut être rejouée après un incident : c'est exactement pour cela que
// gsms_notify_order_paid() envoie toujours la même clientReference. Au pire,
// l'API répond 409 et rien n'est facturé deux fois.

Les réponses qu'il faut savoir traiter

StatutCodeSignificationQue faire
202—Accepté, débité, mis en file.Noter messageId dans la commande.
402INSUFFICIENT_BALANCECrédit épuisé. Aucun débit.Recharger. La commande reste valide, seul le SMS manque.
409—Cette commande a déjà reçu son SMS.Ne rien faire : c'est le comportement voulu sur un hook rejoué.
429RATE_LIMITEDPlus de 10 requêtes par seconde sur la clé.Reprogrammer la tâche une seconde plus tard.
400INVALID_NUMBERLe numéro saisi à la caisse n'est pas exploitable.Rendre le champ téléphone obligatoire et le contrôler.
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. Traiter tous les codes supérieurs à 400 de la même façon ferait apparaître « SMS non envoyé » dans la commande alors que le client l'a bien reçu.

Consigner l'accusé de remise dans la commande

Le 202 dit « accepté », pas « reçu ». La suite arrive en push, sur une route REST que l'extension publie elle-même. 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.

<?php
// Suite de l'extension : réception des statuts de remise.
// URL à déclarer dans la console : https://votre-boutique.cm/wp-json/gsms/v1/webhook

add_action('rest_api_init', function (): void {
    register_rest_route('gsms/v1', '/webhook', [
        'methods'  => 'POST',
        'callback' => 'gsms_handle_webhook',
        // L'authentification, ici, c'est la signature HMAC — pas un utilisateur WordPress.
        'permission_callback' => '__return_true',
    ]);
});

function gsms_handle_webhook(WP_REST_Request $request): WP_REST_Response
{
    // Le corps BRUT : la signature porte sur les octets reçus, jamais sur un
    // tableau re-sérialisé par WordPress.
    $raw       = $request->get_body();
    $timestamp = (string) $request->get_header('x-gsms-timestamp');
    $signature = (string) $request->get_header('x-gsms-signature');

    if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
        return new WP_REST_Response(null, 400); // hors de la fenêtre de ±5 minutes
    }

    $secret   = defined('GSMS_WEBHOOK_SECRET') ? GSMS_WEBHOOK_SECRET : '';
    $expected = 'sha256=' . hash_hmac('sha256', $raw . '.' . $timestamp, $secret);

    // hash_equals : comparaison à temps constant. Un === ici est une faille.
    if (! hash_equals($expected, $signature)) {
        return new WP_REST_Response(null, 401);
    }

    $event = json_decode($raw, true);

    // Livraison « au moins une fois » : le même eventId peut arriver plusieurs fois.
    $seen = 'gsms_evt_' . md5((string) $event['eventId']);

    if (get_transient($seen) !== false) {
        return new WP_REST_Response(null, 200);
    }

    set_transient($seen, 1, 2 * DAY_IN_SECONDS);

    $data = $event['data'];

    // La référence posée à l'envoi nous ramène à la commande.
    if (preg_match('/^wc-(\d+)-/', (string) ($data['clientReference'] ?? ''), $matches)) {
        $order = wc_get_order((int) $matches[1]);

        if ($order instanceof WC_Order) {
            $order->add_order_note(sprintf(
                'SMS %s : %s%s',
                $data['messageId'],
                $data['status'],
                $data['failureReason'] ? ' (' . $data['failureReason'] . ')' : ''
            ));
        }
    }

    // Tout code 2xx vaut accusé de réception. Répondez en moins de 10 s.
    return new WP_REST_Response(null, 200);
}
  • 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 : 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, à 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.
  • Attention aux extensions de sécurité qui bloquent /wp-json/ : la route doit rester joignable de l'extérieur, sinon vous ne recevrez jamais rien.

Combien coûte l'exemple de cette page

Texte envoyé« Boutique Awa : votre commande 1042 est confirmée. Total : 25 000 FCFA. » — 70 caractères
EncodageGSM-7 : le « é » de « confirmée » fait partie de l'alphabet standard
Segments1 (jusqu'à 160 caractères en GSM-7)
DestinataireUn mobile Orange ou MTN
Coût5 XAF

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. Cent commandes par mois coûtent donc environ 500 XAF de SMS.

Ce qui va vous surprendre

  • Une espace insécable peut doubler la facture. Tant que le texte tient dans l'alphabet GSM-7, un segment vaut 160 caractères ; sinon il tombe à 70. Or l'espace insécable qu'insèrent wc_price() et beaucoup de thèmes n'appartient pas à cet alphabet — pas plus que l'apostrophe typographique « ’ », le « ç » minuscule ou les guillemets « ». Le message de l'exemple fait 70 caractères : en GSM-7 il en reste 90 de marge, en UCS-2 il tient tout juste et un caractère de plus le ferait payer double. D'où le number_format() de l'exemple, plutôt que le montant formaté par WooCommerce.
  • 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. C'est pour cela que l'exemple commence par le nom de la boutique — sans lui, le client reçoit un message anonyme.
  • Ce n'est pas une extension de SMS marketing. G-SMS sert les messages que le client attend : confirmation, expédition, code de connexion, rappel de rendez-vous. Pas les campagnes vers un fichier acheté.
  • 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.

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