Genuka WA docs

Notifications de commande WhatsApp depuis votre backend

Envoyez confirmations de commande, avis d'expédition et de livraison sur WhatsApp depuis votre backend : templates utilitaires, variables, statuts.

Pour notifier une commande sur WhatsApp, créez un template de catégorie UTILITY par étape (confirmée, expédiée, livrée), faites-le approuver par Meta, puis appelez POST /api/v1/messages depuis votre backend avec les variables de la commande. Un texte libre n'est permis que si le client vous a écrit dans les 24 dernières heures.

Mis à jour le 8 octobre 2026

De quoi avez-vous besoin avant de commencer ?

  • Un numéro connecté à Genuka WA et son connectionId — voir le démarrage rapide.
  • Une clé API, utilisée uniquement côté serveur — voir Authentification.
  • Un moyen de paiement sur le compte WhatsApp Business, chez Meta. Genuka est Meta Tech Provider, pas BSP : Meta facture le compte directement, et un client onboardé par un Tech Provider doit y ajouter son propre moyen de paiement (Meta, Partners). Sans lui, les templates sont approuvés mais chaque envoi revient en erreur 131042 — voir erreur 131042.

Quels templates créer pour une boutique en ligne ?

Un template par moment où le client attend une nouvelle de sa commande. Trois suffisent pour commencer :

ÉtapeTemplateVariables du corpsBouton
Commande confirméecommande_confirmeeprénom, numéro, montantURL « Voir ma commande »
Commande expédiéecommande_expedieeprénom, numéro, transporteurURL « Suivre le colis »
Commande livréecommande_livreeprénom, numéroAucun : le message invite à répondre

Pour Meta, un template utilitaire est déclenché par une action ou une demande du client, lui est propre, et ne contient rien de promotionnel. Une confirmation de commande ou un avis d'expédition en sont les exemples types ; un template qui mêle l'information de commande à une offre, une vente additionnelle ou une incitation au renouvellement est reclassé en marketing (doc Meta, catégorisation). Gardez les promotions hors de ces messages.

Comment créer un template utilitaire ?

Soumettez le template

Chaque variable du corps exige un exemple dans example.body_text, et la variable d'un bouton URL ne remplace que la fin de l'adresse, avec son propre exemple.

POST /api/v1/templates
curl -X POST https://wa.genuka.com/api/v1/templates \
  -H "Authorization: Bearer $GENUKA_WA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "connectionId": "con_1",
    "name": "commande_expediee",
    "language": "fr",
    "category": "UTILITY",
    "components": [
      { "type": "BODY",
        "text": "Bonjour {{1}}, votre commande {{2}} est partie avec {{3}}. Suivez-la avec le bouton ci-dessous.",
        "example": { "body_text": [["Awa", "CMD-1042", "DHL"]] } },
      { "type": "BUTTONS", "buttons": [
        { "type": "URL", "text": "Suivre le colis",
          "url": "https://boutique.example.com/suivi/{{1}}",
          "example": ["https://boutique.example.com/suivi/CMD-1042"] }
      ] }
    ]
  }'

Depuis le tableau de bord : Templates › Nouveau template, catégorie Utilitaire.

Attendez l'approbation

Le verdict de Meta arrive en webhook template_status (si votre endpoint est abonné à template.status_changed, ou à tous les événements), se lit sur GET /api/v1/templates/{id}, et se rattrape avec POST /api/v1/templates/sync si un webhook s'est perdu. N'envoyez qu'un template approved.

Fixez une durée de vie si le message périme

Si un message ne peut pas être livré, WhatsApp réessaie pendant la durée de vie du template : 30 jours par défaut pour un template utilitaire, réglable de 30 secondes à 12 heures (doc Meta, time-to-live). Un « votre livreur arrive dans 30 minutes » reçu le lendemain fait plus de mal que de bien : pour ce genre de template, passez messageSendTtlSeconds à la création.

Comment envoyer la notification depuis votre backend ?

Un appel par notification. body remplit {{1}}, {{2}}, {{3}} dans l'ordre (variables est un alias accepté), et buttons[].text complète l'URL du bouton.

send-order-shipped.ts
const API = "https://wa.genuka.com/api/v1";

type Order = { number: string; firstName: string; phone: string; carrier: string };

/** Corps de la réponse : `data` en cas de succès, sinon `error` (et `meta` si Meta a refusé). */
type ApiResult = {
  data?: { messageId: string };
  error?: string;
  message?: string;
  meta?: { retryable?: boolean; code?: number };
};

