Webhooks

Recevez un appel HTTPS signé dès qu'un paiement passe, échoue ou est remboursé, ou qu'un dossier est entièrement payé. Configuration, vérification de la signature, catalogue des événements et nouvelles tentatives.

Vérifié sur le code de l'API le 13 octobre 2026

Le principe

Quand il se passe quelque chose sur un paiement, Ezus Pay envoie un POST avec un corps JSON à chacun de vos endpoints abonnés à cet événement. Chaque requête est signée avec un secret que seuls vous et Ezus Pay connaissez.

Les webhooks partent une fois le paiement enregistré. Un endpoint lent ou en panne ne retarde jamais un paiement, et un envoi échoué est retenté pendant environ huit heures.

Ajouter un endpoint

Dans l'app, un administrateur ouvre Paramètres → Intégrations → Webhooks et clique sur Ajouter un webhook.

  1. Saisissez l'URL. Elle doit utiliser https:// sur le port standard (443), sans identifiant ni mot de passe, et pointer vers une adresse publique. localhost et les réseaux privés sont refusés.
  2. Choisissez les événements. Vous pouvez ajouter jusqu'à 5 endpoints par agence, chacun avec ses propres événements.
  3. Copiez le secret de signature. Il commence par ezpw_whk_ et ne s'affiche qu'une seule fois.
  4. Cliquez sur Envoyer un test. Ezus Pay envoie un événement ping et affiche la réponse de votre serveur.

Chaque endpoint a sa propre page, avec le journal des envois des 30 derniers jours. Pour chaque envoi, vous voyez le contenu envoyé, la réponse de votre serveur et les tentatives. Vous pouvez renvoyer un envoi, ou renvoyer en un clic tous les échecs des 7 derniers jours.

Changer le secret prend effet immédiatement, sans période de recouvrement. Pour le changer sans perdre d'événements, faites accepter à votre serveur deux secrets pendant quelque temps : le nouveau et le précédent.

Suspension automatique. Un endpoint qui échoue depuis 72 heures sans un seul succès est suspendu, et chaque administrateur reçoit un email. Les événements survenus pendant la suspension sont conservés comme échoués. Depuis la page de l'endpoint, Réactiver et renvoyer les événements manqués rattrape le retard en un clic. Les administrateurs reçoivent aussi un email, au plus une fois par jour, quand un événement a épuisé toutes ses tentatives.

La requête

HTTP
POST /webhooks/ezus-pay HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: EzusPay-Webhooks/1.0
Ezuspay-Signature: t=1760350000,v1=5f2b0c6d...e91a
Ezuspay-Event-Id: 6f1c0b3a-7f2e-4a1d-9c55-4b2f1e0a8d33
Ezuspay-Event-Type: payment.succeeded
En-têteContenu
Ezuspay-Signaturet= l'heure Unix en secondes, puis v1= la signature HMAC-SHA256 en hexadécimal minuscule
Ezuspay-Event-IdL'identifiant de l'événement. Même valeur que id dans le corps, stable d'une tentative à l'autre
Ezuspay-Event-TypeLe type d'événement, pour router sans lire le corps

Tous les événements partagent la même enveloppe :

JSON
{
  "id": "6f1c0b3a-7f2e-4a1d-9c55-4b2f1e0a8d33",
  "type": "payment.succeeded",
  "created": 1760350000123,
  "api_version": "2026-05-01",
  "agency_id": "3f9a1c2e-1a4b-4c8d-9e10-77b2c5d4e6f1",
  "data": { "object": { } }
}
  • created est en millisecondes. Le t de la signature est en secondes.
  • api_version ne change pas quand des champs sont ajoutés. Acceptez les champs que vous ne connaissez pas.
  • Votre serveur a 8 secondes pour répondre. Les redirections ne sont pas suivies.

Vérifier la signature

La signature porte sur l'horodatage et sur le corps brut, octet par octet :

Text
signed_payload = t + "." + raw_body
v1             = hex(HMAC_SHA256(secret, signed_payload))

