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.

  1. Enter the URL. It must use https:// on the standard port (443), with no user name or password, and point to a public address. localhost and private networks are refused.
  2. Pick the events. You can add up to 5 endpoints per agency, each with its own events.
  3. Copy the signing secret. It starts with ezpw_whk_ and is shown only once.
  4. Click Send a test. Ezus Pay sends a ping event 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

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
HeaderContent
Ezuspay-Signaturet= Unix time in seconds, then v1= the HMAC-SHA256 signature in lowercase hex
Ezuspay-Event-IdThe event ID. Same value as id in the body, stable across retries
Ezuspay-Event-TypeThe event type, to route without parsing the body

Every event shares the same envelope:

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 is in milliseconds. The t of the signature is in seconds.
  • api_version stays 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:

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

Use the whole secret as the key, ezpw_whk_ prefix included. Then:

  1. Read the raw body before any JSON parsing. Parsing then re-encoding changes the bytes.
  2. Compare signatures in constant time.
  3. Reject a t more than 5 minutes away from your clock. Each retry is signed again with a fresh t.
  4. Skip an id you have already processed.
  5. Answer 2xx quickly, then do the work in the background.

Node.js

JavaScript
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

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
<?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

TypeSent when
payment.succeededA payment on a link is confirmed, including each installment of a payment plan
payment.failedA payment attempt is declined, including a failed automatic charge
payment.refundedA refund has actually gone through at Stripe
booking.fully_paidA payment brings an alternative's collected total to its full amount
pingOnly 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:

ObjectFields
bookingid, ezus_project_id (or null), reference (or null)
alternativeid, ezus_alternative_id (or null)
payment_linkid, label, kind (SINGLE or PLAN)
installmentnull 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.

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": "passport_number", "label": "Passport number", "type": "TEXT", "value": "20AB12345" },
        { "key": "insurance", "label": "Cancellation insurance", "type": "CHECKBOX", "value": "true" }
      ]
    }
  }
}
  • amount_cents is the amount of this payment. For a plan, that is the installment.
  • payment_method is card, sepa_debit, or null.
  • custom_fields holds the traveler's answers to the checkout fields, or null. Every value is a string.
  • paid_at is 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.

JSON
{
  "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.

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": "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_cents is this refund. total_refunded_cents is the running total for the payment, so you can catch up after a missed event.
  • reason is Stripe's reason (duplicate, fraudulent, requested_by_customer) or null.
  • Treat refund_id as 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.

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 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 attemptNext attempt after
130 seconds
22 minutes
310 minutes
41 hour
56 hours
6No 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 id with a unique constraint.
  • No ordering. payment.succeeded and booking.fully_paid leave at the same moment, and retries arrive late. Rely on paid_at, created and total_refunded_cents, not on arrival order.
  • One id per endpoint. The same business event sent to two of your endpoints carries two different ids.
  • Signed at send time, with the endpoint's current secret.
  • Accept new event types and new fields without failing. The catalog will grow.

Limits

LimitValue
Endpoints per agency5 (test mode has its own)
URLhttps, port 443, public address, up to 2,000 characters
Response time8 seconds
Response body kept in the log2,048 characters
Delivery log30 days
Resend all failuresLast 7 days, up to 500 deliveries
Automatic suspension72 hours without a successful delivery
Managing endpoints (create, edit, test, resend)60 actions per hour per user