Webhooks
Get a signed HTTPS call the moment a payment succeeds, fails or is refunded, or a booking is fully paid. Setup, signature check, event catalog and retries.
Checked against the API code on October 13, 2026
How it works
When something happens to a payment, Ezus Pay sends a POST with a JSON body to each of your endpoints subscribed to that event. Every request is signed with a secret that only you and Ezus Pay know.
Webhooks are sent after the payment is recorded. A slow or broken endpoint never delays a payment, and a failed delivery is retried for about eight hours.
Add an endpoint
In the app, an admin opens Settings → Integrations → Webhooks and clicks Add a webhook.
- Enter the URL. It must use
https://on the standard port (443), with no user name or password, and point to a public address.localhostand private networks are refused. - Pick the events. You can add up to 5 endpoints per agency, each with its own events.
- Copy the signing secret. It starts with
ezpw_whk_and is shown only once. - Click Send a test. Ezus Pay sends a
pingevent and shows your server's answer.
Each endpoint has its own page with a delivery log covering the last 30 days. For each delivery you see the payload sent, your server's response and the attempts. You can resend one delivery, or resend all failures from the last 7 days in one click.
Rotating the secret takes effect at once, with no overlap. To rotate without losing events, have your receiver accept either of two secrets for a while: the new one and the previous one.
Automatic suspension. An endpoint that has failed for 72 hours without a single success is suspended, and every admin gets an email. Events that occur while it is suspended are kept as failed. From the endpoint page, Re-enable and resend missed events catches up in one click. Admins also get an email, at most once a day, when an event fails all its attempts.
The request
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
| Header | Content |
|---|---|
Ezuspay-Signature | t= Unix time in seconds, then v1= the HMAC-SHA256 signature in lowercase hex |
Ezuspay-Event-Id | The event ID. Same value as id in the body, stable across retries |
Ezuspay-Event-Type | The event type, to route without parsing the body |
Every event shares the same envelope:
{
"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": { } }
}
createdis in milliseconds. Thetof the signature is in seconds.api_versionstays the same when fields are added. Accept fields you do not know.- Your server has 8 seconds to answer. Redirects are not followed.
Verify the signature
The signature covers the timestamp and the raw body, byte for byte:
signed_payload = t + "." + raw_body
v1 = hex(HMAC_SHA256(secret, signed_payload))
Use the whole secret as the key, ezpw_whk_ prefix included. Then:
- Read the raw body before any JSON parsing. Parsing then re-encoding changes the bytes.
- Compare signatures in constant time.
- Reject a
tmore than 5 minutes away from your clock. Each retry is signed again with a fresht. - Skip an
idyou have already processed. - Answer
2xxquickly, then do the work in the background.
Node.js
import crypto from "node:crypto";
import express from "express";
const app = express();
// Current and previous secret, so a rotation loses nothing.
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, not express.json, on this 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") {
// Store event.id with a unique constraint, then queue the work.
}
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() # raw bytes, before request.json
if not verify(raw, request.headers.get("Ezuspay-Signature")):
return "invalid signature", 400
event = json.loads(raw)
# Skip event["id"] if already seen, then queue the work.
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);
// Skip $event['id'] if already seen, then queue the work.
http_response_code(200);
Events
| Type | Sent when |
|---|---|
payment.succeeded | A payment on a link is confirmed, including each installment of a payment plan |
payment.failed | A payment attempt is declined, including a failed automatic charge |
payment.refunded | A refund has actually gone through at Stripe |
booking.fully_paid | A payment brings an alternative's collected total to its full amount |
ping | Only from the Send a test button. Answer 200 and ignore it |
All amounts are integers in cents, and currency is an uppercase ISO 4217 code. These objects appear in several events:
| Object | Fields |
|---|---|
booking | id, ezus_project_id (or null), reference (or null) |
alternative | id, ezus_alternative_id (or null) |
payment_link | id, label, kind (SINGLE or PLAN) |
installment | null for a simple link. For a plan payment: plan_id, sequence (1 is the deposit), count (total installments), due_date (YYYY-MM-DD) |
payment.succeeded
One event per payment. For a SEPA debit, it is sent when Stripe confirms the debit, a few days after checkout.
{
"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": "passport_number", "label": "Passport number", "type": "TEXT", "value": "20AB12345" },
{ "key": "insurance", "label": "Cancellation insurance", "type": "CHECKBOX", "value": "true" }
]
}
}
}
amount_centsis the amount of this payment. For a plan, that is the installment.payment_methodiscard,sepa_debit, ornull.custom_fieldsholds the traveler's answers to the checkout fields, ornull. Everyvalueis a string.paid_atis when Ezus Pay recorded the payment.
payment.failed
Sent for each declined attempt. A traveler who tries two cards produces two events, which can share the same transaction_id. A later payment.succeeded for that transaction makes them obsolete. No money moved: treat it as a signal to follow up.
{
"transaction_id": "3b9d2c1a-0f9e-4d8c-b7a6-5e4d3c2b1a09",
"payment_link": { "id": "7c6b5a4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d", "label": "Deposit 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"
}
This is data.object; the envelope is the same as above. failure_code is Stripe's decline code when there is one, and failure_message is Stripe's message, in English.
payment.refunded
Sent when a refund has actually gone through, not when it is requested. It covers refunds made from the app, from the API and from Stripe. A refund that fails sends nothing.
{
"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": "Deposit 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_centsis this refund.total_refunded_centsis the running total for the payment, so you can catch up after a missed event.reasonis Stripe's reason (duplicate,fraudulent,requested_by_customer) ornull.- Treat
refund_idas an opaque string. It is the key to deduplicate refunds.
booking.fully_paid
Sent right after the payment.succeeded that completes an alternative. A final payment therefore produces two events, each with its own 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 is the alternative's total, not the last payment.
Retries and guarantees
Success is any 2xx answer. Everything else is retried: all 4xx (including 401, 404 and 429), 5xx, redirects, timeouts and network errors. To refuse an event for good, answer 200 and ignore it.
| Failed attempt | Next attempt after |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 1 hour |
| 5 | 6 hours |
| 6 | No more retries. The delivery is marked failed |
The six attempts span about 7 hours 45 minutes. After that, resend from the endpoint page. For a longer outage, catch up with GET /v1/transactions?dateFrom=….
- At least once. The same event can arrive twice, for example after a timeout or a manual resend. Store
idwith a unique constraint. - No ordering.
payment.succeededandbooking.fully_paidleave at the same moment, and retries arrive late. Rely onpaid_at,createdandtotal_refunded_cents, not on arrival order. - One
idper endpoint. The same business event sent to two of your endpoints carries two differentids. - Signed at send time, with the endpoint's current secret.
- Accept new event types and new fields without failing. The catalog will grow.
Limits
| Limit | Value |
|---|---|
| Endpoints per agency | 5 (test mode has its own) |
| URL | https, port 443, public address, up to 2,000 characters |
| Response time | 8 seconds |
| Response body kept in the log | 2,048 characters |
| Delivery log | 30 days |
| Resend all failures | Last 7 days, up to 500 deliveries |
| Automatic suspension | 72 hours without a successful delivery |
| Managing endpoints (create, edit, test, resend) | 60 actions per hour per user |