A duplicate webhook should not reach your business rules at all. If one does, your usage limit will reject your own retry, and it will be correct to do that.
The failure is a quiet one. A promo code carries a per-customer limit of one. The payment succeeds, the redemption gets recorded, and then the payment provider redelivers the webhook, as providers do. The second call runs validation, validation sees one prior use by this customer, and the code is refused with customer_limit_reached. Nothing is broken. Every component did its job. Your customer still gets an error on a discount they already earned.
Same story with a validity window. The payment landed while the code was live, the redelivery lands after it closed, and the rule answers expired, which is a correct answer to the wrong question.
What fixes this is ordering, not more rules. In our open-source promo code library, redeem does the type checks first: a non-string idempotency key is a TypeError, an empty string is a ValueError, and the timestamp is settled from the caller or from current UTC. Then, before anything else, it asks the store whether this key has been seen. A stored reservation short-circuits the whole retry loop. The discount gets recomputed from the code, and a result comes back carrying the stored usage and a replayed flag. Validation does not run, and neither does the write. The rules never learn that a second delivery happened, because as far as the business is concerned, it didn't.
An unknown key returns nothing and the ordinary path proceeds: read usage, validate, then a compare-and-set write retried a bounded number of times. And the write itself still answers a known key with the replayed flag, which is the backstop for two deliveries that both miss the lookup and race into the same insert.
The principle generalises well beyond promo codes. Any rule that counts, expires, or rate-limits is a rule that can refuse a retry, and a retry is not a second request. Deduplication has to sit upstream of policy rather than inside it. Put it downstream and every correctness rule you add becomes a new way for at-least-once delivery to fail your customers.
Somewhere in your stack, a retry is probably still being treated as a fresh attempt.