Webhooks are simple to send and miserable to receive correctly. Most "we lost a payment notification" post-mortems trace back to one of three things: the receiver trusted an unsigned payload, the receiver trusted a signature without a timestamp window, or the receiver wasn't idempotent under retries. This post is how BchainPay solves all three — and how you can copy the pattern in any language.
The threat model nobody writes down#
When you publish a POST /webhooks/bchainpay endpoint, here's what's
trying to ruin your day:
- An attacker spoofing payment events. They want a free t-shirt by
replaying or forging
payment_intent.succeeded. - A network glitch causing duplicate deliveries. Your sender retries; your receiver double-books the order.
- A re-org or downstream bug emitting an event that gets superseded. You shipped before the chain settled.
- A noisy neighbour hammering your endpoint with garbage to burn CPU on signature verification.
A robust webhook receiver beats all four. Let's walk through each.
Signing: HMAC-SHA256 over ${timestamp}.${rawBody}#
Every BchainPay webhook ships with three headers:
x-bchainpay-event-id: evt_01HW2…
x-bchainpay-timestamp: 1714165200
x-bchainpay-signature: 9f2c… (hex)
The signature is computed as:
hex(HMAC-SHA256(secret, `${timestamp}.${rawBody}`))Two non-obvious details:
- The timestamp is part of the signed input. This makes it impossible to "replay yesterday's webhook today" with the original signature still intact. Without this, your shared secret is one packet capture away from being useless.
- It's the raw body, not the parsed JSON. A single space added by your framework's JSON re-serialization will break the signature. Read the body as bytes / text before anything parses it.
In Next.js Route Handlers:
export const runtime = 'nodejs';
export async function POST(req: Request) {
const ts = req.headers.get('x-bchainpay-timestamp')!;
const sig = req.headers.get('x-bchainpay-signature')!;
const raw = await req.text(); // ← raw body, not req.json()
// … verify, then JSON.parse(raw).
}In Express, you'll need to opt out of automatic JSON parsing for that route:
app.post(
'/webhooks/bchainpay',
express.raw({ type: 'application/json' }),
handler,
);Constant-time comparison or it doesn't count#
Comparing strings with === leaks information through timing. Use the
constant-time comparator in your runtime:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(raw: string, ts: string, sig: string, secret: string) {
const expected = createHmac('sha256', secret)
.update(`${ts}.${raw}`)
.digest('hex');
// Bail before timingSafeEqual if lengths differ, which would throw.
if (expected.length !== sig.length) return false;
return timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(sig, 'hex'),
);
}In Go: hmac.Equal. In Python: hmac.compare_digest. In Ruby:
OpenSSL::HMAC.compare. Whatever language you're in, there is a
constant-time comparator. Use it.
The 5-minute window that closes the replay gap#
Even with a perfectly verified signature, an attacker who captured a legitimate webhook last week can replay it forever. Add a window:
const skewSec = Math.abs(Date.now() / 1000 - Number(ts));
if (skewSec > 300) return new Response('stale', { status: 401 });Five minutes is the BchainPay default; we send retries inside the window. If the window is too tight you'll drop deliveries during clock skew, and if it's too loose you've effectively done nothing.
Idempotency: the part everyone gets wrong#
You've verified the signature, you've checked the timestamp. Now your
sender retries because your 200 OK got lost in transit. That's good.
What's bad is your handler fulfilling the order twice.
Two patterns work:
Pattern A — dedupe by event id#
Store the event id (evt_…) in a unique-keyed table. If the insert
fails with a duplicate key error, treat the delivery as a no-op and
return 200.
try {
await db.webhookDeliveries.create({ data: { id: eventId } });
} catch (e) {
if (isUniqueViolation(e)) return new Response('dup', { status: 200 });
throw e;
}This is dead simple and works for any backend with a UNIQUE constraint.
Pattern B — dedupe by business key#
Inside the handler, make the side-effect itself idempotent: a
markOrderPaid(orderId) that's a no-op if the order is already paid.
This composes better with state machines because it survives even if
the dedupe table is wiped.
In production we recommend both: pattern A as a fast guard, pattern B as the safety net.
Re-orgs and superseding events#
On EVM and Solana, a payment can be confirmed and then un-confirmed
during a re-org. BchainPay surfaces this as a follow-up
payment_intent.invalidated event, not by silently rewriting history.
Your handler should accept that the truth is the latest event for a
given payment_intent.id, not the first one.
This is also why we recommend the confirmation policies that match each chain's re-org safety:
- 1 block on Tron and Solana for low-value (<$10)
- 12 blocks on Ethereum mainnet for medium-value
- "finalized" on Solana for anything above $1,000
Cheap denial-of-service: skip the work, not the response#
Verifying an HMAC is cheap, but parsing a 50-MB body before verifying isn't. Two cheap defences:
- Cap the body size at 1 MB before reading. Anything larger is garbage.
- Bail before parse if the timestamp is outside the window or the signature length is wrong.
if (req.headers.get('content-length') &&
Number(req.headers.get('content-length')) > 1_048_576) {
return new Response('too big', { status: 413 });
}What 200 vs 4xx vs 5xx means to BchainPay#
2xx— delivered. We won't retry.4xx— don't retry, your receiver thinks the request is malformed (bad signature, expired timestamp). We log it and move on.5xxor no response — retry with exponential backoff for up to 24 hours: 1s, 5s, 30s, 2m, 10m, 1h, 6h, 24h.
If you ever need to debug why an event "didn't arrive", check the Webhook deliveries tab in the dashboard — every attempt is recorded with response code, latency and body.
TL;DR — a checklist you can paste into your repo#
[ ] Read raw body before any JSON parse
[ ] Verify HMAC-SHA256(secret, "${ts}.${rawBody}")
[ ] Compare signatures with timingSafeEqual / hmac.compare_digest
[ ] Reject if |now - ts| > 300s
[ ] UNIQUE index on event_id; INSERT to dedupe
[ ] Side-effect handler is idempotent on business key
[ ] Cap body at 1 MB
[ ] Return 2xx fast; 5xx triggers retries
[ ] Track latest event per resource (re-org safety)Webhooks aren't glamorous, but a payments integration is only as good as its quietest delivery path. Get this right once and you'll never think about it again.
If you'd rather not think about it at all, BchainPay handles the sender side and ships SDKs that handle the receiver side. Sandbox keys are free.