export async function sendOrderShipped(order: Order): Promise<string> {
  const response = await fetch(`${API}/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.GENUKA_WA_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      connectionId: process.env.GENUKA_WA_CONNECTION_ID,
      to: order.phone, // international, avec le + : "+237699001122"
      template: {
        name: "commande_expediee",
        language: "fr", // la langue exacte du template, sinon l'envoi vise "en_US"
        body: [order.firstName, order.number, order.carrier],
        buttons: [{ type: "url", text: order.number }],
      },
    }),
    signal: AbortSignal.timeout(15_000),
  });

  // Un 5xx renvoyé par le CDN n'est pas du JSON : sans ce catch, l'erreur d'analyse perdrait
  // `retryable` et la notification serait classée en échec définitif.
  const result = (await response.json().catch(() => ({}))) as ApiResult;
  if (!response.ok || !result.data) {
    throw Object.assign(new Error(result.message ?? result.error), {
      status: response.status,
      // Un refus définitif (template, numéro) ne se réessaie pas : meta.retryable le dit.
      retryable: response.status >= 500 || result.meta?.retryable === true,
    });
  }
  return result.data.messageId;
}

Un 200 veut dire que Meta a accepté le message ; la livraison se suit par webhook, avec ce messageId. Pour envoyer le même template à une liste entière en une fois, passez plutôt par les campagnes ; tous les types de contenu sont décrits dans Envoyer des messages.

Quand pouvez-vous envoyer un message libre plutôt qu'un template ?

Quand le client vous écrit, une fenêtre de service de 24 heures s'ouvre, et chaque nouveau message de sa part la relance. Tant qu'elle est ouverte, vous pouvez répondre avec n'importe quel message ; une fois fermée, seuls les templates approuvés passent (doc Meta).

Le cas typique : le client répond à l'avis d'expédition « je ne serai pas là demain ». Votre réponse peut être un simple texte, cité grâce à replyTo :

POST /api/v1/messages
{
  "connectionId": "con_1",
  "to": "+237699001122",
  "replyTo": "wamid.HBg…",
  "text": "C'est noté, le livreur repassera jeudi entre 14 h et 16 h."
}

Côté facture, Meta ne fait pas payer les messages hors template, et un template utilitaire livré pendant une fenêtre ouverte est gratuit lui aussi (tarifs Meta).

Hors fenêtre, un texte libre échoue, mais pas au moment de l'envoi. D'après nos essais sur un numéro réel, Meta l'accepte avec un messageId, puis l'abandonne : le plus souvent, un statut failed avec l'erreur 131047 suit (codes d'erreur Meta) ; parfois, aucun statut n'arrive. N'attendez donc pas l'échec : Genuka WA ne bloque pas l'envoi, mais vous prévient dans la réponse, et c'est sur cet avertissement qu'il faut agir :

200 OK — avec avertissement
{
  "data": {
    "messageId": "wamid.HBg…",
    "warning": { "code": "outside_service_window", "message": "…" }
  }
}

outside_service_window signifie que le dernier message du client date de plus de 24 heures : renvoyez plutôt un template. unverified_service_window signifie seulement que nous n'avons aucun message entrant de ce contact en mémoire, ce qui ne prouve pas que la fenêtre est fermée. Suivez alors le statut du message, et ne renvoyez un template que si failed avec 131047 arrive, ou si rien n'arrive : le renvoyer tout de suite risquerait d'envoyer deux fois la même notification.

Comment éviter d'envoyer deux fois la même notification ?

L'API n'a pas de clé d'idempotence : c'est à votre backend de garantir qu'une commande ne reçoit qu'une seule fois chaque notification. La façon la plus simple est une table dont la clé primaire est le couple commande + étape.

schema.sql
create table order_notifications (
  order_id    text        not null,
  event       text        not null,                  -- confirmed | shipped | delivered
  status      text        not null default 'pending',
  message_id  text unique,                            -- le wamid renvoyé par Genuka WA
  error_code  integer,
  created_at  timestamptz not null default now(),
  primary key (order_id, event)
);
notify-once.ts
import { Pool } from "pg";

const db = new Pool();

export async function notifyOnce(orderId: string, event: string, send: () => Promise<string>) {
  // 1. Réserver : un second appel pour la même commande et la même étape ne fait rien.
  const claimed = await db.query(
    "insert into order_notifications (order_id, event) values ($1, $2) on conflict do nothing",
    [orderId, event],
  );
  if (claimed.rowCount === 0) return;

  try {
    // 2. Envoyer, 3. noter le wamid : c'est lui qui reliera les statuts à cette ligne.
    const messageId = await send();
    await db.query(
      "update order_notifications set status = 'sent', message_id = $3 where order_id = $1 and event = $2",
      [orderId, event, messageId],
    );
  } catch (error) {
    if ((error as { retryable?: boolean }).retryable === true) {
      // Libérer la réservation : le prochain passage du job réessaiera.
      await db.query("delete from order_notifications where order_id = $1 and event = $2", [orderId, event]);
    } else {
      await db.query(
        "update order_notifications set status = 'failed' where order_id = $1 and event = $2",
        [orderId, event],
      );
    }
    throw error;
  }
}

// await notifyOnce(order.id, "shipped", () => sendOrderShipped(order));

Un timeout réseau est ambigu : Meta a peut-être accepté le message avant que la réponse ne se perde. Ce code le classe en échec plutôt que de risquer un doublon. Si, pour votre boutique, un doublon rare vaut mieux qu'une confirmation perdue, traitez aussi les timeouts comme réessayables.

Comment suivre la livraison de chaque notification ?

Les statuts arrivent sur votre webhook sous le type message_status. data.id est le messageId renvoyé à l'envoi, et data.status vaut sent, delivered, read ou failed. En cas d'échec, data.errors[0].code porte le code Meta (doc Meta, statuts).

Dans votre récepteur de webhooks (signature déjà vérifiée)
const RANK: Record<string, number> = { pending: 0, sent: 1, delivered: 2, read: 3, failed: 4 };

if (event.type === "message_status") {
  const { id: messageId, status, errors } = event.data;
  const row = await findNotificationByMessageId(messageId);

  // Les statuts peuvent arriver dans le désordre : un statut n'est jamais rétrogradé,
  // et `failed` est définitif.
  if (row && row.status !== "failed" && (RANK[status] ?? -1) > RANK[row.status]) {
    await setNotificationStatus(messageId, status, errors?.[0]?.code ?? null);
  }
}

Pour Meta, read signifie que le message s'est affiché dans une conversation ouverte sur l'appareil du client ; ne bâtissez pas de logique métier qui l'attend. La vérification de la signature et la déduplication sont détaillées dans Recevoir messages et statuts par webhook.

Quelles erreurs rencontrerez-vous le plus souvent ?

ErreurOù la lireSignificationQue faire
400 missing_fieldsRéponseconnectionId ou to absentCompléter la requête
400 recipient_is_senderRéponseto est le numéro qui envoieChanger de destinataire
402 subscription_past_dueRéponseL'essai ou la période payée est échu : rien ne part avant le renouvellementRenouveler la formule, puis renvoyer ce que notifyOnce a classé failed
402 plan_limit_messagesRéponseQuota de messages de la période épuiséAjouter des numéros ou changer de formule
404 connection_not_foundRéponseLe connectionId n'existe pas ou n'est pas à la portée de votre cléVérifier l'identifiant et la clé
409 number_releasedRéponseLe numéro a été libéré de votre forfaitLe reconnecter par l'Embedded Signup
403 account_deactivatedRéponseLe client propriétaire du numéro est suspenduLe réactiver depuis sa fiche dans le tableau de bord
132001meta.code ou statut failedTemplate absent dans cette langue, ou non approuvéVérifier nom, langue et statut
132000meta.code ou statut failedNombre de variables différent de celui du templateAligner body sur {{1}}…{{n}}
131026Statut failedMessage non délivrable, par exemple numéro sans WhatsAppBasculer vers l'email ou le SMS
131047Statut failed, parfois jamais reçuTexte libre envoyé plus de 24 h après le dernier message du clientEnvoyer un template ; agir sur le warning de la réponse
130429meta.codeDébit de la Cloud API atteintRéessayer avec un délai croissant
131042meta.code ou statut failedProblème de moyen de paiement sur le compte MetaLe client règle sa facturation côté Meta — voir erreur 131042

Les significations des codes Meta viennent de leur liste des codes d'erreur.

FAQ

Combien coûte une notification de commande sur WhatsApp ?

Meta facture les templates livrés selon leur catégorie et le pays du destinataire, directement sur le compte WhatsApp Business du marchand ; un template utilitaire livré pendant une fenêtre de service ouverte est gratuit (tarifs Meta). Genuka WA ne prend aucune marge sur ces tarifs : vous payez un abonnement par numéro, avec un quota de messages — voir les formules.

Puis-je glisser un code promo dans la confirmation de commande ?

Non. Une offre ou une vente additionnelle dans un template utilitaire le fait reclasser en marketing par Meta. Envoyez la promotion à part, dans un template marketing, aux clients qui ont accepté d'en recevoir.

Faut-il le consentement du client ?

Oui. Meta demande d'indiquer clairement que la personne accepte de recevoir des messages de votre entreprise, et de nommer cette entreprise (doc Meta, opt-in). Une case « Recevoir le suivi de ma commande de la part de [Nom de la boutique] sur WhatsApp » au moment du paiement, enregistrée avec la commande, remplit ces deux conditions.

Que faire si le client n'a pas WhatsApp ?

Le statut revient failed avec le code 131026. Ne réessayez pas : envoyez la même information par email ou SMS.

Je gère plusieurs boutiques : comment choisir le numéro d'envoi ?

Chaque boutique connecte son numéro et obtient son connectionId ; c'est lui qui décide d'où part le message. Une clé partenaire atteint toutes vos boutiques, une clé client une seule — voir Authentification et, pour retrouver le numéro d'une boutique, Connecter un numéro.

Sources

Sur cette page