How to prevent duplicate prize awards in instant-win campaigns
The last smartphone has been won twice. Both winners have a screenshot. This guide is about the handful of places where that happens, what each one looks like in practice, and what has to be true of the backend to rule it out. It uses LuckLogic's behaviour as the worked example, but the problems are universal.
Why normal CRUD logic is not enough
The obvious implementation of a redemption reads like this:
code = SELECT * FROM codes WHERE value = ? -- 1. look it up if code.redeemed: return ALREADY_USED -- 2. check prize = SELECT * FROM prizes WHERE left > 0 LIMIT 1 UPDATE codes SET redeemed = true WHERE id = ? -- 3. write UPDATE prizes SET left = left - 1 WHERE id = ? return WIN
Every line is correct on its own. The flaw is between the lines: another request can run steps 1 and 2
after this one did and before this one reaches step 3. Both see an unused code and a prize with one left.
Both write. The code is redeemed twice and the prize count goes to minus one, or worse, the check on
left > 0 passes for both and two claims exist for one prize.
This is a read-then-write race, and it does not show up in testing because tests send one request at a time. It shows up at 09:00 on launch day.
Five ways the same prize gets awarded twice
| Scenario | What happens |
|---|---|
| Two entries, same code | A consumer double-taps submit, or has the form open in two tabs. Two requests with the same code arrive milliseconds apart. |
| Two entries, last prize | Two different consumers with two valid codes are processed at the instant the only remaining prize becomes available. |
| A retried request | The first request succeeded but the response was lost to a timeout. The backend retries, and the retry is treated as a brand-new entry. |
| A participant over the limit | Ten requests for one participant arrive together. Each reads the count as two, under the limit of three, and all ten go through. |
| A webhook delivered twice | The receiver was slow to answer, so the delivery was retried. The fulfilment system ships the prize twice. |
Single-use codes
The code row has to be locked for the duration of the redemption, not just read. The second request for the same code must wait for the first to finish and then see the result of it. Behind the lock sits a database uniqueness constraint on the redemption, so even a bug in the locking cannot produce two redemptions of one code; the second insert fails.
In LuckLogic the second request gets 400 CODE_ALREADY_REDEEMED with the
original redeemed_at in the details, so your frontend can say "this code was
used at 09:14" rather than "something went wrong". The lock wait is bounded: a request that cannot get the
lock within two seconds gets 503 RETRY_LATER rather than hanging.
Atomic prize allocation
The prize must be taken, not counted. Decrementing a counter is a read-then-write in disguise. What works is a row per prize instance, which LuckLogic has in the form of one Winning Moment per prize, and an operation that claims one row exclusively or finds none:
- Select the oldest open moment whose time has passed, lock it, and skip any row another transaction already holds.
- If a row came back, this entry wins it. Write the redemption and the claim, and mark the moment claimed, in the same transaction.
- If nothing came back, this entry loses. The code is still consumed.
Two requests racing for the last prize therefore cannot both get it: the second one skips the locked row and finds nothing. The prize count is never a number that gets decremented; it is the count of rows that are claimed, which cannot be wrong.
Database transactions and lock order
Everything above has to happen in one transaction: code, limit counter, prize, redemption, claim. If the claim insert fails, the prize is not claimed and the code is not consumed. Partial writes are how a prize ends up allocated with no claim, which is a duplicate award waiting to happen when someone "fixes" it by hand.
Taking several locks in one transaction introduces the deadlock problem: request A holds the code and wants the counter, request B holds the counter and wants the code. The cure is a fixed order. LuckLogic always locks the code, then the rate-limit counter, then the moment, and the counters themselves in sorted order, so no two transactions can wait on each other crosswise. The one clock consulted for "has this moment opened" is the database's own, so two API servers with slightly different clocks cannot disagree.
Idempotency and request retries
Retries are not a race on the code. The first request consumed the code and may have won. The retry, if it is treated as new, correctly gets "already redeemed", and the consumer who actually won sees an error. Worse, if the retry carries a different code, the backend has quietly entered the consumer twice.
The fix is an Idempotency-Key: a unique value your backend generates per
attempt and reuses on every retry of that attempt. The server stores the response under the key and
replays it on a repeat. The same key with a different body is refused with
409 IDEMPOTENCY_KEY_REUSED, which catches the bug where a key is reused across
different consumers.
const key = randomUUID(); // generate once for (let attempt = 0; attempt < 3; attempt++) { try { return await redeem({ code, participantId }, { idempotencyKey: key }); // same key each time } catch (err) { if (!isTimeout(err)) throw err; } }
Keep the two concepts apart. Code uniqueness means a code redeems once, for anyone, ever. Request idempotency means one logical request produces one result however many times it is sent. The first is enforced with a lock and a constraint. The second is a stored response. A backend needs both; neither substitutes for the other.
Participant limits
"Three entries per person per day" is a counter, and counters race exactly like prize counts. The
increment has to be conditional and inside the same transaction as the redemption: add one to this
participant's count for this window only if it is still under the limit, and treat "no row
updated" as the limit being hit. Ten parallel requests then get exactly three successes and seven
429 RATE_LIMIT_EXCEEDED responses with a
Retry-After.
The identity used for the count matters too. LuckLogic counts on
participant_id, an opaque reference you choose, falling back to the IP address
when there is none, and refuses the request with PARTICIPANT_REQUIRED
if the campaign has a limit and the request has neither. A limit nobody can be identified against is not a
limit.
Webhook retries versus redemption retries
Webhooks are delivered at least once, on purpose. A delivery that does not get a 2xx within ten seconds is
retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. That means your receiver will, sooner
or later, see the same prize.claimed event twice, and it must not ship two
prizes.
- Verify the signature first, and reject deliveries whose timestamp is more than a few minutes old, so a replayed capture cannot trigger anything.
-
Use the claim token as the idempotency key on your side: "voucher already issued for
clm_…" is a no-op, not an error. - Answer 2xx quickly and do the work afterwards. A slow receiver is the most common cause of duplicate deliveries.
Redemption retries and webhook retries are different problems with the same shape: the sender cannot know whether the receiver acted, so the receiver has to make acting twice harmless.
Audit records
When a dispute arrives, the question is never "is the code valid" but "what exactly happened at 09:14". The system has to be able to answer from records written at the time, not reconstructed. LuckLogic keeps:
- an entry log of every attempt, including losses, unknown codes, reused codes and rate-limited requests, with the outcome, reason, participant, request id and latency, for 12 months;
- on every Winning Moment, the redemption that claimed it and when, so each prize traces to exactly one entry;
- an append-only claim history: created, auto-approved, approved, rejected, collected, fulfilled, expired, each with the actor;
- a change log for live production campaigns: who changed the dates or a prize quantity, when, why, and the before and after.
A duplicate award is, in the end, a reconciliation failure: a tier where won is greater than quantity. The records above are what make that visible, and what let you prove it did not happen.
See the Redemption API
Every behaviour in this guide is observable from the outside: send the same code twice, send ten requests for one participant, retry with the same key. The API reference documents each response.