Oui : envoyez le template dès l'encaissement avec une idempotency_key, gardez l'id renvoyé, puis suivez sent, delivered, read ou failed par les événements message.status de votre webhook, avec GET /v1/messages/{id} pour rattraper.
Astuce
Oui : envoyez le template dès l'encaissement avec une idempotency_key, gardez l'id renvoyé, puis suivez sent, delivered, read ou failed par les événements message.status de votre webhook, avec GET /v1/messages/{id} pour rattraper.
Un client paie à la caisse ou en ligne. Dans la seconde qui suit, il doit recevoir un reçu sur WhatsApp. Et votre équipe veut voir dans le back-office, pour chaque paiement, si le reçu est parti, arrivé sur le téléphone, ou lu. Oui, les deux sont possibles. L'envoi renvoie tout de suite un identifiant, et les changements d'état arrivent ensuite sur votre webhook.
Paiement confirmé chez vous
│
├─► POST /v1/messages (template reçu, idempotency_key = id du paiement)
│ ← 202 id=9d3e… status=sent (Meta a accepté, ce n'est pas encore « arrivé »)
│ vous stockez : paiement 8842 ↔ id 9d3e…
│
▼ plus tard, sur votre webhook
message.status id=9d3e… status=delivered (arrivé sur le téléphone)
message.status id=9d3e… status=read (lu)
ou status=failed error="…" (non remis)
Appelez l'API depuis votre serveur, une fois le paiement confirmé chez vous. Mettez l'identifiant du paiement dans idempotency_key : si votre appel est rejoué, le client ne reçoit pas deux reçus (voir Ne jamais envoyer deux fois).
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",
"idempotency_key": "paiement-8842",
"template": {
"name": "recu_paiement",
"language": "fr",
"variables": ["Awa", "15 000 FCFA", "8842"]
}
}'
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",
"idempotency_key": "paiement-8842",
"template": {
"name": "recu_paiement",
"language": "fr",
"variables": ["Awa", "15 000 FCFA", "8842"]
}
}),
});
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",
"idempotency_key" => "paiement-8842",
"template" => [
"name" => "recu_paiement",
"language" => "fr",
"variables" => ["Awa", "15 000 FCFA", "8842"],
],
]),
]);
$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",
"idempotency_key": "paiement-8842",
"template": {
"name": "recu_paiement",
"language": "fr",
"variables": ["Awa", "15 000 FCFA", "8842"],
},
},
timeout=15,
)
print(res.status_code, res.json())
La réponse contient id (l'identifiant du message côté API) et wa_message_id. Gardez l'id à côté du paiement : c'est lui qui revient dans les statuts.
Hors des 24 h qui suivent un message du client, un reçu est un template approuvé par Meta. Si le client vient d'écrire, voir Répondre en texte libre. Le coût est détaillé dans Coût d'un message.
Chaque changement d'état d'un message que vous avez envoyé arrive en message.status :
{ "event": "message.status",
"data": { "id": "9d3e…", "wa_message_id": "wamid.HBg…", "status": "delivered",
"recipient_masked": "2376****0001", "error": null },
"timestamp": "2026-10-30T09:14:07+00:00" }
| status | Sens |
|---|---|
| sent | accepté par WhatsApp |
| delivered | arrivé sur le téléphone du client |
| read | lu par le client |
| failed | non remis ; error contient la raison, sinon error est null |
Le gestionnaire met à jour la ligne du paiement sans jamais reculer :
Node.js
// Inside your webhook handler (after the signature check): never go backwards.
const RANK = { sent: 1, delivered: 2, read: 3, failed: 4 };
async function onStatus(data) { // data = message.status payload
const row = await db.paymentByMessageId(data.id); // payment 8842 ↔ id 9d3e…
if (!row) return; // not a message of this app
if ((RANK[data.status] || 0) <= (RANK[row.receipt_status] || 0)) return; // already as far or further
await db.updatePayment(row.id, {
receipt_status: data.status,
receipt_error: data.status === "failed" ? data.error : null,
receipt_updated_at: new Date(),
});
}
PHP
<?php
// Inside your webhook handler (after the signature check): never go backwards.
const RANK = ["sent" => 1, "delivered" => 2, "read" => 3, "failed" => 4];
function on_status(array $data): void { // $data = message.status payload
$row = db_payment_by_message_id($data["id"]); // payment 8842 ↔ id 9d3e…
if (!$row) return; // not a message of this app
if ((RANK[$data["status"]] ?? 0) <= (RANK[$row["receipt_status"]] ?? 0)) return; // already as far or further
db_update_payment($row["id"], [
"receipt_status" => $data["status"],
"receipt_error" => $data["status"] === "failed" ? $data["error"] : null,
"receipt_updated_at" => date("c"),
]);
}
Python
# Inside your webhook handler (after the signature check): never go backwards.
from datetime import datetime, timezone
RANK = {"sent": 1, "delivered": 2, "read": 3, "failed": 4}
def on_status(data): # data = message.status payload
row = db.payment_by_message_id(data["id"]) # payment 8842 ↔ id 9d3e…
if not row:
return # not a message of this app
if RANK.get(data["status"], 0) <= RANK.get(row["receipt_status"], 0):
return # already as far or further
db.update_payment(row["id"], {
"receipt_status": data["status"],
"receipt_error": data["error"] if data["status"] == "failed" else None,
"receipt_updated_at": datetime.now(timezone.utc),
})
curl
# Catch-up: read a message's current status (the id you stored at send time).
curl "https://wa-api.geskap.com/v1/messages/9d3e0000-0000-0000-0000-000000000000" \
-H "Authorization: Bearer $GESKAP_API_KEY"
Le serveur applique la même règle : un delivered qui arriverait après un read ne le fait pas redescendre, et failed l'emporte toujours. Un même statut n'est pas poussé deux fois pour un message.
Votre serveur était arrêté ? Il n'y a qu'une tentative d'envoi du webhook, sans relance. Relisez l'état d'un message avec :
curl
curl -X GET "https://wa-api.geskap.com/v1/messages/9d3e0000-0000-0000-0000-000000000000" \
-H "Authorization: Bearer $GESKAP_API_KEY"
Node.js
const res = await fetch("https://wa-api.geskap.com/v1/messages/9d3e0000-0000-0000-0000-000000000000", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.GESKAP_API_KEY}`,
},
});
console.log(res.status, await res.json());
PHP
$ch = curl_init("https://wa-api.geskap.com/v1/messages/9d3e0000-0000-0000-0000-000000000000");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("GESKAP_API_KEY"),
],
]);
$res = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $res;
Python
import os, requests
res = requests.request(
"GET",
"https://wa-api.geskap.com/v1/messages/9d3e0000-0000-0000-0000-000000000000",
headers={"Authorization": f"Bearer {os.environ['GESKAP_API_KEY']}"},
timeout=15,
)
print(res.status_code, res.json())
La réponse donne le status courant. Elle ne donne pas la raison d'un échec : celle-ci est dans l'événement message.status (error) et dans GET /v1/logs?category=sends.
Attention
Piège : status: "sent" dans la réponse d'envoi veut dire « accepté », pas « reçu par le client ». Ne marquez jamais un reçu comme remis avant d'avoir vu delivered.
Attention
Piège : les message.status ne sont pas tracés dans GET /v1/logs?category=webhook : seuls message.inbound et message.echo le sont. Si un statut manque, comparez avec GET /v1/messages/{id}.
Attention
Piège : n'attendez pas read pour une action importante. Il n'est pas garanti : traitez delivered comme le signal fiable, et read comme un bonus.
Attention
Piège : un statut failed reçu après l'envoi n'est pas remboursé automatiquement par le code actuel (seul le refus immédiat de Meta, en 502, l'est). Voir Coût d'un message.
Pour vérifier l'appel entrant, voir Vérifier la signature d'un webhook. Pour un autre cas de notification avec suivi, voir Notification d'arrivée sur place.
Preuve — vérifié dans le code de l'API : public_api.py, meta_webhooks.py, api_usage.py, api_schemas.py, dev_api_docs.py, api_logs.py.