> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# The Coupon That Applied Twice When the Customer Retried
- URL: https://debugly.dev/coupon-applied-twice-on-retry/
- Published: 2026-10-09T15:04:00.000Z
- Updated: 2026-10-10T13:59:26.000Z
- Description: The checkout request timed out but the server had already applied the discount, so the retry applied it again, and the order total went below the coupon's…
- Author: Rohit Bhadani
- Tags: Bug Hunt, WooCommerce, Payments

I started at the incident, because that is where the symptom landed, and walked it backwards link by link until the negative stopped looking innocent.

The non-idempotent discount has a single weak property, and here it is: the first request succeeded on the server and failed on the network, so the client believed it had failed and retried, and the retry was the duplicate, and the duplicate was the double discount.

This was WooCommerce with a percentage coupon, and the mechanics are the same for any discount applied without an idempotency check.

## The hypotheses that failed

The first hypothesis was the coupon configuration, and the configuration was checked, and the configuration was a twenty percent discount, and the twenty percent was correct, and the correct ruled out the configuration. The second hypothesis was the cart calculation, and the calculation was checked, and the calculation applied the percentage to the subtotal, and the subtotal was correct, and the correct ruled out the calculation. The third hypothesis was a stacking rule, and the stacking was checked, and the coupon was not stackable, and the not stackable ruled out the stacking.

The dead end was the clue, because the configuration, the calculation and the stacking were all correct, and the correct with the wrong total is the signature of a duplicate application, and the duplicate is not a rule failure, it is the rule applied twice, and the twice is the request, and the request is the retry, and the retry is the hypothesis nobody had, because the retry is invisible in the order record, which shows one order with two discount lines.

The two discount lines were the finding, and the finding was the duplicate, and the duplicate was the retry, and the retry was the timeout, and the timeout was the customer's experience, and the experience was the spinner, and the spinner was the wait, and the wait was the click, and the click was the second request.

## The breakthrough

The breakthrough was the access log, and the log showed two POST requests to the checkout endpoint, and the two were four seconds apart, and the apart was the retry, and the retry was the customer, and the customer was the timeout, and the timeout was the four seconds, and the four seconds was the first request's duration, and the duration was the server's work, and the work was the discount application, and the application was the success, and the success was the response, and the response was the lost packet, and the lost was the client's timeout, and the client's timeout was the retry.

So the first request succeeded and the client never knew, and the not knowing was the retry, and the retry was the second application, and the application was the idempotency's absence, and the absence was the bug, because the discount application had no key, and the key is the deduplication, and the deduplication is the fix, and the fix is the one that makes the second request a no-op.

This is the same shape as [the Shopify webhook retry that double-booked inventory](https://debugly.dev/shopify-webhook-retry-double-booked/), and the shared property is the one worth fixing.

## What I changed

**Added an idempotency key to the discount application.** The request carries a key, and the key is stored with the applied discount, and the stored is the check, and the check is the second request's rejection, and the rejection is the no-op, and the no-op is the fix, and the fix is the idempotency, and the idempotency is the retry's safety, and the safety is the prevention.

**Made the coupon application check for an existing application.** The code checks whether the coupon is already on the cart before applying, and the check is the guard, and the guard is the fix, and the fix is the one line, and the one line is the prevention, and the prevention is the discipline, because the apply should be conditional on the not-already-applied.

**Returned the cart state on every response.** The response includes the applied coupons, and the included is the client's knowledge, and the knowledge is the retry's avoidance, and the avoidance is the fix, and the fix is the state, and the state is the reconciliation, because the client can see what the server has and does not retry blindly.

**Reconciled the affected orders.** The orders with two discount lines were found by the query, and the found was the list, and the list was the correction, and the correction was the refund reversal, and the reversal was the finance's fix, and the fix was the money, and the money was the incident's cost, and the cost was the reason the idempotency matters.

## The takeaway

A discount applied without an idempotency key is applied again on every retry, and a retry happens whenever the client times out on a request the server already processed. Add an idempotency key, check for an existing application before applying, return the cart state on every response, and reconcile the affected orders.

The customer retried because the spinner never resolved, and the server had already applied the coupon on the first attempt, so the retry applied it a second time and the order total went negative. That is the whole pattern, and it is the part worth remembering.