Webhooks, and never trusting the browser
One endpoint in this application has no guard on it, is reachable by anybody on the internet, and can mark an order paid.
That should worry you. Being worried about it correctly is this lesson.
The endpoint
@Post("webhook")
@HttpCode(HttpStatus.OK)
webhook(
@Req() request: RawBodyRequest,
@Headers("x-kirana-signature") signature?: string,
): Promise<{ received: true }> {
const raw = request.rawBody?.toString("utf8") ?? "";
return this.payments.handleWebhook(raw, signature);
}
No @UseGuards. There cannot be: the gateway has no session, no cookie and no
account with you. The signature is the entire security boundary.
Verifying a signature, properly
verify(rawBody: string, signature: string): boolean {
const expected = Buffer.from(this.sign(rawBody), "utf8");
const provided = Buffer.from(signature, "utf8");
if (expected.length !== provided.length) return false;
return timingSafeEqual(expected, provided);
}
Three things, and each one is a bug somebody has shipped.
The raw bytes
const raw = request.rawBody?.toString("utf8") ?? "";
The signature covers exactly what was sent. JSON.stringify(request.body)
produces valid JSON with the same fields and different whitespace, so the
signature never matches — and the usual response to "signature verification
never works" is to remove it.
timingSafeEqual, not ===
String comparison returns as soon as two bytes differ. That makes it fast, and it makes how long it takes depend on how much of the input was correct.
An attacker who can send many requests and measure the response time can use
that to find the signature one byte at a time. It is a slow attack and a real
one, with a free fix: timingSafeEqual always compares every byte.
The length check before it is necessary because timingSafeEqual throws on a
length mismatch — but comparing lengths only leaks the length, which the
attacker already knows, because it is the length of a SHA-256 hex digest.
Verify before parsing
if (!signature || !this.gateway.verify(rawBody, signature)) {
throw new AppException(ErrorCode.UNAUTHENTICATED, HttpStatus.UNAUTHORIZED, "Bad signature.");
}
let event: PaymentWebhookEvent;
try {
event = JSON.parse(rawBody) as PaymentWebhookEvent;
} catch { … }
Signature first, then parse. An unverified body is attacker-controlled input, and the less that touches it before it is authenticated, the better.
The error says "Bad signature." and nothing else. A forged request should learn nothing about why it was rejected — not which field was wrong, not whether the payment reference exists.
Status codes here are not the usual status codes
This is the part that feels wrong and is right.
if (!payment) {
this.logger.warn(`Webhook for unknown payment ${event.providerRef}`);
return { received: true };
}
A webhook for a payment we have never heard of returns 200, not 404.
The reason is that you are not talking to a browser. You are talking to a retrying machine. A gateway that receives an error will send the same event again — after a second, a minute, an hour, for a day — because from its side an error means "they did not get it".
Retrying will not make you recognise a payment you have never heard of. So 200 means "received, stop sending", which is the honest answer.
The rule for webhook endpoints:
| You want the gateway to… | Return |
|---|---|
| stop sending this event | 2xx — including when you deliberately ignore it |
| try again shortly | 5xx, or time out |
| stop and alert somebody | 4xx, for a genuinely malformed or forged request |
Being sloppy in the other direction is worse, by the way: returning 200 to something you failed to process means the gateway stops, and that payment notification is gone for good. Which is why the amount mismatch below throws.
Idempotency is a single check
if (payment.status !== "PENDING") {
this.logger.log(`Ignoring repeat webhook for ${event.providerRef}`);
return { received: true };
}
Gateways deliver the same webhook more than once. This is documented behaviour, not a fault. If your endpoint is slow or returns a 500, they retry — because the alternative is losing payment notifications.
So duplicates are certain, not unlikely, and they will arrive on the busiest day. Without that check, a duplicate would place the order again, send a second confirmation text, and — once refunds exist — potentially refund twice.
The check works because the payment's status is the state. PENDING means
"nothing has happened to this yet". Anything else means we have already acted.
The eventId is stored as the idempotencyKey as well, which gives a second,
stronger guard once you want it: a unique index on that column turns a duplicate
into a database error rather than relying on reading the status first.
Never trust the amount either
if (event.amountPaise !== payment.amountPaise) {
this.logger.error(
`Amount mismatch on ${event.providerRef}: expected ${payment.amountPaise}, got ${event.amountPaise}`,
);
throw new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST,
"Amount does not match the order.");
}
The signature proves the message came from the gateway. It does not prove the gateway is right, and it does not protect against your own bugs — an intent opened for the wrong order, a price changed between intent and payment, a misconfigured sandbox.
Compare against your own record. If they disagree, refuse loudly and let a human
look. This is one of the few places where a log line at error level is exactly
right: it is rare, it is never routine, and somebody must see it.
Marking it paid
await this.prisma.payment.update({
where: { id: payment.id },
data: { status: "SUCCEEDED", idempotencyKey: event.eventId },
});
await this.orders.setStatus(payment.order.orderNumber, "PLACED", {
note: "Payment received",
});
Through OrdersService.setStatus — module 13's state machine — not a direct
write to the status column.
That gets three things for free: the transition is validated, an OrderEvent is
appended in the same transaction, and the customer's confirmation message is
sent by the same code that sends every other one. A direct update here would
be a fourth code path that quietly skips all of it.
When you find yourself writing a status column outside the one function that owns it, that is the smell.
A failure is not the end
} else if (event.event === "payment.failed") {
await this.prisma.payment.update({
where: { id: payment.id },
data: { status: "FAILED", idempotencyKey: event.eventId },
});
this.logger.log(`Payment failed for ${payment.order.orderNumber}`);
}
The payment is marked FAILED. The order stays PENDING_PAYMENT.
Cards get declined for dull reasons — a daily limit, a bank's fraud heuristic, a mistyped OTP. The customer's basket, address and slot are all still valid, and they will very likely succeed on the second attempt with a different method.
Cancelling the order on a failed payment throws all of that away and loses a sale that was nearly made. Leaving it lets them retry, and the sweep in the next lesson deals with the ones who never do.
What this design is still missing
Worth being honest about, since the gap is where people get stuck.
A raw event log. Production stores every webhook it receives — body, headers, signature, verdict — before doing anything with it. When a payment is disputed three weeks later, that table is the answer.
Out-of-order delivery. A payment.failed for attempt one can arrive after a
payment.succeeded for attempt two. Here, each is keyed to its own payment row,
so it works — but only because attempts are separate rows. That is not an
accident.
A replay window. A gateway usually includes a timestamp in the signed payload so a captured request cannot be replayed a month later. Worth adding the day you handle real money.
Check your work
Why the webhook route has no guard: the gateway has no session, so the signature is the only boundary.
Why the raw body: the signature covers the bytes sent, and re-serialising a parsed object produces different ones.
Why timingSafeEqual: === returns at the first differing byte, leaking
how much of the signature was right.
Why verify before parsing: an unverified body is attacker-controlled input.
Why the error says only "Bad signature": a forged request should learn nothing about why it failed.
Why an unknown payment returns 200: you are talking to a retrying machine, and retrying will not make you recognise it.
Why a duplicate changes nothing: gateways retry by design, so duplicates are certain and will arrive when you are busiest.
Why the amount is checked against your own record: the signature proves who sent it, not that it is right.
Why it goes through setStatus: validation, the audit trail and the
customer's message all live there.
Why a failed payment leaves the order pending: declines are usually dull and retrying usually works.
Practice
- Send a webhook with no signature header. Confirm 401.
- Send one with a signature of 64 zeroes. Confirm 401.
- Sign a body correctly, then change one character of the body before sending. Confirm 401.
- Change
timingSafeEqualto===and confirm everything still passes. Note that your tests cannot see the difference — this is a bug tests do not catch. - Send a correctly signed event for a
providerRefthat does not exist. Confirm 200, and find the log line. - Send the same success webhook twice. Confirm the order's event trail does not grow the second time.
- Sign an event with the right reference and the wrong amount. Confirm 400 and find the error log.
- Make the handler write
status: "PLACED"directly instead of callingsetStatus. List what stops happening. - Send a
payment.failed, then open a new intent and succeed. Confirm the order is placed and that two payment rows exist with different statuses. - Sketch the
webhook_eventstable you would add for an audit log: the columns, the index, and how long you would keep rows.
Next: failures, retries and refunds.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship