Demo project

Payment webhooks: signature, duplicates and a journal

A payment receiver demo: forgeries rejected, repeats never charged twice, a failure does not lose the payment.

demo development · Node.js · HTTP · HMAC SHA-256 · JSONL

in progressDedup in process memory, port and secret hardcoded, no RE...

5 scenariosforged, paid, duplicate, failure, retry
0double charges on a repeated event
HMAC SHA-256body signature: no key, no forgery
3 codesin the run: 200, 401, 503
1 linein the journal per event
Demo first screen: Payment webhooks: signature, duplicates and a journal

The task

A shop takes money through a payment provider. When an order is paid, the provider posts an “order paid” notification to the shop’s endpoint - and that is where the awkward questions begin. What stops someone from forging that notification and walking off with the goods? What happens when the same notification arrives twice - does the customer get charged again? And if the shop’s own system was down at that moment, is the payment simply lost?

The demo answers those three questions with a run instead of a promise. The shop and the amounts are invented; the mechanics are real.

The solution

A webhook receiver written in plain Node - no frameworks, no dependencies - plus a scenario that pushes five events through it in a row:

  • a forged notification - 401, the order is untouched;
  • a genuine payment - 200, the order goes through;
  • the same webhook again - 200, but no second charge;
  • the shop’s own handler failing - 503 and an honest “the provider will retry”;
  • the provider’s retry - 200, the payment lands after all.

Every line the scenario prints is a real HTTP response, not a mock-up: it actually sends five requests. One caveat: that table is what a freshly started receiver produces. The list of accepted events lives in process memory, so a second run against the same receiver prints something else - the forgery is still rejected on its signature, but the other four events come back as duplicates. Restart the receiver to reproduce the table.

journal.jsonl is append-only: five lines per run, each with a timestamp, status code, verdict and event id - the answer to “where do I look to see what really arrived?”.

A third entry point, verify.mjs, prints the notification body, its signature, and the verdict on a tampered body: change the amount in that same body to 1 and the signature stops matching, so the event is dropped. The signature covers the whole body, so “paid” cannot be forged without the key.

Details that are easy to miss

  • The order of checks: signature first, then JSON parsing, then the duplicate check, and only then money. The signature is computed over the raw body and checked before everything else, so a forged event never reaches the business logic.
  • Signatures are compared with timingSafeEqual after a length check, not with ===. Character-by-character comparison leaks a guessed prefix through response time.
  • Deduplication keys on the event id, not the order number: a provider sends several different events about one order, and silencing them wholesale would hide real ones.
  • When the handler fails, the event is not marked as processed. Otherwise the provider’s retry would be rejected as a duplicate and the payment would vanish for good. Same reason for replying 503 rather than 200: to a provider, 200 means “delivered, stop retrying”.
  • Rejected events are logged too. The id for the journal line is pulled out separately, inside a try/catch: on malformed JSON the line records an empty id instead of the receiver crashing.
  • The handler failure is deterministic rather than random: an event carrying the flaky flag fails exactly once per receiver process and goes through on the next attempt.

What is still missing

Three things mark this as a demo rather than production code. The record of processed events lives in process memory: restarting the receiver wipes the duplicate protection, and a second run against a receiver that has already worked produces a completely different table from the first. In a working deployment that record belongs in a database table. The signing secret and port 4320 are hardcoded, and the very same port is hardcoded in the lead receiver of a neighbouring demo, so the two cannot run side by side. And the demo still has no README of its own, while a run needs two terminals - one for the receiver, one for the scenario. Next step: configuration through environment variables, keeping processed ids outside the process, and a single command that starts the receiver, runs the scenario and shuts it down afterwards.