Documentation du pont WhatsApp

Un appel HTTP, et le message part de votre propre numéro WhatsApp. Aucune bibliothèque à installer.

Démarrer

Créez un compte sur l'espace développeurs, acceptez les conditions, connectez un numéro dédié aux notifications, puis générez une clé. Votre compte doit ensuite être validé par nous avant d'émettre — vous pouvez écrire toute votre intégration entre-temps, les erreurs vous diront exactement où vous en êtes.

Adresse de base : https://wapy.pro. Toutes les requêtes sont en application/json.

Mettez votre clé dans une variable d'environnement, jamais dans la commande

Les exemples de cette page lisent WAPY_PONT_CLE. Une clé écrite en clair dans une commande survit dans l'historique du terminal, dans les journaux du serveur et dans le premier message de support où on la recopie.

bash

export WAPY_PONT_CLE="wapy_pont_…"

PowerShell

$env:WAPY_PONT_CLE = "wapy_pont_…"

Sous Windows PowerShell, les exemples curl ne fonctionnent pas : curl y est un alias d'Invoke-WebRequest, qui ignore -X, -H et -d. Chaque section donne l'équivalent PowerShell juste en dessous.

Authentification

La clé se transmet dans l'en-tête Authorization, jamais dans l'URL : une clé en chaîne de requête finit dans les journaux du serveur, dans l'historique du navigateur et dans les référents sortants.

Authorization: Bearer $WAPY_PONT_CLE

Gardez-la côté serveur. Elle ne doit jamais atteindre un navigateur ni une application mobile : quiconque la détient peut écrire depuis votre numéro. Nous n'en conservons que l'empreinte — perdue, elle se remplace, elle ne se retrouve pas. Générer une nouvelle clé coupe immédiatement la précédente.

Idempotence : l'en-tête à ne pas oublier

Chaque envoi exige un en-tête Idempotency-Key unique. Rejouer une requête portant la même clé ne produit jamais un second message : la réponse initiale est renvoyée, avec rejeu: true.

Ce n'est pas une formalité. Sans elle, un délai réseau suivi d'une reprise annonce deux fois le même paiement au même parent. Utilisez un identifiant qui décrit l'évènement, pas l'instant : l'identifiant de la transaction, pas un horodatage.

Envoyer un message

POST /pont/v1/messages — pour un reçu, une confirmation, un rappel. Le champ consentement est obligatoire : une référence attestant que le destinataire a accepté d'être contacté. Nous ne pouvons pas la vérifier ; elle engage votre responsabilité.

curl

curl -X POST https://wapy.pro/pont/v1/messages \
  -H "Authorization: Bearer $WAPY_PONT_CLE" \
  -H "Idempotency-Key: paiement-2026-114" \
  -H "Content-Type: application/json" \
  -d '{
    "destinataire": "+22990000001",
    "texte": "Paiement reçu : 50 000 FCFA pour le 2e trimestre. Merci.",
    "consentement": "inscription-2026-114"
  }'

Réponse

{
  "id": 1042,
  "message_id": "3EB0C8F2A1D4E5B6",
  "statut": "envoye",
  "remise": "acceptee",
  "rejeu": false
}

Numéro du destinataire. Avant tout envoi, nous demandons à WhatsApp si le numéro a un compte, et sous quelle forme. Un numéro sans compte est refusé (404 destinataire-inexistant), sans rien consommer. Les écritures d'un même numéro mènent au même compte et comptent pour un seul destinataire : au Bénin, +2290197101385 (10 chiffres) et +22997101385 (8 chiffres) sont la même personne. Envoyez une seule forme, celle que vous avez.

Sous Windows PowerShell, la commande ci-dessus ne fonctionne pas : curl y est un alias d'Invoke-WebRequest, qui ignore -X, -H et -d, et les \ de fin de ligne n'y sont pas des continuations. Utilisez plutôt :

PowerShell

$corps = @{
  destinataire = "+22990000001"
  texte        = "Paiement reçu : 50 000 FCFA. Merci."
  consentement = "inscription-2026-114"
} | ConvertTo-Json

