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.
- Saisissez l'URL. Elle doit utiliser
https://sur le port standard (443), sans identifiant ni mot de passe, et pointer vers une adresse publique.localhostet les réseaux privés sont refusés. - Choisissez les événements. Vous pouvez ajouter jusqu'à 5 endpoints par agence, chacun avec ses propres événements.
- Copiez le secret de signature. Il commence par
ezpw_whk_et ne s'affiche qu'une seule fois. - Cliquez sur Envoyer un test. Ezus Pay envoie un événement
pinget 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
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ête | Contenu |
|---|---|
Ezuspay-Signature | t= l'heure Unix en secondes, puis v1= la signature HMAC-SHA256 en hexadécimal minuscule |
Ezuspay-Event-Id | L'identifiant de l'événement. Même valeur que id dans le corps, stable d'une tentative à l'autre |
Ezuspay-Event-Type | Le type d'événement, pour router sans lire le corps |
Tous les événements partagent la même enveloppe :
{
"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": { } }
}
createdest en millisecondes. Letde la signature est en secondes.api_versionne 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 :
signed_payload = t + "." + raw_body
v1 = hex(HMAC_SHA256(secret, signed_payload))
Utilisez le secret complet comme clé, préfixe ezpw_whk_ compris. Ensuite :
- Lisez le corps brut avant tout parsing JSON. Parser puis réencoder change les octets.
- Comparez les signatures en temps constant.
- Refusez un
tà plus de 5 minutes de votre horloge. Chaque nouvelle tentative est signée avec untrécent. - Ignorez un
iddéjà traité. - Répondez
2xxrapidement, puis faites le travail en arrière-plan.
Node.js
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
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
$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
| Type | Envoyé quand |
|---|---|
payment.succeeded | Un paiement par lien est confirmé, y compris chaque échéance d'un plan de paiement |
payment.failed | Une tentative de paiement est refusée, y compris un débit automatique échoué |
payment.refunded | Un remboursement est effectivement passé chez Stripe |
booking.fully_paid | Un paiement porte le total encaissé d'une alternative à son montant complet |
ping | Uniquement 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 :
| Objet | Champs |
|---|---|
booking | id, ezus_project_id (ou null), reference (ou null) |
alternative | id, ezus_alternative_id (ou null) |
payment_link | id, label, kind (SINGLE ou PLAN) |
installment | null 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.
{
"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_centsest le montant de ce paiement. Pour un plan, c'est l'échéance.payment_methodvautcard,sepa_debitounull.custom_fieldscontient les réponses du voyageur aux champs du checkout, ounull. Chaquevalueest une chaîne.paid_atest 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.
{
"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.
{
"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_centsest ce remboursement.total_refunded_centsest le cumul pour ce paiement, ce qui permet de se recaler après un événement manqué.reasonest le motif Stripe (duplicate,fraudulent,requested_by_customer) ounull.- Traitez
refund_idcomme 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.
{
"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ée | Tentative suivante après |
|---|---|
| 1 | 30 secondes |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 1 heure |
| 5 | 6 heures |
| 6 | Plus 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
idavec une contrainte d'unicité. - Aucun ordre garanti.
payment.succeededetbooking.fully_paidpartent au même moment, et les nouvelles tentatives arrivent en retard. Appuyez-vous surpaid_at,createdettotal_refunded_cents, pas sur l'ordre d'arrivée. - Un
idpar endpoint. Le même événement envoyé à deux de vos endpoints porte deuxiddiffé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
| Limite | Valeur |
|---|---|
| Endpoints par agence | 5 (le mode test a les siens) |
| URL | https, port 443, adresse publique, jusqu'à 2 000 caractères |
| Temps de réponse | 8 secondes |
| Corps de réponse conservé dans le journal | 2 048 caractères |
| Journal des envois | 30 jours |
| Renvoyer tous les échecs | 7 derniers jours, jusqu'à 500 envois |
| Suspension automatique | 72 heures sans envoi réussi |
| Gestion des endpoints (création, modification, test, renvoi) | 60 actions par heure et par utilisateur |