One click, two copies · slopware 101

slopware 101 · step 25 of 35

One click, two copies

by · 4 min read

A busy app does more than handle larger amounts of data. It also meets repeated requests: someone clicks again while waiting, or a system repeats a message because the first answer did not arrive. We touched on this uncertainty when saving a note. Now we need to prevent a repeated attempt from creating a second result.

A webhook makes the issue especially clear. Another system calls an endpoint of yours when something happens on its side, such as a payment succeeding. That endpoint must be reachable by the provider, and it may receive the same message more than once.

A timeout is not an answer

A request goes out and nothing comes back. Not an error, not a receipt, only a timeout, and a timeout does not tell you whether the work happened. The server may have taken the money and died before it could say so. From where the client sits, a charge that went through but lost its answer looks exactly like a charge that never ran.

The client may need to retry, but a state-changing request needs a way to make that retry safe. The user does the same with their thumb when the button stays grey. Payment providers also resend webhooks they are not sure you received, and Stripe's documentation says your endpoint may get the same event more than once. You do not get to decide how many times a message arrives. You only get to decide how many times it counts.

Check-then-insert is a race

The first instinct is to check: look up the order, and if it is not there, create it. Now picture two requests forty milliseconds apart. Both look, both see nothing, and both create. Looking more carefully does not help, because the looking is the gap.

What helps is letting the database decide. One key identifies one logical operation across its retries. Scope it to the authenticated account and bind it to the validated parameters; the table storing that identity has a unique constraint. You insert first. The second insert fails inside the database, which is the only place that sees both requests at once, and you read the existing state rather than starting the same work again. A conflict on an in-progress attempt is not a completed result.

The key needs three states, not two: absent, in progress and finished. A client that retries after two hundred milliseconds lands in the middle state. The right answer there is that the work is still running and the client should ask again later, not that a second attempt should start.

For webhooks, the provider hands you the key: the event id. Store it in a processed_events table with a unique constraint, inside the same database transaction as the change the event causes. Either both commit or neither does. Also guard the business effect, such as fulfilment for one payment, because two distinct provider events can concern the same payment.

Where the database stops

That transaction protects your tables. It does not reach the payment provider. An API call to an outside service is a side effect your database cannot roll back, so local uniqueness on its own cannot stop a double charge if your code calls the provider twice.

Before reading on, think through this sequence. You insert a payment row, call the provider, the call times out, and your code retries. What stops the second call from charging again?

The pattern that holds has four parts.

Record intent before acting. Insert a payment_attempts row with your own key, the order, the amount, the currency and the status pending. Commit it.

Make the provider call idempotent. Stripe's API v1 accepts an Idempotency-Key header for these requests; check the API version and operation you integrate. When the same key arrives again, Stripe returns the saved result of the first request instead of running it again, even when that first result was an error such as a 500. Stripe also compares the parameters and rejects a reused key that comes with different ones. So bind each key to one operation with fixed parameters: this attempt, this amount, this currency. A different amount is a different attempt and gets a new key.

Retry with the same key. Stripe documents that keys may be removed once they are at least 24 hours old. The key protects retries within that period, so it is not a permanent record. Keep your own attempt history for the lifetime your workflow requires. After the provider's key-retention window, reconcile the earlier attempt before issuing a fresh request that could repeat the charge.

Reconcile. Some attempts will stay pending because a response never came. A scheduled job asks the provider what happened to each of them and moves it to succeeded or failed. Give failures an error state a person can see, instead of retrying forever.

Deciding what counts as the same

Use test mode to send the same logical operation twice, then send two operations that happen to have the same amount. The first should reuse its outcome; the second should remain distinct. Finally, simulate losing the first response after the provider accepted it. Can the application recover the result without creating another payment? These cases test the identity you chose, not merely whether a unique column exists.

References