Protéger une action sensible (solde, historique, paiement) : lien unique et PIN

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.

Protéger une action sensible (solde, historique, paiement) : lien unique et PIN

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.

Scénario

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.

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. »

Code

  1. Votre serveur fabrique un jeton aléatoire, l'enregistre avec le numéro et une heure d'expiration, puis envoie le lien. Dans les 24 h qui suivent le message du client, c'est du texte libre :

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())
  1. Si la conversation est fermée (plus de 24 h), envoyez le même lien dans un template à lien variable : le bouton « Ouvrir » reçoit le jeton dans button_url_param :

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())
  1. Sur votre page, appliquez les règles ci-dessous, puis répondez au client par POST /v1/messages/reply.

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 };
}

Les règles que votre page doit appliquer

RèglePourquoi
Jeton aléatoire long, jamais le numéro ni un identifiant devinableun 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 correctun lien transféré ne sert plus
Nombre d'essais limité (5 en 10 min dans l'exemple), puis refusbloque la devinette de PIN
PIN stocké haché, jamais en clairune fuite de base ne révèle pas les codes
Réponse WhatsApp uniquement après la validation côté serveurle 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.