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.
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.
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.
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
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 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.
| Question | Ré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.