Idempotent payment capture
Author: Tomas Berg · Date: 2026-09-08
Overview
Halden Labs' billing service sometimes charges a customer twice when a client retries a capture request after a network timeout. This spec adds idempotency keys to POST /v2/captures so a retried request returns the original result instead of creating a second charge.
Goals
- A retried capture with the same
Idempotency-Keynever creates a second charge. - Duplicate captures drop from about 40 a month to zero within one release.
- p95 latency of the capture endpoint rises by no more than 10 ms.
Non-goals
- Idempotency for refunds and payouts (follow-up spec).
- Changing the payment processor integration.
Approach
Store each key with a request hash and the response in a new idempotency_keys table, written in the same transaction as the capture. A unique constraint on the key makes concurrent retries safe without a distributed lock.
Detailed design
New middleware idempotency.ts in billing/http reads the header, hashes the body, and either replays a stored response or reserves the key. CaptureService.capture() takes the reservation and stores the response before commit. Keys expire after 24 hours via the existing cleanup job.
sequenceDiagram
participant C as Checkout client
participant A as Billing API
participant S as CaptureService
participant D as Postgres
C->>A: POST /v2/captures (Idempotency-Key)
A->>D: Insert key as reserved
alt key already completed
D-->>A: Stored response
A-->>C: 200 replayed
else new key
A->>S: capture()
S->>D: Insert charge and response in one transaction
S-->>A: Charge
A-->>C: 201 Created
end
A key reused with a different body returns 422 idempotency_mismatch. A key still reserved by an in-flight request returns 409 so the client retries later.
Sequencing
- Migration for
idempotency_keys(no reads yet). - Middleware behind flag
capture_idempotency, off. - Checkout clients start sending the header.
- Flag on at 10%, then 100%.
Milestones
| Milestone | Owner | Date |
|---|---|---|
| Migration and middleware merged | Tomas Berg | 2026-09-19 |
| Clients send the header | Hana Ito | 2026-09-26 |
| Flag at 100% | Tomas Berg | 2026-10-03 |
Testing strategy
Unit tests cover replay, mismatch and in-flight cases. An integration test fires 20 concurrent identical captures and asserts exactly one charge. It does not prove behavior under a Postgres failover; that is covered by the staging chaos run.
Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Table growth slows inserts | Low | Medium | 24 hour expiry, index on created_at |
| Old clients send no key | High | Low | Behavior unchanged without a key |
Open questions
Review
| Reviewer | Role | Status |
|---|---|---|
| Grace Okafor | Billing tech lead | Approved |
| Hana Ito | Checkout engineer | Approved with comments |
Review by: 2026-09-12
| Decision | Date | Decided by | Notes |
|---|---|---|---|
| Approved, keys scoped per merchant | 2026-09-11 | Grace Okafor | Hana to confirm client retry backoff |