How to verify payment webhook authenticity in Node.js and Python — and two mistakes that break it
Most payment integrations handle webhooks the same way: receive POST, parse JSON, update order status. Fast, clean, works in staging.
What most integrations skip: verifying that the payload actually came from the payment provider and wasn't modified in transit.
This isn't paranoid security theatre. An unsigned webhook endpoint is an unauthenticated state-mutation surface. Anyone who can reach it can tell your system an order was paid.
How signing works
The provider signs every outgoing webhook payload using a shared secret — typically HMAC-SHA256 over the raw request body. The signature is sent in a request header (usually X-Signature or X-PaynetEasy-Signature). On your end, you compute the expected signature and compare.
Node.js, 8 lines:
```js
const crypto = require('crypto');
function verifyWebhook(rawBody, receivedSig, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(receivedSig)
);
}
```Python, same idea:
```python
import hmac, hashlib
def verify_webhook(raw_body: bytes, received_sig: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, received_sig)
```
Two things people get wrong
Signing the parsed body, not the raw bytes.
If you parse JSON first and then re-serialize to compute the signature, you'll get mismatches on key ordering, whitespace, or float precision. Always sign request.rawBody / request.body before any parsing.
Using == instead of a constant-time comparison.crypto.timingSafeEqual / hmac.compare_digest exist for a reason. String equality short-circuits on the first differing byte, which leaks timing information an attacker can use to guess valid signatures one character at a time. Use them.
Signature verification tells you the payload is authentic. It doesn't tell you it's fresh. A valid signed webhook from three days ago is still a valid signed webhook.
Fix: include a timestamp in the signed payload (or as a separate signed header), and reject anything older than 5 minutes. Most providers do this already — check your docs.
```js
const FIVE_MINUTES = 5 60 1000;
function isTimestampFresh(timestampMs) {
return (Date.now() - timestampMs) < FIVE_MINUTES;
}
```
The checklist
- Verify signature before any processing
- Sign raw bytes, not parsed JSON
- Use constant-time comparison
- Reject stale timestamps (> 5 min)
- Return 200 even on verification failure — don't tell the sender what failed
That last one is deliberate. Returning 403 on bad signature leaks which check failed. Return 200, log it internally, alert your team.
We sign all Payneteasy webhook deliveries and expose the verification logic in our integration docs. If you're building on top of our API and want a reference https://payneteasy.com
1
1
0