Gérer STOP et la désinscription d'un client : l'API ne le fait pas pour vous

Non, l'API ne gère pas STOP : aucune liste de désinscrits ni blocage à l'envoi. Vous filtrez STOP dans message.inbound, tenez votre propre liste et la consultez avant chaque POST /v1/messages.

Gérer STOP et la désinscription d'un client : l'API ne le fait pas pour vous

Astuce
Non, l'API ne gère pas STOP : aucune liste de désinscrits ni blocage à l'envoi. Vous filtrez STOP dans message.inbound, tenez votre propre liste et la consultez avant chaque POST /v1/messages.

Scénario

Vous envoyez des promotions ou des rappels. Un client répond « STOP ». Vous devez cesser de lui écrire. L'API ne le fait pas à votre place. Le message « STOP » arrive sur votre webhook comme n'importe quel texte, et rien n'empêche ensuite un POST /v1/messages vers ce numéro : il n'y a ni liste de désinscrits côté API, ni contrôle à l'envoi.

Le mot « STOP » n'a de traitement automatique que pour d'autres usages internes de la plateforme, pas pour les comptes API. La responsabilité de respecter la demande du client est la vôtre : les règles de WhatsApp Business demandent de cesser de contacter quelqu'un qui le refuse.

Le circuit

Client      : STOP
Webhook     : message.inbound  from="+237690000001"  text="STOP"
Votre code  : 1. enregistrer +237690000001 dans VOTRE liste de désinscrits
              2. confirmer par POST /v1/messages/reply  (fenêtre de 24 h ouverte)
Plus tard   : avant chaque POST /v1/messages, chercher le numéro dans la liste
              → présent : ne rien envoyer

Code

Node.js

// 1) On message.inbound: is it a STOP?  2) Before every send: is the number listed?
const STOP_WORDS = new Set(["stop", "arret", "desabonner", "unsubscribe"]);   // + your button's title