Invoke-RestMethod -Method Post -Uri "https://wapy.pro/pont/v1/messages" `
  -Headers @{
    "Authorization"   = "Bearer $env:WAPY_PONT_CLE"
    "Idempotency-Key" = "paiement-2026-114"
  } `
  -ContentType "application/json; charset=utf-8" `
  -Body ([System.Text.Encoding]::UTF8.GetBytes($corps))

Les deux dernières lignes ne sont pas décoratives : sans elles, Windows PowerShell 5.1 encode le corps dans la page de codes du système et « Paiement reçu » arrive mutilé chez votre client. Le binaire curl.exe reste disponible si vous préférez la syntaxe Unix — mais appelez-le par son nom complet, sinon c'est l'alias qui répond.

Envoyer un code de vérification

POST /pont/v1/otp — le corps du message est composé par nous à partir d'un gabarit fixe : vous fournissez le code, pas le texte. Cela garantit qu'un point d'entrée conçu pour l'authentification ne serve pas à autre chose.

Le code n'est ni journalisé, ni stocké, ni renvoyé dans la réponse. Limite : deux codes par heure et par destinataire.

curl

curl -X POST https://wapy.pro/pont/v1/otp \
  -H "Authorization: Bearer $WAPY_PONT_CLE" \
  -H "Idempotency-Key: otp-9f3c1a" \
  -H "Content-Type: application/json" \
  -d '{
    "destinataire": "+22990000001",
    "code": "482913",
    "service": "Mon École",
    "minutes": 10
  }'

PowerShell

$corps = @{
  destinataire = "+22990000001"
  code         = "482913"
  service      = "Mon École"
  minutes      = 10
} | ConvertTo-Json

Invoke-RestMethod -Method Post -Uri "https://wapy.pro/pont/v1/otp" `
  -Headers @{
    "Authorization"   = "Bearer $env:WAPY_PONT_CLE"
    "Idempotency-Key" = "otp-9f3c1a"
  } `
  -ContentType "application/json; charset=utf-8" `
  -Body ([System.Text.Encoding]::UTF8.GetBytes($corps))

Le destinataire reçoit :

Votre code de vérification Mon École est 482913.
Il expire dans 10 minutes. Ne le communiquez à personne.

Prévoyez toujours un second canal. Si votre utilisateur n'a pas WhatsApp sur ce téléphone, un code envoyé ici est une porte fermée : gardez le SMS ou le courriel en repli.

Consulter l'état du compte

GET /pont/v1/statut — reste consultable même quand l'émission est bloquée : c'est là que vous lisez pourquoi.

Réponse

{
  "statut": "actif",
  "peut_emettre": true,
  "blocage": "",
  "conditions_acceptees": true,
  "whatsapp": "WORKING",
  "numero_connecte": true,
  "webhook": { "url": "https://monapp.bj/wapy" },
  "limites": {
    "intervalle_min_s": 3,
    "envois_par_heure": 60,
    "envois_par_jour": 500,
    "otp_par_heure_et_destinataire": 2,
    "taille_max_message": 1000
  },
  "consommation": { "derniere_heure": 12, "dernier_jour": 87 }
}

numero_connecte reflète l'état réel de la session WhatsApp (whatsapp : WORKING, SCAN_QR_CODE, STOPPED, FAILED…). Tant qu'il est faux, tout envoi répond 502.

Suivre la remise d'un message

Un 200 signifie que WhatsApp a accepté le message, pas qu'il est arrivé. Le champ remise progresse ensuite avec les accusés de WhatsApp : acceptee → serveur → appareil → lu, ou echec. Deux façons de le lire.

Interroger. GET /pont/v1/messages/{id} avec l'id renvoyé à l'envoi.

Réponse

{
  "id": 1042,
  "message_id": "3EB0C8F2A1D4E5B6",
  "genre": "texte",
  "statut": "envoye",
  "motif": "",
  "remise": "appareil",
  "remise_le": "2026-09-14T10:32:07",
  "cree_le": "2026-09-14T10:31:58"
}

Être prévenu. POST /pont/v1/webhook avec { "url": "https://…" } (HTTPS obligatoire). La réponse contient un secret, montré une seule fois. À chaque changement d'état nous appelons votre URL en POST avec le même corps que ci-dessus plus "evenement": "remise", et l'en-tête X-Wapy-Signature: sha256=… : le HMAC-SHA256 du corps brut avec votre secret. Vérifiez-le avant de croire l'appel. DELETE /pont/v1/webhook retire l'adresse.

Vérifier la signature (Node)

import crypto from "node:crypto";

function signatureValide(corpsBrut, entete, secret) {
  const attendu = "sha256=" + crypto.createHmac("sha256", secret).update(corpsBrut).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(attendu), Buffer.from(entete || ""));
}

