Envoyez du WhatsApp
sans toucher à Meta.
La Gateway est un service HTTP qui parle à l'API WhatsApp Cloud à votre place. Vous envoyez du JSON, vous recevez du JSON.
Vous n'avez ni token Meta à protéger, ni signature Graph à valider, ni structure de webhook imbriquée à démêler. La Gateway s'occupe des statuts de livraison, de la déduplication des événements et des nouvelles tentatives.
Votre application garde toute sa logique métier. La Gateway transporte des messages, elle ne décide de rien.
Le numéro expéditeur ne s'envoie pas
Aucune requête ne contient de numéro d'expéditeur. La Gateway le déduit de l'application authentifiée. Vous ne pouvez donc jamais émettre depuis le numéro de quelqu'un d'autre, même par erreur.
Ce que l'administrateur vous remet
La clé API n'est affichée qu'une fois. En cas de perte, l'administrateur en génère une nouvelle et l'ancienne cesse aussitôt de fonctionner.
# Seul endpoint public
curl https://gateway.exemple.sn/api/v1/health
{
"status": "ok",
"service": "whatsapp-gateway",
"time": "2026-08-20T12:19:38+00:00"
}
Authentification
Chaque requête porte deux en-têtes. Il n'y a ni session, ni token à rafraîchir : les identifiants voyagent avec chaque appel.
| En-tête | Valeur |
|---|---|
| X-App-ID | L'UUID de votre application |
| X-API-Key | Votre clé secrète, préfixée wag_ |
| Accept | application/json |
Sans Accept: application/json, les erreurs de validation reviennent en HTML au lieu de JSON. C'est la première chose à vérifier si vos réponses d'erreur sont illisibles.
Réponses possibles
| Code | Ce que ça veut dire |
|---|---|
| 401 | En-têtes absents ou identifiants invalides |
| 403 | Identifiants bons, application désactivée |
| 429 | Quota dépassé, 60 requêtes par minute |
Un 401 ne précise jamais si c'est l'application ou la clé qui pose problème. Rien ne doit filtrer sur les applications existantes.
Gardez la clé dans une variable d'environnement. Jamais dans le code, jamais dans un dépôt Git.
curl https://gateway.exemple.sn/api/v1/me \
-H "X-App-ID: $GATEWAY_APP_ID" \
-H "X-API-Key: $GATEWAY_API_KEY" \
-H "Accept: application/json"
use Illuminate\Support\Facades\Http;
$reponse = Http::withHeaders([
'X-App-ID' => config('gateway.app_id'),
'X-API-Key' => config('gateway.api_key'),
])
->acceptJson()
->timeout(10)
->get(config('gateway.url') . '/api/v1/me');
dd($reponse->json());
import 'dart:convert';
import 'package:http/http.dart' as http;
final reponse = await http.get(
Uri.parse('$baseUrl/api/v1/me'),
headers: {
'X-App-ID': appId,
'X-API-Key': apiKey,
'Accept': 'application/json',
},
);
print(jsonDecode(reponse.body));
const reponse = await fetch(
`${baseUrl}/api/v1/me`,
{
headers: {
'X-App-ID': process.env.GATEWAY_APP_ID,
'X-API-Key': process.env.GATEWAY_API_KEY,
'Accept': 'application/json',
},
}
);
console.log(await reponse.json());
import os, requests
reponse = requests.get(
f"{BASE_URL}/api/v1/me",
headers={
"X-App-ID": os.environ["GATEWAY_APP_ID"],
"X-API-Key": os.environ["GATEWAY_API_KEY"],
"Accept": "application/json",
},
timeout=10,
)
print(reponse.json())
{
"application": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Ma Boutique",
"callback_url": "https://maboutique.sn/whatsapp"
},
"whatsapp_number": {
"phone_number": "+221771111111",
"display_name": "Ma Boutique",
"is_active": true
}
}
Message texte
Envoie un message libre. Réservé aux conversations déjà ouvertes.
| Champ | Description |
|---|---|
| torequis | Numéro au format E.164 : un + suivi de 8 à 15 chiffres |
| textrequis | De 1 à 4096 caractères |
La fenêtre de 24 heures. WhatsApp n'autorise le texte libre que dans les 24 heures suivant le dernier message de la personne. Passé ce délai, l'envoi échoue avec le code Meta 131047 et vous devez passer par un template.
La réponse arrive en 202 : le message est accepté, pas encore livré. Conservez l'id pour suivre son acheminement.
Normalisez les numéros avant l'envoi. Un numéro sénégalais saisi sans indicatif — 771234567 — doit devenir +221771234567.
curl -X POST https://gateway.exemple.sn/api/v1/messages/text \
-H "X-App-ID: $GATEWAY_APP_ID" \
-H "X-API-Key: $GATEWAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"to": "+221771234567",
"text": "Votre commande CMD-4521 est prête."
}'
class GatewayClient
{
public function envoyerTexte(string $vers, string $texte): array
{
$reponse = Http::withHeaders([
'X-App-ID' => config('gateway.app_id'),
'X-API-Key' => config('gateway.api_key'),
])
->acceptJson()
->timeout(10)
->post(config('gateway.url') . '/api/v1/messages/text', [
'to' => $vers,
'text' => $texte,
]);
return $reponse->json();
}
}
class GatewayClient {
final String baseUrl, appId, apiKey;
GatewayClient(this.baseUrl, this.appId, this.apiKey);
Map<String, String> get _entetes => {
'X-App-ID': appId,
'X-API-Key': apiKey,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
Future<Map<String, dynamic>> envoyerTexte(
String vers, String texte,
) async {
final reponse = await http.post(
Uri.parse('$baseUrl/api/v1/messages/text'),
headers: _entetes,
body: jsonEncode({'to': vers, 'text': texte}),
).timeout(const Duration(seconds: 10));
return jsonDecode(reponse.body);
}
}
const entetes = {
'X-App-ID': process.env.GATEWAY_APP_ID,
'X-API-Key': process.env.GATEWAY_API_KEY,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
async function envoyerTexte(vers, texte) {
const reponse = await fetch(
`${baseUrl}/api/v1/messages/text`,
{
method: 'POST',
headers: entetes,
body: JSON.stringify({ to: vers, text: texte }),
}
);
return reponse.json();
}
ENTETES = {
"X-App-ID": os.environ["GATEWAY_APP_ID"],
"X-API-Key": os.environ["GATEWAY_API_KEY"],
"Accept": "application/json",
}
def envoyer_texte(vers: str, texte: str) -> dict:
reponse = requests.post(
f"{BASE_URL}/api/v1/messages/text",
headers=ENTETES,
json={"to": vers, "text": texte},
timeout=10,
)
return reponse.json()
{
"id": "3489c425-ce2a-480a-aa08-b485976c63e6",
"status": "pending",
"direction": "outbound",
"type": "text",
"to": "+221771234567",
"from": "+221771111111",
"created_at": "2026-08-20T12:19:38+00:00",
"sent_at": null,
"error": null
}
Template
Le seul moyen d'écrire à quelqu'un qui ne vous a pas contacté. Le modèle doit avoir été créé et approuvé par Meta au préalable — demandez-le à l'administrateur de la Gateway.
| Champ | Description |
|---|---|
| torequis | Numéro au format E.164 |
| templaterequis | Nom exact du modèle approuvé |
| language | Code de langue : fr, fr_FR, en_US |
| parameters | Valeurs des variables, dans l'ordre |
Les paramètres sont positionnels
Le premier élément remplit 1, le deuxième 2, et ainsi de suite. Pour un modèle qui dit « Bonjour 1, votre commande 2 est prête », vous envoyez ["Amadou", "CMD-4521"].
Le code de langue doit correspondre exactement à celui du modèle chez Meta. Un modèle en fr appelé avec fr_FR échoue avec l'erreur 132001.
curl -X POST https://gateway.exemple.sn/api/v1/messages/template \
-H "X-App-ID: $GATEWAY_APP_ID" \
-H "X-API-Key: $GATEWAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"to": "+221771234567",
"template": "confirmation_commande",
"language": "fr",
"parameters": ["Amadou", "CMD-4521", "15 000 FCFA"]
}'
public function envoyerTemplate(
string $vers,
string $modele,
array $parametres = [],
string $langue = 'fr'
): array {
return Http::withHeaders($this->entetes())
->acceptJson()
->post($this->url . '/api/v1/messages/template', [
'to' => $vers,
'template' => $modele,
'language' => $langue,
'parameters' => $parametres,
])
->json();
}
Future<Map<String, dynamic>> envoyerTemplate({
required String vers,
required String modele,
List<String> parametres = const [],
String langue = 'fr',
}) async {
final reponse = await http.post(
Uri.parse('$baseUrl/api/v1/messages/template'),
headers: _entetes,
body: jsonEncode({
'to': vers,
'template': modele,
'language': langue,
'parameters': parametres,
}),
);
return jsonDecode(reponse.body);
}
async function envoyerTemplate(
vers, modele, parametres = [], langue = 'fr'
) {
const reponse = await fetch(
`${baseUrl}/api/v1/messages/template`,
{
method: 'POST',
headers: entetes,
body: JSON.stringify({
to: vers,
template: modele,
language: langue,
parameters: parametres,
}),
}
);
return reponse.json();
}
def envoyer_template(
vers: str,
modele: str,
parametres: list[str] | None = None,
langue: str = "fr",
) -> dict:
reponse = requests.post(
f"{BASE_URL}/api/v1/messages/template",
headers=ENTETES,
json={
"to": vers,
"template": modele,
"language": langue,
"parameters": parametres or [],
},
timeout=10,
)
return reponse.json()
Boutons et listes
Proposez des choix à toucher plutôt qu'à écrire. Vous récupérez ensuite l'identifiant exact de l'option choisie, sans avoir à interpréter du texte libre.
| Champ | Description |
|---|---|
| torequis | Numéro au format E.164 |
| bodyrequis | Le message, jusqu'à 1024 caractères |
| optionsrequis | De 1 à 10 choix, identifiants uniques |
| options[].idrequis | Vous revient tel quel au clic |
| options[].titlerequis | Tronqué à 20 caractères en boutons, 24 en liste |
| options[].description | Listes uniquement, tronqué à 72 caractères |
| header | Titre au-dessus, 60 caractères |
| footer | Mention en dessous, 60 caractères |
| button_label | Libellé qui ouvre la liste |
Le format se choisit tout seul
De 1 à 3 options, WhatsApp affiche des boutons sous le message. À partir de 4, une liste déroulante. Vous envoyez vos options, la Gateway retient le meilleur format disponible.
Les identifiants que vous définissez sont ceux qui vous reviennent dans le callback. Choisissez-les stables et parlants pour votre logique — catalogue plutôt que opt_3.
curl -X POST https://gateway.exemple.sn/api/v1/messages/interactive \
-H "X-App-ID: $GATEWAY_APP_ID" \
-H "X-API-Key: $GATEWAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"to": "+221771234567",
"header": "Ma Boutique",
"body": "Que souhaitez-vous faire ?",
"footer": "Touchez une option",
"options": [
{ "id": "prix", "title": "Consulter les prix" },
{ "id": "commande", "title": "Passer commande" },
{ "id": "suivi", "title": "Suivre ma commande" }
]
}'
public function envoyerOptions(
string $vers,
string $message,
array $options
): array {
return Http::withHeaders($this->entetes())
->acceptJson()
->post($this->url . '/api/v1/messages/interactive', [
'to' => $vers,
'body' => $message,
'options' => $options,
])
->json();
}
// Appel
$client->envoyerOptions('+221771234567', 'Que faire ?', [
['id' => 'prix', 'title' => 'Consulter les prix'],
['id' => 'commande', 'title' => 'Passer commande'],
]);
class OptionGateway {
final String id;
final String titre;
final String? description;
const OptionGateway({
required this.id,
required this.titre,
this.description,
});
Map<String, dynamic> versJson() => {
'id': id,
'title': titre,
if (description != null) 'description': description,
};
}
Future<Map<String, dynamic>> envoyerOptions({
required String vers,
required String message,
required List<OptionGateway> options,
String? entete,
}) async {
final reponse = await http.post(
Uri.parse('$baseUrl/api/v1/messages/interactive'),
headers: _entetes,
body: jsonEncode({
'to': vers,
'body': message,
if (entete != null) 'header': entete,
'options': options.map((o) => o.versJson()).toList(),
}),
);
return jsonDecode(reponse.body);
}
async function envoyerOptions(vers, message, options, entete) {
const reponse = await fetch(
`${baseUrl}/api/v1/messages/interactive`,
{
method: 'POST',
headers: entetes,
body: JSON.stringify({
to: vers,
body: message,
...(entete && { header: entete }),
options,
}),
}
);
return reponse.json();
}
// Appel
await envoyerOptions('+221771234567', 'Que faire ?', [
{ id: 'prix', title: 'Consulter les prix' },
{ id: 'commande', title: 'Passer commande' },
]);
def envoyer_options(
vers: str,
message: str,
options: list[dict],
entete: str | None = None,
) -> dict:
charge = {"to": vers, "body": message, "options": options}
if entete:
charge["header"] = entete
reponse = requests.post(
f"{BASE_URL}/api/v1/messages/interactive",
headers=ENTETES,
json=charge,
timeout=10,
)
return reponse.json()
{
"to": "+221771234567",
"body": "Choisissez un produit",
"button_label": "Voir la liste",
"options": [
{ "id": "riz", "title": "Riz", "description": "Sac de 50 kg" },
{ "id": "mil", "title": "Mil", "description": "Sac de 50 kg" },
{ "id": "mais", "title": "Maïs", "description": "Sac de 50 kg" },
{ "id": "arachide", "title": "Arachide", "description": "Sac de 40 kg" }
]
}
Suivre un message
Retourne l'état courant d'un message que vous avez envoyé. L'id est celui reçu à l'envoi.
Vous ne pouvez consulter que vos propres messages. Deviner l'identifiant d'une autre application donne un 404.
C'est le seul moyen de savoir si un message a été livré ou lu. Enregistrez l'id de chaque envoi dans votre base au moment où vous le recevez.
curl https://gateway.exemple.sn/api/v1/messages/3489c425-ce2a-480a-aa08-b485976c63e6 \
-H "X-App-ID: $GATEWAY_APP_ID" \
-H "X-API-Key: $GATEWAY_API_KEY" \
-H "Accept: application/json"
public function suivre(string $id): array
{
return Http::withHeaders($this->entetes())
->acceptJson()
->get($this->url . "/api/v1/messages/{$id}")
->json();
}
Future<Map<String, dynamic>> suivre(String id) async {
final reponse = await http.get(
Uri.parse('$baseUrl/api/v1/messages/$id'),
headers: _entetes,
);
return jsonDecode(reponse.body);
}
async function suivre(id) {
const reponse = await fetch(
`${baseUrl}/api/v1/messages/${id}`,
{ headers: entetes }
);
return reponse.json();
}
def suivre(identifiant: str) -> dict:
reponse = requests.get(
f"{BASE_URL}/api/v1/messages/{identifiant}",
headers=ENTETES,
timeout=10,
)
return reponse.json()
{
"id": "3489c425-ce2a-480a-aa08-b485976c63e6",
"status": "read",
"direction": "outbound",
"type": "text",
"to": "+221771234567",
"from": "+221771111111",
"created_at": "2026-08-20T12:19:38+00:00",
"sent_at": "2026-08-20T12:19:41+00:00",
"error": null
}
Envoyer un code de vérification
La Gateway génère le code, l'envoie par WhatsApp, gère son expiration et compte les tentatives. Votre application ne voit jamais le code.
| Champ | Description |
|---|---|
| torequis | Numéro au format E.164 |
Règles appliquées
| Règle | Valeur |
|---|---|
| Validité | 5 minutes |
| Tentatives | 5 essais maximum |
| Délai entre demandes | 60 secondes |
| Codes actifs | Un seul par numéro |
Demander un nouveau code invalide le précédent. Un code vérifié est consommé et ne peut pas resservir.
Le code n'apparaît jamais dans la réponse HTTP. Il ne circule que jusqu'au téléphone du destinataire. Toute intégration qui aurait besoin de le lire côté serveur est un défaut de conception.
curl -X POST https://gateway.exemple.sn/api/v1/otp/send \
-H "X-App-ID: $GATEWAY_APP_ID" \
-H "X-API-Key: $GATEWAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{ "to": "+221771234567" }'
public function envoyerCode(string $vers): array
{
$reponse = Http::withHeaders($this->entetes())
->acceptJson()
->post($this->url . '/api/v1/otp/send', [
'to' => $vers,
]);
if ($reponse->status() === 429) {
throw ValidationException::withMessages([
'phone' => 'Patientez une minute avant de redemander un code.',
]);
}
return $reponse->json();
}
Future<Map<String, dynamic>> envoyerCode(String vers) async {
final reponse = await http.post(
Uri.parse('$baseUrl/api/v1/otp/send'),
headers: _entetes,
body: jsonEncode({'to': vers}),
);
if (reponse.statusCode == 429) {
throw Exception(
'Patientez une minute avant de redemander un code.',
);
}
return jsonDecode(reponse.body);
}
async function envoyerCode(vers) {
const reponse = await fetch(
`${baseUrl}/api/v1/otp/send`,
{
method: 'POST',
headers: entetes,
body: JSON.stringify({ to: vers }),
}
);
if (reponse.status === 429) {
throw new Error(
'Patientez une minute avant de redemander un code.'
);
}
return reponse.json();
}
def envoyer_code(vers: str) -> dict:
reponse = requests.post(
f"{BASE_URL}/api/v1/otp/send",
headers=ENTETES,
json={"to": vers},
timeout=10,
)
if reponse.status_code == 429:
raise ValueError(
"Patientez une minute avant de redemander un code."
)
return reponse.json()
{
"status": "sent",
"message_id": "0a6cecf9-9ebf-4297-9cd9-3a71034a7fb7",
"expires_at": "2026-08-19T17:10:26+00:00"
}
Vérifier un code
| Champ | Description |
|---|---|
| torequis | Le même numéro que lors de l'envoi |
| coderequis | De 4 à 8 chiffres |
La Gateway ne décide de rien
Elle répond à une seule question : cette personne contrôle-t-elle ce téléphone ? Ouvrir une session, créer un compte, valider une commande — tout cela reste votre décision.
Erreurs de vérification
| error | Cause |
|---|---|
| otp_not_found | Aucun code en attente, ou déjà utilisé |
| otp_expired | Les 5 minutes sont passées |
| otp_invalid | Code incorrect |
| otp_too_many_attempts | 5 essais atteints, redemander un code |
Toutes reviennent en 422 avec verified: false.
curl -X POST https://gateway.exemple.sn/api/v1/otp/verify \
-H "X-App-ID: $GATEWAY_APP_ID" \
-H "X-API-Key: $GATEWAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{ "to": "+221771234567", "code": "482915" }'
public function verifierCode(string $vers, string $code): bool
{
$reponse = Http::withHeaders($this->entetes())
->acceptJson()
->post($this->url . '/api/v1/otp/verify', [
'to' => $vers,
'code' => $code,
]);
return $reponse->successful()
&& $reponse->json('verified') === true;
}
Future<bool> verifierCode(String vers, String code) async {
final reponse = await http.post(
Uri.parse('$baseUrl/api/v1/otp/verify'),
headers: _entetes,
body: jsonEncode({'to': vers, 'code': code}),
);
if (reponse.statusCode != 200) return false;
final corps = jsonDecode(reponse.body);
return corps['verified'] == true;
}
async function verifierCode(vers, code) {
const reponse = await fetch(
`${baseUrl}/api/v1/otp/verify`,
{
method: 'POST',
headers: entetes,
body: JSON.stringify({ to: vers, code }),
}
);
if (!reponse.ok) return false;
const corps = await reponse.json();
return corps.verified === true;
}
def verifier_code(vers: str, code: str) -> bool:
reponse = requests.post(
f"{BASE_URL}/api/v1/otp/verify",
headers=ENTETES,
json={"to": vers, "code": code},
timeout=10,
)
if reponse.status_code != 200:
return False
return reponse.json().get("verified") is True
{
"verified": true,
"phone": "+221771234567",
"verified_at": "2026-08-19T17:06:46+00:00"
}
{
"verified": false,
"error": "otp_invalid",
"message": "Code incorrect."
}
Recevoir les messages
Dès qu'une personne vous écrit ou touche une option, la Gateway envoie une requête POST à l'URL que vous avez déclarée. C'est le seul moyen de recevoir : il n'y a pas d'endpoint à interroger en boucle.
Sans URL de callback, votre application peut parler mais pas entendre. Elle est indispensable dès que vous voulez un chatbot, un menu, ou toute réaction à ce que dit la personne.
En-têtes reçus
| En-tête | Contenu |
|---|---|
| X-Gateway-Event | message.received |
| X-Gateway-Timestamp | Horodatage Unix de l'envoi |
| X-Gateway-Signature | sha256= suivi du HMAC |
Champs du message
| Champ | Description |
|---|---|
| id | Identifiant Gateway |
| wamid | Identifiant WhatsApp, votre clé de déduplication |
| type | text, interactive, button, image… |
| from | L'expéditeur, en E.164 |
| text | Le texte saisi, ou le libellé de l'option touchée |
| selection | Présent uniquement sur un clic |
| raw | Le payload WhatsApp brut, pour les cas non couverts |
selection.id est l'identifiant exact que vous aviez défini à l'envoi. Aucune analyse de texte à faire : une personne qui touche « Voir le catalogue » vous renvoie catalogue.
{
"event": "message.received",
"sent_at": "2026-08-20T13:17:45+00:00",
"message": {
"id": "ff7ccbe9-80f4-4e17-9315-7dbdb4a29b89",
"wamid": "wamid.HBgMMjIx...",
"type": "text",
"from": "+221785830419",
"to": "+221771111111",
"text": "Je veux 5 sacs de riz",
"timestamp": "2026-08-20T13:16:20+00:00",
"raw": { }
}
}
{
"event": "message.received",
"sent_at": "2026-08-20T13:17:45+00:00",
"message": {
"id": "ff7ccbe9-80f4-4e17-9315-7dbdb4a29b89",
"wamid": "wamid.HBgMMjIx...",
"type": "interactive",
"from": "+221785830419",
"to": "+221771111111",
"text": "Voir le catalogue",
"selection": {
"id": "catalogue",
"title": "Voir le catalogue",
"description": null
},
"timestamp": "2026-08-20T13:16:20+00:00",
"raw": { }
}
}
// La sélection prime sur le texte
const entree =
message.selection?.id ?? message.text;
Vérifier la signature
Votre URL de callback est publique. Sans vérification, n'importe qui peut vous envoyer de faux messages en se faisant passer pour vos utilisateurs. La signature prouve que la requête vient bien de la Gateway et que son contenu n'a pas été modifié en route.
La formule
signature = "sha256=" + HMAC_SHA256(
timestamp + "." + corps_brut,
callback_secret
)
Signez le corps brut, pas un JSON reconstruit. Si vous décodez puis ré-encodez le corps, l'ordre des clés ou l'échappement changent et la signature ne correspondra jamais. C'est de loin l'erreur d'intégration la plus fréquente.
Trois règles
Refusez toute requête dont le timestamp dépasse 5 minutes : cela empêche qu'une requête interceptée soit rejouée plus tard.
Comparez en temps constant — hash_equals, timingSafeEqual, compare_digest. Une comparaison ordinaire s'arrête au premier caractère différent et laisse fuiter de l'information par le temps de réponse.
Répondez 403 si la signature ne passe pas, sans autre détail.
public function recevoir(Request $requete)
{
if (! $this->signatureValide($requete)) {
return response()->json(['error' => 'invalid_signature'], 403);
}
$message = $requete->input('message', []);
$wamid = data_get($message, 'wamid');
// Déduplication : la Gateway peut livrer deux fois
if ($wamid && ! Cache::add('wa:' . hash('sha256', $wamid), true, 600)) {
return response()->json(['status' => 'duplicate']);
}
// Traitement en tâche de fond, jamais dans la requête
TraiterMessage::dispatch(
data_get($message, 'from'),
data_get($message, 'selection.id') ?? data_get($message, 'text')
);
return response()->json(['status' => 'ok']);
}
private function signatureValide(Request $requete): bool
{
$signature = $requete->header('X-Gateway-Signature');
$timestamp = $requete->header('X-Gateway-Timestamp');
$secret = config('gateway.callback_secret');
if (blank($signature) || blank($timestamp) || blank($secret)) {
return false;
}
// Anti-rejeu
if (abs(now()->timestamp - (int) $timestamp) > 300) {
return false;
}
$attendu = 'sha256=' . hash_hmac(
'sha256',
$timestamp . '.' . $requete->getContent(),
$secret
);
return hash_equals($attendu, $signature);
}
// pubspec.yaml : crypto: ^3.0.0
import 'dart:convert';
import 'package:crypto/crypto.dart';
bool signatureValide({
required String corpsBrut,
required String? signature,
required String? timestamp,
required String secret,
}) {
if (signature == null || timestamp == null) return false;
// Anti-rejeu : 5 minutes
final maintenant = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final envoi = int.tryParse(timestamp);
if (envoi == null || (maintenant - envoi).abs() > 300) {
return false;
}
final hmac = Hmac(sha256, utf8.encode(secret));
final calcule = hmac.convert(
utf8.encode('$timestamp.$corpsBrut'),
);
final attendu = 'sha256=${calcule.toString()}';
// Comparaison en temps constant
if (attendu.length != signature.length) return false;
var diff = 0;
for (var i = 0; i < attendu.length; i++) {
diff |= attendu.codeUnitAt(i) ^ signature.codeUnitAt(i);
}
return diff == 0;
}
const crypto = require('crypto');
// Conserver le corps brut au passage
app.use(express.json({
verify: (req, res, buf) => { req.corpsBrut = buf.toString(); },
}));
app.post('/whatsapp/callback', (req, res) => {
const signature = req.get('X-Gateway-Signature');
const timestamp = req.get('X-Gateway-Timestamp');
const secret = process.env.GATEWAY_CALLBACK_SECRET;
if (!signature || !timestamp) {
return res.status(403).json({ error: 'invalid_signature' });
}
// Anti-rejeu
const maintenant = Math.floor(Date.now() / 1000);
if (Math.abs(maintenant - Number(timestamp)) > 300) {
return res.status(403).json({ error: 'expired' });
}
const attendu = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${req.corpsBrut}`)
.digest('hex');
const valide =
attendu.length === signature.length &&
crypto.timingSafeEqual(
Buffer.from(attendu),
Buffer.from(signature)
);
if (!valide) {
return res.status(403).json({ error: 'invalid_signature' });
}
res.json({ status: 'ok' }); // répondre d'abord
traiterEnArrierePlan(req.body.message); // traiter ensuite
});
import hashlib, hmac, time
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET = os.environ["GATEWAY_CALLBACK_SECRET"]
def signature_valide(corps_brut: bytes) -> bool:
signature = request.headers.get("X-Gateway-Signature")
timestamp = request.headers.get("X-Gateway-Timestamp")
if not signature or not timestamp:
return False
# Anti-rejeu : 5 minutes
if abs(time.time() - int(timestamp)) > 300:
return False
attendu = "sha256=" + hmac.new(
SECRET.encode(),
f"{timestamp}.".encode() + corps_brut,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(attendu, signature)
@app.post("/whatsapp/callback")
def recevoir():
if not signature_valide(request.get_data()):
return jsonify(error="invalid_signature"), 403
message = request.json.get("message", {})
entree = (message.get("selection") or {}).get("id") \
or message.get("text")
traiter_en_tache_de_fond(message.get("from"), entree)
return jsonify(status="ok")
Quatre règles pour votre endpoint
Répondez 200 en moins de 8 secondes
Au-delà, la Gateway considère la livraison en échec et retente. Le délai n'est pas négociable.
Traitez en tâche de fond
Ne faites jamais tourner votre logique métier dans le cycle de la requête. Accusez réception, mettez le travail en file d'attente, traitez ensuite. Un appel à un modèle de langage ou une requête lourde en base suffit à dépasser le délai.
Soyez idempotent
La Gateway peut livrer deux fois le même message. Utilisez le wamid comme clé de déduplication, sinon une seule demande produira deux commandes.
Vérifiez la signature
Systématiquement, sur chaque requête. Voir la section précédente.
Que se passe-t-il si vous ne répondez pas
La Gateway retente jusqu'à cinq fois, avec un délai croissant :
Une application redémarrée ou en maintenance récupère donc ses messages à son retour, dans une fenêtre d'environ vingt minutes.
// Répondre vite, traiter après
TraiterMessage::dispatch($expediteur, $entree);
return response()->json(['status' => 'ok']);
// Le pipeline tourne dans la requête.
// Au-delà de 8 s, la Gateway abandonne
// et rejoue le message.
$reponse = $this->chatbot->traiter($entree);
$this->gateway->envoyerTexte($expediteur, $reponse);
return response()->json(['status' => 'ok']);
// Une clé par wamid, gardée 10 minutes.
// Cache::add est atomique : il échoue
// si la clé existe déjà.
if (! Cache::add('wa:' . $wamid, true, 600)) {
return response()->json(['status' => 'duplicate']);
}
Un cache en mémoire volatile ne déduplique rien : il disparaît à la fin de chaque requête, et chaque doublon passe. Utilisez un cache partagé entre les processus.
Statuts d'un message
| Statut | Signification |
|---|---|
| pending | Accepté par la Gateway, pas encore transmis |
| sent | Transmis à WhatsApp |
| delivered | Arrivé sur le téléphone |
| read | Lu par le destinataire |
| failed | Échec définitif, détail dans error |
Un statut ne régresse jamais. WhatsApp remonte parfois delivered après read : l'horodatage est enregistré, mais le statut affiché reste le plus avancé.
Erreurs
Toutes les erreurs partagent le même format : un champ error lisible par votre code, un champ message lisible par un humain.
Erreurs de la Gateway
| Code | error | Cause |
|---|---|---|
| 401 | unauthorized | En-têtes absents ou invalides |
| 403 | forbidden | Application désactivée |
| 422 | no_active_number | Aucun numéro rattaché |
| 422 | — | Validation, détail dans errors |
| 429 | — | Quota dépassé |
Erreurs WhatsApp, renvoyées en 502
| Code | Cause | Que faire |
|---|---|---|
| 131047 | Fenêtre de 24 h fermée | Passer par un template |
| 132001 | Modèle inconnu ou mauvaise langue | Vérifier le nom et le code |
| 131026 | Le destinataire n'a pas WhatsApp | Prévoir un canal de repli |
| 190 | Token expiré | Prévenir l'administrateur |
| 131005 | Accès refusé | Prévenir l'administrateur |
Une 502 ne vient ni de votre code ni de la Gateway : c'est WhatsApp qui refuse. Le message d'origine est transmis tel quel pour que vous puissiez agir.
Quand la Gateway ne répond pas
Traitez l'indisponibilité comme une panne temporaire, jamais comme une erreur fatale. Votre application doit se dégrader proprement — file d'attente, message de repli, nouvelle tentative plus tard.
{
"error": "no_active_number",
"message": "Aucun numéro WhatsApp actif n'est associé à cette application."
}
{
"message": "Le numéro doit être au format E.164, par exemple +221771234567.",
"errors": {
"to": ["Le numéro doit être au format E.164, par exemple +221771234567."]
}
}
{
"id": "e9c1fb4e-b3d3-4bec-848a-8d1069d71b97",
"status": "failed",
"to": "+221771234567",
"sent_at": null,
"error": {
"code": "131047",
"message": "Re-engagement message"
}
}
$resultat = $gateway->envoyerTexte($vers, $texte);
if (data_get($resultat, 'error.code') === '131047') {
// Hors fenêtre : basculer sur un template
return $gateway->envoyerTemplate(
$vers,
'relance_conversation'
);
}
Limites et référence
| Méthode | Endpoint | Authentification |
|---|---|---|
| GET | /api/v1/health | — |
| GET | /api/v1/me | requise |
| POST | /api/v1/messages/text | requise |
| POST | /api/v1/messages/template | requise |
| POST | /api/v1/messages/interactive | requise |
| GET | /api/v1/messages/{id} | requise |
| POST | /api/v1/otp/send | requise |
| POST | /api/v1/otp/verify | requise |
Plafonds
| Limite | Valeur |
|---|---|
| Requêtes | 60 par minute et par application |
| Texte | 4096 caractères |
| Corps interactif | 1024 caractères |
| Options | 10 maximum |
| Titre de bouton | 20 caractères |
| Titre de liste | 24 caractères |
| Description | 72 caractères |
| En-tête et pied | 60 caractères |
Les titres trop longs sont tronqués, pas rejetés. Écrivez court plutôt que de compter les caractères.