# claude session 68969d63-3984-4bbf-a591-a0175931b122 ## User Customers are getting charged twice when Stripe retries the payment webhook. The incident log is logs/incident-2291.log. Fix it so a retry can never double charge. ### Tool call: `Bash` ``` { "command": "grep -c 'captured twice' logs/incident-2291.log", "description": "Count double captures" } ``` ### Tool result ``` 2 ``` ### Tool call: `Read` ``` { "file_path": "/home/dev/code/payments-api/app/webhooks.py" } ``` ### Tool result ``` 1 from app.db import db 2 from app.stripe import capture 3 4 5 def handle_payment_succeeded(event): 6 order = db.orders.get(event["data"]["order_id"]) 7 capture(order.payment_intent) 8 order.status = "paid" 9 db.orders.save(order) ``` ### Assistant (thinking) Both incidents: Stripe retried after 30s because the handler was slow, and the handler has no memory of events it already processed, so the retry captures again. Two layers needed: (1) skip events we've seen, keyed by Stripe's event id, (2) skip orders that are already paid. Checking order.status alone races if two deliveries run at once, so record the event id inside the same transaction. ### Tool call: `Edit` ``` { "file_path": "/home/dev/code/payments-api/app/webhooks.py", "old_string": "def handle_payment_succeeded(event):\n order = db.orders.get(event[\"data\"][\"order_id\"])\n capture(order.payment_intent)\n", "new_string": "def handle_payment_succeeded(event):\n # Stripe retries any delivery that takes over 30s; the same event can arrive twice.\n with db.transaction():\n if not db.processed_events.add(event[\"id\"]):\n return # already handled\n order = db.orders.get(event[\"data\"][\"order_id\"], for_update=True)\n if order.status == \"paid\":\n return\n capture(order.payment_intent)\n" } ``` ### Tool result ``` The file app/webhooks.py has been updated. ``` ### Tool call: `Write` ``` { "file_path": "/home/dev/code/payments-api/tests/test_webhooks.py", "content": "from app.webhooks import handle_payment_succeeded\n\n\ndef test_retry_does_not_capture_twice(fake_db, fake_stripe):\n event = {\"id\": \"evt_1\", \"data\": {\"order_id\": 1}}\n handle_payment_succeeded(event)\n handle_payment_succeeded(event)\n assert fake_stripe.captures == 1\n" } ``` ### Tool result ``` File created successfully at: tests/test_webhooks.py ``` ### Tool call: `Bash` ``` { "command": "pytest tests/test_webhooks.py -q", "description": "Run webhook tests" } ``` ### Tool result ``` 1 passed in 0.21s ``` ## Assistant Fixed. The handler now records Stripe's event id in a `processed_events` table inside the same transaction as the capture, and skips orders that are already paid. A retried delivery returns early. Added a test that delivers the same event twice and checks there is one capture. ### Tool call: `Bash` ``` { "command": "git commit -am \"Make payment webhook idempotent\"", "description": "Commit" } ``` ### Tool result ``` [main fa76cc9] Make payment webhook idempotent ``` ## User Also make this impossible at the database level, in case someone adds another code path. ### Tool call: `Write` ``` { "file_path": "/home/dev/code/payments-api/migrations/0007_processed_events.sql", "content": "CREATE TABLE processed_events (\n event_id text PRIMARY KEY,\n processed_at timestamptz NOT NULL DEFAULT now()\n);\n\n-- one successful capture per order, whatever calls it\nCREATE UNIQUE INDEX captures_one_per_order ON captures (order_id) WHERE status = 'succeeded';\n" } ``` ### Tool result ``` File created successfully at: migrations/0007_processed_events.sql ``` ## Assistant Added migration 0007: `processed_events` keyed by event id, and a partial unique index so an order can only ever have one succeeded capture. ### Tool call: `Bash` ``` { "command": "git commit -am \"Add unique constraints for webhook events and captures\"", "description": "Commit" } ```