Your webhook address is public: anyone who knows it could send it a message. To make sure a message really comes from Ezus Pay and hasn't been altered, every message is signed with your webhook's secret. Your server should check that signature before doing anything with the message.
This article is for the developer building the connection. If you use a no-code tool, read No-code tools at the end.
Where to find the secret
Each webhook has its own signing secret, starting with ezpw_whk_. It is shown once, when the webhook is created (see Set up a webhook).
If you didn't keep it, generate a new one: open the webhook's page, then Signing secret → Regenerate.
Store the secret like a password: on your server, in an environment variable or a secrets manager. Use it exactly as shown, ezpw_whk_ prefix included, without decoding or trimming it.
How the signature works
Each message carries an Ezuspay-Signature header:
Ezuspay-Signature: t=1758700800,v1=8f3c2a…c1a9
tis the moment the message was signed, in seconds.v1is an HMAC-SHA256 signature, in lowercase hexadecimal, of the string<t>.<raw body>, computed with your webhook's secret.
To verify a message, your server:
Reads the raw body, byte for byte, before any JSON parsing.
Checks the timestamp: refuses the message if
tis more than 5 minutes away from the current time. This stops someone from replaying an old message they intercepted.Recomputes the signature from
t, the raw body and the secret.Compares it to
v1in constant time, withtimingSafeEqual,hash_equalsorcompare_digestrather than==.Ignores messages already processed, using the
idin the body (also sent in theEzuspay-Event-Idheader). The same message can be delivered more than once.
Only then should it process the event, ideally in the background, after answering 200 right away.
Each retry is signed again with a new t, so a message delivered hours later still passes the 5-minute check.
Code examples
Node.js (Express)
import crypto from "node:crypto"; import express from "express"; const app = express(); const SECRET = process.env.EZUS_PAY_WEBHOOK_SECRET; // ezpw_whk_… // express.raw: the signature covers the raw body, not parsed JSON. app.post("/webhooks/ezus-pay", express.raw({ type: "application/json" }), (req, res) => { const header = req.get("Ezuspay-Signature") ?? ""; const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400); const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest(); const given = Buffer.from(parts.v1 ?? "", "hex"); if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) { return res.sendStatus(400); } const event = JSON.parse(req.body.toString("utf8")); // Skip if event.id was already processed, then queue the work. res.sendStatus(200); });PHP
<?php $secret = getenv('EZUS_PAY_WEBHOOK_SECRET'); $payload = file_get_contents('php://input'); // raw body parse_str(str_replace(',', '&', $_SERVER['HTTP_EZUSPAY_SIGNATURE'] ?? ''), $parts); $t = (int) ($parts['t'] ?? 0); if ($t === 0 || abs(time() - $t) > 300) { http_response_code(400); exit; } $expected = hash_hmac('sha256', $t . '.' . $payload, $secret); if (!hash_equals($expected, $parts['v1'] ?? '')) { http_response_code(400); exit; } $event = json_decode($payload, true); // Skip if $event['id'] was already processed, then handle the event. http_response_code(200);Python (Flask)
import hashlib, hmac, os, time from flask import Flask, abort, request app = Flask(__name__) SECRET = os.environ["EZUS_PAY_WEBHOOK_SECRET"].encode() @app.post("/webhooks/ezus-pay") def ezus_pay_webhook(): header = request.headers.get("Ezuspay-Signature", "") parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) t = int(parts.get("t", "0") or 0) if abs(time.time() - t) > 300: abort(400) body = request.get_data() # raw bytes expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, parts.get("v1", "")): abort(400) event = request.get_json() # Skip if event["id"] was already processed, then handle the event. return "", 200The same examples are available in the app: Settings → Integrations → Documentation → Webhooks → Verify the signature.
Test your verification
On the webhook's page, click Send a test. The ping message is signed exactly like real events. If your server answers 200, your verification works. If it answers 400, check the pitfalls below.
Common pitfalls
The body was parsed before being verified. Re-serializing JSON changes spaces, key order or escaping, so the signature no longer matches. Always verify the raw body. In Express,
express.json()must not run before your webhook route.Wrong secret. Each webhook has its own secret. If you have several webhooks, check you're using the one shown on this webhook's page. After a Regenerate, only the new secret works.
Modified secret. The
ezpw_whk_prefix was removed, or spaces were added when copying.Server clock off. If your server's time drifts by more than 5 minutes, every message is refused. Keep it synchronized (NTP).
Comparing with
==. It works, but lets an attacker guess the signature bit by bit. Use a constant-time comparison.
When you regenerate the secret
Regenerating a secret takes effect immediately: from then on, every message is signed with the new secret only.
To avoid losing anything:
Click Regenerate and copy the new secret.
Update your server right away.
Messages your server refuses in the meantime aren't lost: they are retried automatically over the next hours (see Deliveries, retries and troubleshooting) and will pass once your server has the new secret.
No-code tools
Tools such as Zapier or Make generally can't verify a signature. That's fine for notifications, such as a Slack message or a row in a spreadsheet.
For anything sensitive, such as confirming a booking, triggering a refund or updating accounting, don't trust the message's content on its own: use the transaction_id it contains to read the payment back through the API (GET /transactions/:id). The API only answers requests made with your key, so what it returns is certain to come from Ezus Pay.