Aucun appel ne porte de destinataire ni de contenu : uniquement nos identifiants, l'état et l'heure. Un webhook qui ne répond pas n'est pas réessayé ; l'interrogation reste possible à tout moment.

Codes d'erreur

Le champ detail nomme la cause exacte. Un envoi refusé est tracé : rejouer la même clé d'idempotence après un refus renverra ce refus, pas un nouvel essai — changez de clé pour réessayer.

CodeSensCas
400Requête invalideChamp manquant, numéro illisible, message vide ou trop long, en-tête Idempotency-Key absent.
401Clé refuséeClé absente, inconnue, ou remplacée par une plus récente.
403Envoi interditconditions-non-acceptees, compte-non-valide, compte-suspendu, aucune-cle, destinataire-desabonne.
404Destinataire inexistantdestinataire-inexistant : ce numéro n'a pas de compte WhatsApp. Rien n'est envoyé ni compté.
429Limite atteintetrop-rapide, plafond-horaire, plafond-journalier, plafond-otp-destinataire. L'en-tête Retry-After indique le délai.
502WhatsApp injoignableVotre numéro n'a pas pu émettre alors qu'il semblait connecté. Réessayez avec la même clé d'idempotence.
503Numéro déconnecténumero-deconnecte : la session WhatsApp de votre compte est tombée ou attend un scan de QR. Rien n'est envoyé ni compté ; reconnectez le numéro depuis la console. Vous êtes aussi prévenu par e-mail.

Limites et bon usage

Un intervalle minimal sépare deux envois. Cinquante paiements encaissés à midi ne doivent pas produire cinquante messages en dix secondes : mettez vos envois en file d'attente et respectez le Retry-After renvoyé sur un 429. C'est ce qui protège votre numéro, pas seulement notre service.

Un destinataire qui répond STOP cesse définitivement d'être joignable depuis votre compte : les envois suivants renvoient 403 destinataire-desabonne. Traitez ce code comme une désinscription dans votre base, pas comme une erreur passagère.

Exemples complets

Node.js

async function envoyerRecu(numero, montant, reference) {
  const r = await fetch("https://wapy.pro/pont/v1/messages", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.WAPY_PONT_CLE}`,
      "Idempotency-Key": `paiement-${reference}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      destinataire: numero,
      texte: `Paiement reçu : ${montant} FCFA. Merci.`,
      consentement: reference,
    }),
  });
  if (r.status === 429) {
    const attente = Number(r.headers.get("Retry-After") || 5);
    throw new Error(`Limite atteinte, réessayer dans ${attente}s`);
  }
  if (!r.ok) throw new Error((await r.json()).detail);
  return r.json();
}

PHP

function envoyer_recu(string $numero, int $montant, string $reference): array {
    $ch = curl_init("https://wapy.pro/pont/v1/messages");
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer " . getenv("WAPY_PONT_CLE"),
            "Idempotency-Key: paiement-" . $reference,
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS => json_encode([
            "destinataire" => $numero,
            "texte" => "Paiement reçu : {$montant} FCFA. Merci.",
            "consentement" => $reference,
        ], JSON_UNESCAPED_UNICODE),
    ]);
    $corps = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($code !== 200) {
        throw new RuntimeException("Envoi refusé : " . $corps);
    }
    return json_decode($corps, true);
}

Python

import os, requests

def envoyer_recu(numero: str, montant: int, reference: str) -> dict:
    r = requests.post(
        "https://wapy.pro/pont/v1/messages",
        headers={
            "Authorization": f"Bearer {os.environ['WAPY_PONT_CLE']}",
            "Idempotency-Key": f"paiement-{reference}",
        },
        json={
            "destinataire": numero,
            "texte": f"Paiement reçu : {montant} FCFA. Merci.",
            "consentement": reference,
        },
        timeout=20,
    )
    if r.status_code == 429:
        raise RuntimeError(f"Limite atteinte, réessayer dans {r.headers.get('Retry-After', '5')}s")
    r.raise_for_status()
    return r.json()

Ce que nous conservons

Aucun contenu de message, aucun code de vérification, aucun numéro en clair. Nous gardons des métadonnées d'envoi — date, genre, taille, issue — pendant quatre-vingt-dix jours, puis elles sont effacées. Les destinataires n'apparaissent que sous forme d'empreinte irréversible, servant uniquement à appliquer les limites.

Le détail des engagements est dans les conditions d'utilisation.