Envoyer un reçu instantané après encaissement et suivre envoyé, délivré, lu dans mon back-office

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.

Envoyer un reçu instantané après encaissement et suivre envoyé, délivré, lu dans mon back-office

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.

Scénario

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.

Le parcours

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)

1. Envoyer au moment de l'encaissement

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.

2. Suivre les statuts sur le webhook

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" }
statusSens
sentaccepté par WhatsApp
deliveredarrivé sur le téléphone du client
readlu par le client
failednon 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.

3. Rattraper ce qu'on a manqué

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.