Il n'y a pas de bouton d'action sécurisée ni de saisie masquée dans WhatsApp via /v1 : envoyez un lien à usage unique et de courte durée, et faites saisir le PIN sur votre page web, jamais dans la conversation.
Astuce
Il n'y a pas de bouton d'action sécurisée ni de saisie masquée dans WhatsApp via /v1 : envoyez un lien à usage unique et de courte durée, et faites saisir le PIN sur votre page web, jamais dans la conversation.
Un client écrit « Mon solde » ou « Mes dernières opérations ». Vous ne voulez pas afficher ces données dans une conversation sur la seule foi de son numéro, et surtout vous ne voulez pas qu'il tape son PIN dans WhatsApp.
Ce que l'API ne fait pas : /v1 n'a ni bouton d'action interactif (seulement des boutons de réponse et des listes, qui renvoient un texte) ni champ de saisie masqué. Un code tapé dans le chat reste lisible dans l'historique du téléphone, des sauvegardes et chez les contacts qui voient l'écran.
Ce qui marche : envoyer un lien unique vers votre page web. Le client y saisit son PIN ; votre serveur vérifie, puis lui répond sur WhatsApp. Le PIN ne transite jamais par la conversation.
Client : Mon solde
Serveur : numéro connu (reconnu par from) mais pas encore authentifié
Vous : « Pour votre sécurité, ouvrez ce lien et saisissez votre PIN (valable 15 min, un seul usage) »
https://app.example.com/l/plk_x7k2m9qa
Client : (ouvre la page, saisit son PIN, valide)
Serveur : jeton marqué utilisé, session ouverte pour ce numéro
Vous : « Merci Awa, votre solde est de 45 000 FCFA. »
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": "Pour votre sécurité, ouvrez ce lien et saisissez votre PIN (valable 15 min, un seul usage) : https://app.example.com/l/plk_x7k2m9qa"
}'
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": "Pour votre sécurité, ouvrez ce lien et saisissez votre PIN (valable 15 min, un seul usage) : https://app.example.com/l/plk_x7k2m9qa"
}),
});
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" => "Pour votre sécurité, ouvrez ce lien et saisissez votre PIN (valable 15 min, un seul usage) : https://app.example.com/l/plk_x7k2m9qa",
]),
]);
$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": "Pour votre sécurité, ouvrez ce lien et saisissez votre PIN (valable 15 min, un seul usage) : https://app.example.com/l/plk_x7k2m9qa",
},
timeout=15,
)
print(res.status_code, res.json())
curl
curl -X POST "https://wa-api.geskap.com/v1/messages" \
-H "Authorization: Bearer $GESKAP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+237690000001",
"template": {
"name": "ouvrir_espace_securise",
"language": "fr",
"variables": ["Awa"],
"button_url_param": "plk_x7k2m9qa"
},
"idempotency_key": "lien-plk_x7k2m9qa"
}'
Node.js
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": "+237690000001",
"template": {
"name": "ouvrir_espace_securise",
"language": "fr",
"variables": ["Awa"],
"button_url_param": "plk_x7k2m9qa"
},
"idempotency_key": "lien-plk_x7k2m9qa"
}),
});
console.log(res.status, await res.json());
PHP
$ch = curl_init("https://wa-api.geskap.com/v1/messages");
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",
"template" => [
"name" => "ouvrir_espace_securise",
"language" => "fr",
"variables" => ["Awa"],
"button_url_param" => "plk_x7k2m9qa",
],
"idempotency_key" => "lien-plk_x7k2m9qa",
]),
]);
$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",
headers={"Authorization": f"Bearer {os.environ['GESKAP_API_KEY']}"},
json={
"to": "+237690000001",
"template": {
"name": "ouvrir_espace_securise",
"language": "fr",
"variables": ["Awa"],
"button_url_param": "plk_x7k2m9qa",
},
"idempotency_key": "lien-plk_x7k2m9qa",
},
timeout=15,
)
print(res.status_code, res.json())
Node.js
// Your server — the PIN is checked on YOUR page, never in WhatsApp.
import crypto from "node:crypto";
export function createLoginLink(phone) {
const token = "plk_" + crypto.randomBytes(24).toString("base64url"); // unguessable
const expiresAt = Date.now() + 15 * 60_000; // short TTL
db.linkTokens.insert({ token, phone, expiresAt, usedAt: null });
return `https://app.example.com/l/${token}`;
}
export function submitPin(token, pin) {
const link = db.linkTokens.get(token);
if (!link || link.usedAt || link.expiresAt < Date.now()) return { status: 410 }; // expired or used
if (tooManyAttempts(link.phone)) return { status: 429 }; // e.g. 5 per 10 min
if (!pinMatches(pin, db.customers.byPhone(link.phone).pinHash)) {
recordFailure(link.phone);
return { status: 401 };
}
db.linkTokens.update(token, { usedAt: Date.now() }); // single use
sendBalanceOnWhatsApp(link.phone); // POST /v1/messages/reply
return { status: 200 };
}
| Règle | Pourquoi |
|---|---|
| Jeton aléatoire long, jamais le numéro ni un identifiant devinable | un lien deviné donne accès |
| Durée de vie courte (15 min dans l'exemple) | un lien oublié dans l'historique expire |
| Usage unique : marqué utilisé dès que le PIN est correct | un lien transféré ne sert plus |
| Nombre d'essais limité (5 en 10 min dans l'exemple), puis refus | bloque la devinette de PIN |
| PIN stocké haché, jamais en clair | une fuite de base ne révèle pas les codes |
| Réponse WhatsApp uniquement après la validation côté serveur | le chat ne montre rien avant l'authentification |
Attention
Piège : ne confondez pas le numéro et la preuve. from dit quel téléphone écrit (Reconnaître un client à son numéro), pas qui le tient. Le PIN sur votre page est ce qui prouve la personne.
Attention
Piège : button_url_param n'accepte que la fin du lien (200 caractères, sans espace) et ne peut pas changer de domaine. Le jeton va là ; le domaine reste celui de votre page approuvée par Meta.
Attention
Piège : n'envoyez jamais le PIN, un code de carte ou un mot de passe dans un message, même en template. Si vous avez besoin d'un code à usage unique envoyé par WhatsApp, lisez d'abord Envoyer un code de vérification (OTP).
Ce parcours est celui de la démonstration WhatsApp de CamaireTech (lien à usage unique, PIN saisi sur la page web, essais limités). Cette démonstration envoie directement à Meta : elle prouve la logique de conversation, pas l'appel /v1.
Preuve — vérifié dans le code de l'API : api_schemas.py, public_api.py, templates.py ; appliqué par la démo WhatsApp : auth, config, engine.