How to Verify Webhook Signatures with HMAC-SHA256
Learn to verify a webhook signature with HMAC-SHA256: the t=…,v1=… header, raw bodies, constant-time comparison and replay protection, in PHP, Python and Node.

To verify a webhook signature is to prove that a payment notification really came from your payment provider and wasn't altered on the way. Without that check, anyone can send your endpoint a fake "payment received" request. This guide explains how HMAC-SHA256 webhook signatures work, how to verify them in PHP, Python and Node.js, and how to block replay attacks.
Why webhook signatures matter
Your webhook endpoint is a public URL, such as https://myshop.com/mercanpay/webhook. Anyone who knows it can send something like this:
{ "type": "invoice.paid", "data": { "invoice": { "order_id": "order-1001", "status": "paid" } } }
Without signature verification, your system marks the order as paid and ships it, even though no money arrived. This is one of the most expensive mistakes in crypto payment integrations.
Signatures fix this. You and the provider share a secret key, and the provider signs every request with it. Nobody without the secret can produce a valid signature.
How HMAC-SHA256 works
HMAC (Hash-based Message Authentication Code) combines a message with a secret key and hashes them together. SHA-256 is the hash function. The result has three useful properties:
- The same message and secret always produce the same signature.
- Changing a single byte of the message changes the signature completely.
- Producing a valid signature without the secret is practically impossible.
MercanPay's signature is the hex value of HMAC-SHA256(secret, timestamp + "." + raw_body). It is sent in the X-MercanPay-Signature header in the form t=1790000052,v1=5f2b...e91a.
t is a Unix timestamp in seconds, and v1 is the hex signature. The secret is the webhook signing secret on the Settings page of the panel, starting with whsec_.
Verifying a webhook signature in 5 steps
- Read the raw body. Take the request body as bytes, before any JSON parsing.
- Parse the header. Extract
tandv1from the comma-separated parts. - Check the timestamp. Reject the request if
tis more than 5 minutes away from now. - Compute the expected signature. Run
t + "." + raw_bodythrough HMAC-SHA256 with your secret. - Compare in constant time. Compare your result with
v1using a timing-safe function.
If the signature is valid, parse the JSON and handle the event. If not, return 401 and do nothing else.
Verifying in PHP
<?php
$secret = getenv('MERCANPAY_WEBHOOK_SECRET'); // whsec_...
$body = file_get_contents('php://input'); // raw body
$header = $_SERVER['HTTP_X_MERCANPAY_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $sig); // t=...,v1=...
$fresh = isset($sig['t']) && abs(time() - (int) $sig['t']) <= 300;
$mac = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, $secret);
if (!$fresh || !hash_equals($mac, $sig['v1'] ?? '')) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
With the official library (composer require mercanpay/mercanpay-php), it's a one-liner:
if (!\MercanPay\MercanPay::verifyWebhook($secret, $body, $header)) { http_response_code(401); exit; }
Verifying in Python
import hashlib, hmac, time
def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
sig = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t = int(sig.get("t", 0))
if "v1" not in sig or abs(time.time() - t) > tolerance:
return False
mac = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, sig["v1"])
The verify_webhook(secret, body, header) function from pip install mercanpay does the same. For Flask and Django examples, see the Python integration guide.
Verifying in Node.js
import crypto from 'node:crypto';
import express from 'express';
function verify(secret, rawBody, header, tolerance = 300) {
const sig = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(sig.t);
if (!sig.v1 || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const mac = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return mac.length === sig.v1.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(sig.v1));
}
const app = express();
// the signature covers the raw body: don't use express.json() on this route
app.post('/mercanpay/webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(process.env.MERCANPAY_WEBHOOK_SECRET, req.body.toString(), req.get('X-MercanPay-Signature') || '')) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
res.sendStatus(200);
});
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
| Parsing and re-serialising the JSON | Whitespace and key order change; the signature fails | Use the raw bytes |
Comparing with == |
Open to timing attacks | hash_equals, hmac.compare_digest, timingSafeEqual |
| Skipping the timestamp check | Old requests can be replayed | Enforce a 5-minute tolerance |
| Hard-coding the secret | A leaked repo leaks the secret | Use an environment variable |
| Returning 200 on a bad signature | Problems stay hidden | Return 401 and log it |
Fulfilling from the success_url redirect |
URLs can be forged | Only the signed webhook or the API |
Protecting against replay attacks
Someone who captures a valid webhook request could send it again later. This is a replay attack, and since the signature is genuine, checking the signature alone won't stop it. Protection comes in two layers:
- A time window. The timestamp is part of the signed payload, so an attacker can't change
t. If you reject anything older than 5 minutes, a captured request quickly becomes useless. - De-duplication by event id. Every webhook body carries a unique
id. Store processed ids in a unique database column and never process the same id twice.
The second layer also handles legitimate repeats. If your server doesn't answer 2xx within 10 seconds, MercanPay retries after 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h, 12 h and 24 h, so the same event can reach you several times. Your code should take that in stride.
After the signature: business checks
The signature proves the request came from MercanPay. Before fulfilling, still check that:
data.invoice.order_idreally exists in your systemtokenandpaid_amountmatch your order- the event is
invoice.paidorinvoice.overpaid;invoice.partialis an underpayment, and the order isn't complete yet
See Underpayments, overpayments and late payments for how to handle each case.
FAQ
How do I test my webhook?
The "Send test" button on the Webhooks page of the panel sends a signed test.ping event. The delivery history shows each request's response code and timing, and you can resend any event.
Why a 5-minute tolerance?
Server clocks drift slightly. Five minutes is wide enough not to reject genuine requests and narrow enough to stop replays. Make sure your server clock is synced over NTP.
Do I still need signatures if I use HTTPS?
Yes. HTTPS encrypts traffic in transit, but it doesn't prove who sent the request. Anyone can call your endpoint over HTTPS.
Get started with MercanPay
MercanPay webhooks are signed with HMAC-SHA256, retried automatically and logged with a full delivery history in the panel. Start with the PHP integration guide, or browse every endpoint in the documentation. Create your merchant account.


