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

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
timingSafeEqualafter 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.
Screenshots