Utilisez le secret complet comme clé, préfixe ezpw_whk_ compris. Ensuite :

  1. Lisez le corps brut avant tout parsing JSON. Parser puis réencoder change les octets.
  2. Comparez les signatures en temps constant.
  3. Refusez un t à plus de 5 minutes de votre horloge. Chaque nouvelle tentative est signée avec un t récent.
  4. Ignorez un id déjà traité.
  5. Répondez 2xx rapidement, puis faites le travail en arrière-plan.

Node.js

JavaScript
import crypto from "node:crypto";
import express from "express";

const app = express();
// Secret actuel et précédent : un changement de secret ne fait rien perdre.
const SECRETS = [process.env.EZUSPAY_WEBHOOK_SECRET, process.env.EZUSPAY_WEBHOOK_SECRET_PREVIOUS].filter(Boolean);

function verify(rawBody, header) {
  const parts = {};
  for (const item of (header ?? "").split(",")) {
    const i = item.indexOf("=");
    if (i > 0) parts[item.slice(0, i)] = item.slice(i + 1);
  }
  const { t, v1 } = parts;
  if (!t || !/^\d+$/.test(t) || !v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false;

  const signed = Buffer.concat([Buffer.from(`${t}.`), rawBody]);
  const received = Buffer.from(v1);
  return SECRETS.some((secret) => {
    const expected = Buffer.from(crypto.createHmac("sha256", secret).update(signed).digest("hex"));
    return expected.length === received.length && crypto.timingSafeEqual(expected, received);
  });
}

// express.raw, et non express.json, sur cette route.
app.post("/webhooks/ezus-pay", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.get("Ezuspay-Signature"))) return res.status(400).send("invalid signature");
  const event = JSON.parse(req.body.toString("utf8"));
  if (event.type !== "ping") {
    // Enregistrez event.id avec une contrainte d'unicité, puis mettez le traitement en file.
  }
  res.sendStatus(200);
});

Python

Python
import hashlib, hmac, json, os, time
from flask import Flask, request

SECRETS = [s for s in (os.environ.get("EZUSPAY_WEBHOOK_SECRET"),
                       os.environ.get("EZUSPAY_WEBHOOK_SECRET_PREVIOUS")) if s]

def verify(raw_body: bytes, header: str | None) -> bool:
    parts = {}
    for item in (header or "").split(","):
        key, sep, value = item.partition("=")
        if sep:
            parts[key] = value
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not v1 or abs(int(time.time()) - int(t)) > 300:
        return False
    signed = t.encode() + b"." + raw_body
    return any(
        hmac.compare_digest(hmac.new(s.encode(), signed, hashlib.sha256).hexdigest(), v1)
        for s in SECRETS
    )

app = Flask(__name__)

@app.post("/webhooks/ezus-pay")
def ezus_pay_webhook():
    raw = request.get_data()  # octets bruts, avant request.json
    if not verify(raw, request.headers.get("Ezuspay-Signature")):
        return "invalid signature", 400
    event = json.loads(raw)
    # Ignorez event["id"] s'il est déjà connu, puis mettez le traitement en file.
    return "", 200

PHP

PHP
<?php
$raw    = file_get_contents('php://input');
$header = $_SERVER['HTTP_EZUSPAY_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $item) {
    $kv = explode('=', $item, 2);
    if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; }
}
$t  = $parts['t']  ?? '';
$v1 = $parts['v1'] ?? '';

if (!ctype_digit($t) || $v1 === '' || abs(time() - (int) $t) > 300) {
    http_response_code(400); exit('bad signature');
}

$secrets = array_filter([getenv('EZUSPAY_WEBHOOK_SECRET'), getenv('EZUSPAY_WEBHOOK_SECRET_PREVIOUS')]);
$valid = false;
foreach ($secrets as $secret) {
    if (hash_equals(hash_hmac('sha256', $t . '.' . $raw, $secret), $v1)) { $valid = true; break; }
}
if (!$valid) { http_response_code(400); exit('invalid signature'); }

$event = json_decode($raw, true);
// Ignorez $event['id'] s'il est déjà connu, puis mettez le traitement en file.
http_response_code(200);

Événements