function normalize(text) {
  return String(text || "")
    .normalize("NFD").replace(/[̀-ͯ]/g, "")   // no accents
    .toLowerCase().replace(/[\s.!?,;:'"]+$/g, "").trim();
}
const digits = (phone) => String(phone).replace(/\D/g, "");

export async function onInbound(data) {                 // data = message.inbound payload
  const said = normalize(data.text);
  if (STOP_WORDS.has(said) || said === "se desinscrire") {
    await optouts.add(digits(data.from));               // YOUR table
    await fetch("https://wa-api.geskap.com/v1/messages/reply", {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.GESKAP_API_KEY}`, "Content-Type": "application/json" },
      body: JSON.stringify({ to: data.from, from: data.number,
        text: "C'est noté : vous ne recevrez plus nos messages. Répondez START pour les reprendre." }),
    });
  } else if (said === "start") {
    await optouts.remove(digits(data.from));
  }
}

// The ONLY function your code uses to send a template.
export async function sendTemplate(to, template, idempotencyKey) {
  if (await optouts.has(digits(to))) return { skipped: "opted-out" };
  const res = await fetch("https://wa-api.geskap.com/v1/messages", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.GESKAP_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ to, template, idempotency_key: idempotencyKey }),
  });
  return res.json();
}

PHP

<?php
// 1) On message.inbound: is it a STOP?  2) Before every send: is the number listed?
const STOP_WORDS = ["stop", "arret", "desabonner", "unsubscribe"];   // + your button's title

function normalize(?string $text): string {
    $t = iconv("UTF-8", "ASCII//TRANSLIT//IGNORE", $text ?? "");   // no accents
    return trim(rtrim(strtolower($t), " .!?,;:'\""));
}
function digits(string $phone): string { return preg_replace("/\D/", "", $phone); }

function on_inbound(array $data): void {                          // $data = message.inbound payload
    $said = normalize($data["text"]);
    if (in_array($said, STOP_WORDS, true) || $said === "se desinscrire") {
        optouts_add(digits($data["from"]));                       // YOUR table
        api_post("/v1/messages/reply", [
            "to" => $data["from"], "from" => $data["number"],
            "text" => "C'est noté : vous ne recevrez plus nos messages. Répondez START pour les reprendre.",
        ]);
    } elseif ($said === "start") {
        optouts_remove(digits($data["from"]));
    }
}

// The ONLY function your code uses to send a template.
function send_template(string $to, array $template, string $idempotencyKey): array {
    if (optouts_has(digits($to))) return ["skipped" => "opted-out"];
    return api_post("/v1/messages", ["to" => $to, "template" => $template, "idempotency_key" => $idempotencyKey]);
}

Python

# 1) On message.inbound: is it a STOP?  2) Before every send: is the number listed?
import os, re, unicodedata, requests

STOP_WORDS = {"stop", "arret", "desabonner", "unsubscribe"}   # + your button's title
API = "https://wa-api.geskap.com"
AUTH = {"Authorization": f"Bearer {os.environ['GESKAP_API_KEY']}"}

def normalize(text):
    t = unicodedata.normalize("NFD", text or "")
    t = "".join(c for c in t if unicodedata.category(c) != "Mn")   # no accents
    return t.lower().rstrip(" .!?,;:'\"").strip()

def digits(phone):
    return re.sub(r"\D", "", phone)

def on_inbound(data):                       # data = message.inbound payload
    said = normalize(data.get("text"))
    if said in STOP_WORDS or said == "se desinscrire":
        optouts.add(digits(data["from"]))   # YOUR table
        requests.post(f"{API}/v1/messages/reply", headers=AUTH, timeout=15, json={
            "to": data["from"], "from": data["number"],
            "text": "C'est noté : vous ne recevrez plus nos messages. Répondez START pour les reprendre."})
    elif said == "start":
        optouts.remove(digits(data["from"]))

def send_template(to, template, idempotency_key):
    """The ONLY function your code uses to send a template."""
    if optouts.has(digits(to)):
        return {"skipped": "opted-out"}
    r = requests.post(f"{API}/v1/messages", headers=AUTH, timeout=15,
                      json={"to": to, "template": template, "idempotency_key": idempotency_key})
    return r.json()

La confirmation part par POST /v1/messages/reply : le client vient d'écrire, la fenêtre de 24 h est ouverte, pas besoin de template.

curl

curl -X POST "https://wa-api.geskap.com/v1/messages/reply" \
  -H "Authorization: Bearer $GESKAP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "to": "+237690000001",
  "text": "C'\''est noté : vous ne recevrez plus nos messages. Répondez START pour les reprendre."
}'

Node.js

const res = await fetch("https://wa-api.geskap.com/v1/messages/reply", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.GESKAP_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "to": "+237690000001",
    "text": "C'est noté : vous ne recevrez plus nos messages. Répondez START pour les reprendre."
  }),
});
console.log(res.status, await res.json());

PHP

$ch = curl_init("https://wa-api.geskap.com/v1/messages/reply");
curl_setopt_array($ch, [
  CURLOPT_CUSTOMREQUEST => "POST",
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("GESKAP_API_KEY"),
    "Content-Type: application/json",
  ],
  CURLOPT_POSTFIELDS => json_encode([
    "to" => "+237690000001",
    "text" => "C'est noté : vous ne recevrez plus nos messages. Répondez START pour les reprendre.",
  ]),
]);
$res = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $res;

Python

import os, requests

res = requests.request(
    "POST",
    "https://wa-api.geskap.com/v1/messages/reply",
    headers={"Authorization": f"Bearer {os.environ['GESKAP_API_KEY']}"},
    json={
        "to": "+237690000001",
        "text": "C'est noté : vous ne recevrez plus nos messages. Répondez START pour les reprendre.",
    },
    timeout=15,
)
print(res.status_code, res.json())

Un bouton « Se désinscrire » dans vos templates

Un template de marketing peut porter un bouton de réponse rapide « Se désinscrire » (deux boutons au plus par template). Le clic arrive en message.inbound avec le libellé dans text et l'identifiant dans button_id (voir Savoir quel bouton le client a touché et Template à boutons de réponse rapide). Traitez-le comme un STOP. C'est plus simple pour le client que de taper un mot.

Ce qu'il faut décider

QuestionRéponse conseillée
Quels mots ?STOP, ARRET, ARRÊT, DESABONNER, UNSUBSCRIBE, plus le libellé de votre bouton
Comment comparer ?Sans accents ni majuscules, sur le message entier (pas « stop » au milieu d'une phrase)
Quel numéro stocker ?data.from, normalisé comme vos numéros d'envoi (chiffres seuls)
Réactiver ?Un mot explicite du client (par exemple START) : jamais de votre propre initiative
Liste illisible au moment d'envoyer ?N'envoyez pas
Attention
Piège : l'API ne bloque rien à l'envoi. Si votre code oublie de consulter la liste, le message part et il est facturé, même vers un client désinscrit. Mettez la vérification dans une seule fonction d'envoi que tout votre code utilise.
Attention
Piège : tenez la liste dans votre base. Les messages reçus ne sont conservés côté API que pour la durée de votre plan, puis purgés : retrouver un ancien STOP dans l'historique n'est pas fiable (voir Archiver messages et médias).
Attention
Piège : ne confondez pas les événements. Un message.echo est un message envoyé depuis le téléphone par votre équipe : un « STOP » écrit par un de vos employés n'est pas une désinscription de client. Ne traitez que message.inbound.

Les messages de service liés à une commande en cours ne sont pas du marketing, mais décidez-le explicitement et notez-le. Pour la désinscription par e-mail ou d'autres canaux, cette recette ne s'applique pas.

Preuve — vérifié dans le code de l'API : webhooks.py, marketing_optout.py, api_send.py, public_api.py, api_schemas.py.