Idempotency — Two Clicks, One Charge
A validly signed webhook arrived twice
Goal
You connect signature over the raw body, a time window, and event deduplication into a single webhook processing path.
Why it matters
The payment provider did not receive a response and sent the same event again. The server thought it was a normal request because the signature matched, and added the revenue again. A signature is evidence about who sent it; it is not evidence that this is the first time the request is being processed. You have to check separately the time window that allows resends and the stored event id.
Steps
- In
/root/work/idem-webhook-lab/service.py, signed_bytes(timestamp, body) accepts only an int timestamp (not bool) and a bytes body, and returns str(timestamp).encode()+b'.'+body. A wrong type is a ValueError.
Prepare it once at the beginning. It does not overwrite an existing file.
mkdir -p /root/work/idem-webhook-lab
test -e /root/work/idem-webhook-lab/service.py || cp /opt/fixtures/ten_labs/idem-webhook-lab/service.py /root/work/idem-webhook-lab/service.py
cd /root/work/idem-webhook-lab
-
In
/root/work/idem-webhook-lab/service.py, signature(secret, timestamp, body) is the hex string obtained by applying HMAC-SHA256 to signed_bytes with a bytes secret. -
In
/root/work/idem-webhook-lab/service.py, verify(secret, timestamp, body, supplied) is True when supplied is a str and equals the computed signature by compare_digest, and False otherwise. -
In
/root/work/idem-webhook-lab/service.py, fresh(timestamp, now, tolerance=300) is True if timestamp and now are ints (not bool) and abs(now-timestamp)<=tolerance. Otherwise it is False. tolerance is a positive int given by the caller. -
In
/root/work/idem-webhook-lab/service.py, init_db(path) creates inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL) and total(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL), and inserts id=1,amount=0 into total without duplicates. -
In
/root/work/idem-webhook-lab/service.py, apply(path, event, fault=lambda:None) validates that the id of event is a non-empty str and that amount is a positive int (not bool). It computes the fingerprint of id and amount as canonical JSON. The same id and fingerprint gives False, a different fingerprint is a ValueError, and a new event returns True after inbox insert → fault() → total increase. -
In
/root/work/idem-webhook-lab/service.py, total(path) returns the integer amount of the row with id=1 in total. -
In
/root/work/idem-webhook-lab/service.py, create_app(path, secret, clock) reads the raw body, X-Timestamp, and X-Signature at POST /webhook. A bad time format, time window, or signature failure is 401, a JSON parse failure is 400, and a ValueError from apply is 409. The normal case is 200 {accepted:True, duplicate:False on first processing}.
Notes
- Do it in the existing lab-dev environment, without the internet or package installation.
- Each step runs within a 45-second grading budget. Do not add real sleep or network calls.
- Grading freshly imports the submitted module and checks it with independent inputs and a temporary DB. Implement the contract instead of returning expected values as constants.
- FastAPI official documentation · pytest official documentation · Python sqlite3
- Limitation: this signature format is a teaching protocol and does not stand in for the specification of any real payment provider. Use only a secret meant for learning. The reliability of the server clock, key rotation, the allowed body size, and the permanent retention period are separate operational tasks. Deduplication applies to the same event id with the same body, and reusing the same id with a different body is a conflict.
Preserve the raw bytes to sign
In /root/work/idem-webhook-lab/service.py, signed_bytes(timestamp, body) accepts only an int timestamp (not bool) and a bytes body, and returns str(timestamp).encode()+b'.'+body. A wrong type is a ValueError.
Prepare it once at the beginning. It does not overwrite an existing file.
mkdir -p /root/work/idem-webhook-lab
test -e /root/work/idem-webhook-lab/service.py || cp /opt/fixtures/ten_labs/idem-webhook-lab/service.py /root/work/idem-webhook-lab/service.py
cd /root/work/idem-webhook-lab
Parsing the raw body as JSON and rebuilding it changes what is signed.
After saving, check with bash /opt/lab/checks/idem-webhook-lab/01-contract.sh.
Compute the HMAC
In /root/work/idem-webhook-lab/service.py, signature(secret, timestamp, body) is the hex string obtained by applying HMAC-SHA256 to signed_bytes with a bytes secret.
Use the standard HMAC instead of appending a secret to an ordinary hash.
After saving, check with bash /opt/lab/checks/idem-webhook-lab/02-contract.sh.
Reject a wrong signature
In /root/work/idem-webhook-lab/service.py, verify(secret, timestamp, body, supplied) is True when supplied is a str and equals the computed signature by compare_digest, and False otherwise.
Whether a signature is present and whether it is correct are different checks.
After saving, check with bash /opt/lab/checks/idem-webhook-lab/03-contract.sh.
Limit replay from the past and the future
In /root/work/idem-webhook-lab/service.py, fresh(timestamp, now, tolerance=300) is True if timestamp and now are ints (not bool) and abs(now-timestamp)<=tolerance. Otherwise it is False. tolerance is a positive int given by the caller.
If you unconditionally allow a future timestamp, an attacker can extend the validity period.
After saving, check with bash /opt/lab/checks/idem-webhook-lab/04-contract.sh.
Get ready to store the event and the effect together
In /root/work/idem-webhook-lab/service.py, init_db(path) creates inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL) and total(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL), and inserts id=1,amount=0 into total without duplicates.
The duplicate event record and the business total must be in the same DB transaction.
After saving, check with bash /opt/lab/checks/idem-webhook-lab/05-contract.sh.
Roll back a failure between the record and the revenue
In /root/work/idem-webhook-lab/service.py, apply(path, event, fault=lambda:None) validates that the id of event is a non-empty str and that amount is a positive int (not bool). It computes the fingerprint of id and amount as canonical JSON. The same id and fingerprint gives False, a different fingerprint is a ValueError, and a new event returns True after inbox insert → fault() → total increase.
If an exception occurs in fault, neither the inbox nor the total must remain.
After saving, check with bash /opt/lab/checks/idem-webhook-lab/06-contract.sh.
Read the total on a separate connection
In /root/work/idem-webhook-lab/service.py, total(path) returns the integer amount of the row with id=1 in total.
Instead of the number of callback executions, check the business effect that actually remains in the DB.
After saving, check with bash /opt/lab/checks/idem-webhook-lab/07-contract.sh.
Handle a real webhook request
In /root/work/idem-webhook-lab/service.py, create_app(path, secret, clock) reads the raw body, X-Timestamp, and X-Signature at POST /webhook. A bad time format, time window, or signature failure is 401, a JSON parse failure is 400, and a ValueError from apply is 409. The normal case is 200 {accepted:True, duplicate:False on first processing}.
Verify the signature first and then read the JSON, and also return a duplicate as a normal acknowledgment response.
After saving, check with bash /opt/lab/checks/idem-webhook-lab/08-contract.sh.