# Notifications de commande WhatsApp depuis votre backend

URL: https://wa.genuka.com/docs/guides/order-notifications
Language: French

> 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](https://wa.genuka.com/docs/quickstart).
* Une clé API, utilisée **uniquement côté serveur** — voir [Authentification](https://wa.genuka.com/docs/authentication).
* **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](https://developers.facebook.com/documentation/business-messaging/whatsapp/solution-providers/overview)).
  Sans lui, les templates sont approuvés mais chaque envoi revient en erreur `131042` — voir
  [erreur 131042](https://wa.genuka.com/docs/errors/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 :

| Étape              | Template             | Variables du corps           | Bouton                               |
| ------------------ | -------------------- | ---------------------------- | ------------------------------------ |
| Commande confirmée | `commande_confirmee` | prénom, numéro, montant      | URL « Voir ma commande »             |
| Commande expédiée  | `commande_expediee`  | prénom, numéro, transporteur | URL « Suivre le colis »              |
| Commande livrée    | `commande_livree`    | prénom, numéro               | Aucun : 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](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/template-categorization)).
Gardez les promotions hors de ces messages.

## Comment créer un template utilitaire ?

1. ### 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.

   ```bash title="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.

2. ### 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`.

3. ### 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](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/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.

**Node.js**

```ts title="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;
}
```

**Python**

```python title="send_order_shipped.py"
import os

import requests

API = "https://wa.genuka.com/api/v1"


class NotificationError(Exception):
    def __init__(self, status, body):
        super().__init__(body.get("message") or body.get("error"))
        # Un refus définitif (template, numéro) ne se réessaie pas : meta.retryable le dit.
        self.retryable = status >= 500 or body.get("meta", {}).get("retryable") is True


def send_order_shipped(order):
    response = requests.post(
        f"{API}/messages",
        headers={
            "Authorization": f"Bearer {os.environ['GENUKA_WA_API_KEY']}",
            "User-Agent": "boutique/1.0 (+https://boutique.example.com)",
        },
        json={
            "connectionId": os.environ["GENUKA_WA_CONNECTION_ID"],
            "to": order["phone"],  # international, avec le + : "+237699001122"
            "template": {
                "name": "commande_expediee",
                "language": "fr",
                "body": [order["first_name"], order["number"], order["carrier"]],
                "buttons": [{"type": "url", "text": order["number"]}],
            },
        },
        timeout=15,
    )
    try:
        body = response.json()
    except ValueError:  # un 5xx renvoyé par le CDN n'est pas du JSON
        body = {}
    if not response.ok:
        raise NotificationError(response.status_code, body)
    return body["data"]["messageId"]
```

**PHP (Laravel)**

```php title="app/Notifications/SendOrderShipped.php"
<?php

use Illuminate\Support\Facades\Http;

function sendOrderShipped(Order $order): string
{
    $response = Http::withToken(config('services.genuka_wa.key'))
        ->timeout(15)
        ->post('https://wa.genuka.com/api/v1/messages', [
            'connectionId' => config('services.genuka_wa.connection_id'),
            'to' => $order->phone, // international, avec le + : "+237699001122"
            'template' => [
                'name' => 'commande_expediee',
                'language' => 'fr',
                'body' => [$order->first_name, $order->number, $order->carrier],
                'buttons' => [['type' => 'url', 'text' => $order->number]],
            ],
        ]);

    if ($response->failed()) {
        // Un refus définitif (template, numéro) ne se réessaie pas : meta.retryable le dit.
        $retryable = $response->serverError() || $response->json('meta.retryable') === true;
        throw new OrderNotificationFailed($response->json('message') ?? $response->json('error'), $retryable);
    }

    return $response->json('data.messageId');
}
```

Appelez-la depuis un job en file (`ShouldQueue`) plutôt que dans la requête de paiement : le
client n'a pas à attendre WhatsApp pour voir sa page de confirmation.

**curl**

```bash title="POST /api/v1/messages"
curl -X POST https://wa.genuka.com/api/v1/messages \
  -H "Authorization: Bearer $GENUKA_WA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "connectionId": "con_1",
    "to": "+237699001122",
    "template": {
      "name": "commande_expediee",
      "language": "fr",
      "body": ["Awa", "CMD-1042", "DHL"],
      "buttons": [{ "type": "url", "text": "CMD-1042" }]
    }
  }'
```

```json title="Réponse"
{ "data": { "messageId": "wamid.HBg…" } }
```

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](https://wa.genuka.com/docs/campaigns) ; tous les types de contenu sont décrits dans
[Envoyer des messages](https://wa.genuka.com/docs/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](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/send-messages)).

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` :

```json title="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](https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing)).

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](https://developers.facebook.com/documentation/business-messaging/whatsapp/support/error-codes)) ;
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 :

```json title="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.

```sql title="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)
);
```

```ts title="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](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status)).

```ts title="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](https://wa.genuka.com/docs/guides/receive-messages-webhooks).

## Quelles erreurs rencontrerez-vous le plus souvent ?

| Erreur                      | Où la lire                           | Signification                                                               | Que faire                                                                            |
| --------------------------- | ------------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `400 missing_fields`        | Réponse                              | `connectionId` ou `to` absent                                               | Compléter la requête                                                                 |
| `400 recipient_is_sender`   | Réponse                              | `to` est le numéro qui envoie                                               | Changer de destinataire                                                              |
| `402 subscription_past_due` | Réponse                              | L'essai ou la période payée est échu : rien ne part avant le renouvellement | Renouveler la formule, puis renvoyer ce que `notifyOnce` a classé `failed`           |
| `402 plan_limit_messages`   | Réponse                              | Quota de messages de la période épuisé                                      | Ajouter des numéros ou changer de formule                                            |
| `404 connection_not_found`  | Réponse                              | Le `connectionId` n'existe pas ou n'est pas à la portée de votre clé        | Vérifier l'identifiant et la clé                                                     |
| `409 number_released`       | Réponse                              | Le numéro a été libéré de votre forfait                                     | Le reconnecter par l'Embedded Signup                                                 |
| `403 account_deactivated`   | Réponse                              | Le client propriétaire du numéro est suspendu                               | Le réactiver depuis sa fiche dans le tableau de bord                                 |
| `132001`                    | `meta.code` ou statut `failed`       | Template absent dans cette langue, ou non approuvé                          | Vérifier nom, langue et statut                                                       |
| `132000`                    | `meta.code` ou statut `failed`       | Nombre de variables différent de celui du template                          | Aligner `body` sur `{{1}}`…`{{n}}`                                                   |
| `131026`                    | Statut `failed`                      | Message non délivrable, par exemple numéro sans WhatsApp                    | Basculer vers l'email ou le SMS                                                      |
| `131047`                    | Statut `failed`, parfois jamais reçu | Texte libre envoyé plus de 24 h après le dernier message du client          | Envoyer un template ; agir sur le `warning` de la réponse                            |
| `130429`                    | `meta.code`                          | Débit de la Cloud API atteint                                               | Réessayer avec un délai croissant                                                    |
| `131042`                    | `meta.code` ou statut `failed`       | Problème de moyen de paiement sur le compte Meta                            | Le client règle sa facturation côté Meta — voir [erreur 131042](https://wa.genuka.com/docs/errors/131042) |

Les significations des codes Meta viennent de leur
[liste des codes d'erreur](https://developers.facebook.com/documentation/business-messaging/whatsapp/support/error-codes).

## 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](https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing)).
Genuka WA ne prend aucune marge sur ces tarifs : vous payez un abonnement par numéro, avec un
quota de messages — voir les [formules](https://wa.genuka.com/#pricing).

### 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](https://developers.facebook.com/documentation/business-messaging/whatsapp/getting-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](https://wa.genuka.com/docs/authentication) et, pour retrouver le numéro d'une boutique,
[Connecter un numéro](https://wa.genuka.com/docs/onboarding).

## Sources

* [Meta — Template categorization](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/template-categorization)
* [Meta — Time-to-live](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/time-to-live)
* [Meta — Send messages (customer service window)](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/send-messages)
* [Meta — Pricing](https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing)
* [Meta — Status messages webhook](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status)
* [Meta — Error codes](https://developers.facebook.com/documentation/business-messaging/whatsapp/support/error-codes)
* [Meta — Getting opt-in](https://developers.facebook.com/documentation/business-messaging/whatsapp/getting-opt-in)
* [Meta — Partners (Tech Providers et Solution Partners)](https://developers.facebook.com/documentation/business-messaging/whatsapp/solution-providers/overview)
