takemypay

How to verify a webhook signature: HMAC-SHA256 in practice

· 6 min read

Why payment notifications are signed, how an HMAC-SHA256 signature is built, the four mistakes that stop it from matching, and defence against replays.

A webhook is an HTTP request from a payment service to your address saying “payment X has been paid”. That address is public, and anyone can send a request to it. So a notification you are about to trust with money has to be signed — and the signature verified before the order goes out for shipping.

This applies to accepting payments anywhere: through a bank, or through a gateway that businesses turn to when they were refused acquiring. The mechanics of the signature do not depend on that.

What HMAC is and why a plain hash is not enough

The first idea is to send the SHA-256 of the body along with it. That does not work: an attacker who forged the body will compute its hash just as easily. A hash proves integrity, but not authorship.

HMAC solves exactly the second problem. A secret known only to the two parties takes part in the computation:

signature = HMAC_SHA256(key = secret, msg = request_body)

Without knowing the secret, the signature cannot be forged. Verification on your side means computing the same thing over the body you received and comparing it with the header.

How it works in takemypay

The secret is not the API key itself but its SHA-256 in hex:

X-Signature = HMAC_SHA256(key = hex(SHA256(api_key)), msg = raw_body) → hex

The extra step is not decoration. Only the hash of the key is stored on the service side — the key itself is shown once at creation and is not recoverable afterwards. Since both parties know the hash, the hash is what serves as the secret.

In full, in Python:

import hmac, hashlib

def verify(raw_body: bytes, signature: str, api_key: str) -> bool:
    secret = hashlib.sha256(api_key.encode()).hexdigest()
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Four mistakes that stop the signature from matching

1. Computing it over something other than the raw body. The most common one. The framework has already parsed the JSON, you take the object and serialise it back — and get a different string: the key order changed, spaces appeared or vanished, unicode escaping shifted. The signature is computed over the bytes that arrived, before any parsing. In FastAPI that is await request.body(), in Express express.raw() or a saved rawBody, in Django request.body (but not request.POST).

2. Comparing with ==. An ordinary string comparison exits on the first mismatching byte, so the signature can be guessed byte by byte from the response time. You need a constant-time function: hmac.compare_digest in Python, crypto.timingSafeEqual in Node.

3. Mixing up hex and base64. Both sides have to agree on the encoding. If digest() returned bytes while the header carries a hex string, the comparison will never match — even though the signature is correct.

4. Not accounting for key rotation. When a project has several active keys, the newest one signs. The X-Key-Prefix header carries the first 10 characters of the key that was used, and that is what tells you which key to verify against. Without this step, rotation breaks notification handling at the exact moment a new key is issued.

Defence against replays

A valid signature does not mean the request is fresh. Once a valid notification has been intercepted, it can be resent indefinitely — the signature stays correct.

That is why the signed body carries a timestamp and a nonce. The rule is simple: reject a body older than five minutes. Since both fields sit inside the signature, they cannot be altered without breaking it.

from datetime import datetime, timezone

ts = datetime.fromisoformat(body["timestamp"])
if (datetime.now(timezone.utc) - ts).total_seconds() > 300:
    return False

The response has to be 2xx

A separate trap, unrelated to cryptography. Delivery means a 2xx response; everything else is a reason to retry. Redirects belong to “everything else”: 301 and 302 are not success.

The mistake looks harmless — the framework itself moves /webhook to /webhook/, or http to https. The endpoint opens in a browser, the tests are green, and not a single notification is accepted. It surfaces days later as unshipped orders, because there is no error in the logs: as far as your server is concerned, nothing happened.

It takes one command to check:

curl -i -X POST https://your-domain/webhook -d '{}'

The first line should read HTTP/1.1 200, not 301 or 302.

The cost of this mistake depends on the payment method. Where confirmation only ever arrives over the network — payments over SBP, for instance — a rejected webhook means you will not learn about the payment by any other means at all.

The handler has to be idempotent

The same notification will reach you more than once — for example when your response was lost to a network timeout and a retry fired. Rely on the payment identifier and on the fact that the order has already been processed, not on having received the request. Otherwise a retry turns into a second shipment.

The retry schedule, the headers and the full body format are in the webhook documentation.

Frequently asked questions

Why can’t I just send the SHA-256 of the body?

Because a hash proves integrity, not authorship: an attacker who forged the body will compute its hash just as easily. HMAC brings in a secret known only to the two parties — without it the signature cannot be forged.

What serves as the secret in takemypay?

Not the API key itself but its SHA-256 in hex. Only the hash of the key is stored on the service side: the key is shown once at creation and is not recoverable. So the secret is the thing both sides know.

The signature does not match although the key is right. Where do I look?

First at what the signature is computed over: almost always it is computed over re-serialised JSON whose key order or escaping has shifted, rather than over the raw body. The other three causes are comparison with == instead of a constant-time function, confusion between hex and base64, and unhandled key rotation (the X-Key-Prefix header says which key was used).

Why are timestamp and nonce needed if the signature is valid?

Because a valid signature does not make a request fresh: an intercepted notification can be resent indefinitely and the signature stays correct. Both fields sit inside the signature, so the rule “reject a body older than five minutes” cannot be bypassed without breaking it.

Does a 302 response count as delivery?

No. Only 2xx counts as delivery; everything else is a reason to retry, and redirects are included. The failure looks harmless: the framework moves /webhook to /webhook/, the endpoint opens in a browser, and not one notification is accepted.

Can the same notification arrive twice?

Yes, and it is normal behaviour — for instance after your response was lost to a network timeout and a retry fired. The handler must therefore rely on the payment identifier and on an “order already processed” flag rather than on the arrival of the request.

If you need this in production rather than in theory — the takemypay dashboard issues a test key right after signup. Integration questions — ask support.

Share Telegram X

Read next