TT Lab
Get started
Learn Learning paths Courses

Idempotency — Two Clicks, One Charge

A validly signed webhook arrived twice

Continue in TT Lab

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

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

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

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

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

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

  6. In /root/work/idem-webhook-lab/service.py, total(path) returns the integer amount of the row with id=1 in total.

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

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.