TypeEnvoyé quand
payment.succeededUn paiement par lien est confirmé, y compris chaque échéance d'un plan de paiement
payment.failedUne tentative de paiement est refusée, y compris un débit automatique échoué
payment.refundedUn remboursement est effectivement passé chez Stripe
booking.fully_paidUn paiement porte le total encaissé d'une alternative à son montant complet
pingUniquement depuis le bouton Envoyer un test. Répondez 200 et ignorez-le

Tous les montants sont des entiers en centimes, et currency est un code ISO 4217 en majuscules. Ces objets reviennent dans plusieurs événements :

ObjetChamps
bookingid, ezus_project_id (ou null), reference (ou null)
alternativeid, ezus_alternative_id (ou null)
payment_linkid, label, kind (SINGLE ou PLAN)
installmentnull pour un lien simple. Pour un paiement de plan : plan_id, sequence (1 est l'acompte), count (nombre total d'échéances), due_date (YYYY-MM-DD)

payment.succeeded

Un événement par paiement. Pour un prélèvement SEPA, il part quand Stripe confirme le prélèvement, quelques jours après le paiement.

JSON
{
  "id": "6f1c0b3a-7f2e-4a1d-9c55-4b2f1e0a8d33",
  "type": "payment.succeeded",
  "created": 1760350000123,
  "api_version": "2026-05-01",
  "agency_id": "3f9a1c2e-1a4b-4c8d-9e10-77b2c5d4e6f1",
  "data": {
    "object": {
      "transaction_id": "d41c9a70-5b6e-4f2a-8c31-9a7e5b2d1f04",
      "booking": { "id": "b1e2d3c4-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "ezus_project_id": "PRJ-10482", "reference": "EZ-2026-0142" },
      "alternative": { "id": "a9f8e7d6-4c3b-4a2b-9d1e-0f9e8d7c6b5a", "ezus_alternative_id": "ALT-3391" },
      "payment_link": { "id": "7c6b5a4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d", "label": "Kenya 2027", "kind": "PLAN" },
      "installment": { "plan_id": "e5d4c3b2-1a09-4f8e-9d7c-6b5a4f3e2d1c", "sequence": 2, "count": 3, "due_date": "2026-11-19" },
      "amount_cents": 145000,
      "currency": "EUR",
      "payment_method": "card",
      "paid_at": "2026-10-13T09:20:00.123Z",
      "custom_fields": [
        { "key": "numero_passeport", "label": "Numéro de passeport", "type": "TEXT", "value": "20AB12345" },
        { "key": "assurance", "label": "Assurance annulation", "type": "CHECKBOX", "value": "true" }
      ]
    }
  }
}
  • amount_cents est le montant de ce paiement. Pour un plan, c'est l'échéance.
  • payment_method vaut card, sepa_debit ou null.
  • custom_fields contient les réponses du voyageur aux champs du checkout, ou null. Chaque value est une chaîne.
  • paid_at est le moment où Ezus Pay a enregistré le paiement.

payment.failed

Envoyé pour chaque tentative refusée. Un voyageur qui essaie deux cartes produit deux événements, qui peuvent partager le même transaction_id. Un payment.succeeded ultérieur pour cette transaction les rend caducs. Aucun argent n'a bougé : traitez-le comme un signal de relance.

JSON
{
  "transaction_id": "3b9d2c1a-0f9e-4d8c-b7a6-5e4d3c2b1a09",
  "payment_link": { "id": "7c6b5a4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d", "label": "Acompte 30 %", "kind": "SINGLE" },
  "installment": null,
  "booking": { "id": "b1e2d3c4-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "ezus_project_id": null, "reference": "EZ-2026-0142" },
  "alternative": { "id": "a9f8e7d6-4c3b-4a2b-9d1e-0f9e8d7c6b5a", "ezus_alternative_id": null },
  "amount_cents": 50000,
  "currency": "EUR",
  "failure_code": "insufficient_funds",
  "failure_message": "Your card has insufficient funds.",
  "failed_at": "2026-10-13T09:19:40.000Z"
}

Ceci est le data.object, l'enveloppe reste la même. failure_code est le code de refus de Stripe quand il existe, et failure_message le message de Stripe, en anglais.

payment.refunded

Envoyé quand un remboursement est effectivement passé, pas quand il est demandé. Il couvre les remboursements faits depuis l'app, depuis l'API et depuis Stripe. Un remboursement qui échoue n'envoie rien.

JSON
{
  "transaction_id": "d41c9a70-5b6e-4f2a-8c31-9a7e5b2d1f04",
  "refund_id": "c1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6",
  "booking": { "id": "b1e2d3c4-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "ezus_project_id": "PRJ-10482", "reference": "EZ-2026-0142" },
  "alternative": { "id": "a9f8e7d6-4c3b-4a2b-9d1e-0f9e8d7c6b5a", "ezus_alternative_id": "ALT-3391" },
  "payment_link": { "id": "7c6b5a4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d", "label": "Acompte 30 %", "kind": "SINGLE" },
  "installment": null,
  "amount_refunded_cents": 50000,
  "total_refunded_cents": 50000,
  "currency": "EUR",
  "reason": "requested_by_customer",
  "fully_refunded": false,
  "refunded_at": "2026-10-14T08:00:00.789Z"
}
  • amount_refunded_cents est ce remboursement. total_refunded_cents est le cumul pour ce paiement, ce qui permet de se recaler après un événement manqué.
  • reason est le motif Stripe (duplicate, fraudulent, requested_by_customer) ou null.
  • Traitez refund_id comme une chaîne opaque. C'est la clé pour dédoublonner les remboursements.

booking.fully_paid

Envoyé juste après le payment.succeeded qui solde une alternative. Un dernier paiement produit donc deux événements, chacun avec son propre id.

JSON
{
  "booking": { "id": "b1e2d3c4-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "ezus_project_id": "PRJ-10482", "reference": "EZ-2026-0142" },
  "alternative": { "id": "a9f8e7d6-4c3b-4a2b-9d1e-0f9e8d7c6b5a", "ezus_alternative_id": "ALT-3391" },
  "total_amount_cents": 485000,
  "currency": "EUR"
}

total_amount_cents est le total de l'alternative, pas le dernier paiement.

Nouvelles tentatives et garanties

Un succès est toute réponse 2xx. Tout le reste est retenté : tous les 4xx (y compris 401, 404 et 429), les 5xx, les redirections, les délais dépassés et les erreurs réseau. Pour refuser définitivement un événement, répondez 200 et ignorez-le.

Tentative échouéeTentative suivante après
130 secondes
22 minutes
310 minutes
41 heure
56 heures
6Plus de tentative. L'envoi est marqué en échec

Les six tentatives couvrent environ 7 h 45. Ensuite, renvoyez depuis la page de l'endpoint. Pour une panne plus longue, rattrapez avec GET /v1/transactions?dateFrom=….

  • Au moins une fois. Un même événement peut arriver deux fois, par exemple après un délai dépassé ou un renvoi manuel. Enregistrez id avec une contrainte d'unicité.
  • Aucun ordre garanti. payment.succeeded et booking.fully_paid partent au même moment, et les nouvelles tentatives arrivent en retard. Appuyez-vous sur paid_at, created et total_refunded_cents, pas sur l'ordre d'arrivée.
  • Un id par endpoint. Le même événement envoyé à deux de vos endpoints porte deux id différents.
  • Signé au moment de l'envoi, avec le secret actuel de l'endpoint.
  • Acceptez sans erreur les nouveaux types d'événements et les nouveaux champs. Le catalogue va s'enrichir.

Limites

LimiteValeur
Endpoints par agence5 (le mode test a les siens)
URLhttps, port 443, adresse publique, jusqu'à 2 000 caractères
Temps de réponse8 secondes
Corps de réponse conservé dans le journal2 048 caractères
Journal des envois30 jours
Renvoyer tous les échecs7 derniers jours, jusqu'à 500 envois
Suspension automatique72 heures sans envoi réussi
Gestion des endpoints (création, modification, test, renvoi)60 actions par heure et par utilisateur