MercanPay Blog

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.

Published: September 30, 20265 min readTürkçe oku
How to Verify Webhook Signatures with HMAC-SHA256

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

  1. Read the raw body. Take the request body as bytes, before any JSON parsing.
  2. Parse the header. Extract t and v1 from the comma-separated parts.
  3. Check the timestamp. Reject the request if t is more than 5 minutes away from now.
  4. Compute the expected signature. Run t + "." + raw_body through HMAC-SHA256 with your secret.
  5. Compare in constant time. Compare your result with v1 using 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:

  1. 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.
  2. 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_id really exists in your system
  • token and paid_amount match your order
  • the event is invoice.paid or invoice.overpaid; invoice.partial is 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.

#Webhooks#HMAC#Security#API

Related posts

Start accepting crypto payments in minutes

Accept USDT and TRX (TRC20). Fees from 0.4%, a hosted payment page, API and webhooks.