Skip to main content

Verify that a webhook comes from Ezus Pay

Check each message's signature before acting on it. For developers.

Written by Ezus team

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
  • t is the moment the message was signed, in seconds.

  • v1 is 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:

  1. Reads the raw body, byte for byte, before any JSON parsing.

  2. Checks the timestamp: refuses the message if t is more than 5 minutes away from the current time. This stops someone from replaying an old message they intercepted.

  3. Recomputes the signature from t, the raw body and the secret.

  4. Compares it to v1 in constant time, with timingSafeEqual, hash_equals or compare_digest rather than ==.

  5. Ignores messages already processed, using the id in the body (also sent in the Ezuspay-Event-Id header). 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 "", 200

The 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:

  1. Click Regenerate and copy the new secret.

  2. 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.

Did this answer your question?