Daniel Karpilovsky

Jul 28, 2026 • 2 min read

Webhook Signing: The Five Lines You're Probably Skipping

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

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

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

Join Daniel on Peerlist!

Join amazing folks like Daniel and thousands of other builders on Peerlist.

peerlist.io/

It’s available... this username is available! 😃

Claim your username before it's too late!

This username is already taken, you’re a little late.😐

1

1

0