System Design: Coupon and Promo Code System — Validation, Redemption, and Abuse Prevention

Core Requirements

A coupon system allows businesses to issue discount codes that customers apply at checkout. Types: percentage discount (20% off), fixed amount ($10 off), free shipping, buy-one-get-one (BOGO). Constraints: one-time use codes (each code works once for any customer), per-customer limits (use at most once per account), minimum order value, expiration dates, product/category restrictions. At scale: Black Friday sees millions of coupon validations in minutes — the system must handle burst validation traffic without double-redemptions.

Data Model

CouponCampaign: campaign_id, name, discount_type (PERCENT, FIXED, FREE_SHIPPING), discount_value, min_order_value, max_discount_cap, start_date, end_date, total_redemption_limit (nullable), per_customer_limit, applicable_categories (JSON), status (ACTIVE, PAUSED, EXPIRED). CouponCode: code (PK, unique string), campaign_id, code_type (UNIVERSAL=one code for all customers, UNIQUE=one-time single-use), is_used (for unique codes), used_by_user_id, used_at. Redemption: redemption_id, code, user_id, order_id, discount_applied, redeemed_at.

Validation and Redemption

Validation is read-heavy; redemption is write-critical (must be atomic). Validation checks: (1) Code exists and belongs to an active campaign. (2) Campaign is within start_date and end_date. (3) Order total >= min_order_value. (4) User has not exceeded per_customer_limit: SELECT COUNT(*) FROM redemptions WHERE code=:c AND user_id=:u < per_customer_limit. (5) Campaign total redemptions not exceeded: SELECT COUNT(*) FROM redemptions WHERE campaign_id=:id < total_redemption_limit. (6) For UNIQUE codes: is_used = FALSE. Redemption (at order confirmation): must be atomic to prevent double-use:

def redeem_coupon(code, user_id, order_id, discount):
    with db.transaction():
        coup = db.query("SELECT * FROM coupon_codes WHERE code=%s FOR UPDATE", code)
        # Re-validate inside transaction
        if coup.code_type == "UNIQUE" and coup.is_used:
            raise CouponAlreadyUsedError()
        count = db.query(
            "SELECT COUNT(*) FROM redemptions WHERE code=%s AND user_id=%s",
            code, user_id
        )
        if count >= coup.campaign.per_customer_limit:
            raise LimitExceededError()
        db.execute("INSERT INTO redemptions VALUES (%s,%s,%s,%s,%s,NOW())",
                   uuid4(), code, user_id, order_id, discount)
        if coup.code_type == "UNIQUE":
            db.execute("UPDATE coupon_codes SET is_used=TRUE, used_by=%s WHERE code=%s",
                       user_id, code)

Bulk Code Generation

For campaigns needing thousands of unique codes (loyalty rewards, promotional mailers): generate offline in bulk. Algorithm: generate random 8-character alphanumeric codes (exclude confusable chars: 0/O, 1/I/l). Batch insert into coupon_codes with a unique index — retry any collisions. For 1 million codes: generate in batches of 10,000, insert with ON CONFLICT DO NOTHING, track how many were actually inserted (some may collide), re-generate the missing ones. Total collisions at 1M codes from a space of 36^8 = 2.8 trillion: negligibly small. Store codes in a database with an index on (code). Caching: cache active campaign metadata in Redis — avoid a DB lookup on every validation request. Invalidate on campaign updates.

Abuse Prevention

Coupon abuse patterns: (1) Multi-accounting: user creates multiple accounts to reuse a per-customer code. Mitigation: fingerprint by device ID, payment method, shipping address. Flag accounts with the same fingerprint. (2) Code sharing: unique codes leaked publicly and used by unintended recipients. Mitigation: issue codes via personalized delivery (email to the specific user) rather than broadcasting. Bind codes to the recipient’s email at generation time (validate that user’s email matches the code’s recipient). (3) Bulk redemption attacks: bots try thousands of code combinations. Mitigation: rate limit validation attempts per IP and per user (max 10 failed validations per minute). Add CAPTCHA after 3 failed attempts. (4) Order manipulation: user applies coupon, then edits the cart to reduce it below min_order_value. Mitigation: re-validate coupon at payment, not just at cart stage.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you prevent race conditions when two users try to redeem the same single-use coupon simultaneously?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Use SELECT FOR UPDATE inside a database transaction. When the redemption attempt starts, lock the coupon_codes row: SELECT * FROM coupon_codes WHERE code=:c FOR UPDATE. This acquires an exclusive row lock — any concurrent transaction trying to lock the same row will block until the first transaction completes. Inside the locked transaction: re-validate the coupon (is_used = FALSE, limits not exceeded), insert the redemption record, and update is_used = TRUE. If two requests arrive simultaneously, the second will block at SELECT FOR UPDATE until the first commits; it then sees is_used = TRUE and raises CouponAlreadyUsedError. This is the standard pattern for any “check-then-act” operation that must be atomic: lock first, re-validate inside the lock, then act.”
}
},
{
“@type”: “Question”,
“name”: “How would you design a system to generate and distribute 10 million unique coupon codes for a marketing campaign?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Offline bulk generation: generate random 8-character alphanumeric codes (base-32 alphabet excluding confusable chars: 0/O/1/I/l reduces charset to ~28 characters). Space size: 28^8 u2248 377 billion — collision probability at 10M codes is negligible (birthday problem: ~10M^2 / (2 * 377B) u2248 0.01%). Generation algorithm: generate in batches of 100,000, INSERT … ON CONFLICT DO NOTHING, count inserted rows, re-generate the gap. Total expected iterations: 1-2 passes for 10M codes. Storage: 10M rows * ~40 bytes per code row u2248 400MB — fits in a standard database table with a unique index on code. Distribution: export to a CSV, upload to the email marketing platform (Mailchimp, Klaviyo), distribute one code per recipient. Bind each code to recipient_email at generation time — validate at redemption that the authenticated user’s email matches.”
}
},
{
“@type”: “Question”,
“name”: “What is the difference between a universal coupon code and a unique coupon code?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Universal code: one code string usable by any customer (e.g., SUMMER20). All customers sharing the code see the same discount. Per-customer limits apply: each account can use it at most N times, and there may be a total campaign redemption cap. These are easy to distribute (post on social media, print in ads) but also easy to share beyond the intended audience. Unique code: one code string assigned to one specific customer, valid for a single redemption. Generated in bulk, distributed individually (via email, loyalty reward). Cannot be reused after the first redemption. Harder to abuse: sharing the code means the original recipient can no longer use it. Use cases: universal for broad promotions and public discount events; unique for loyalty rewards, referral programs, and personalized offers where you need to track which customer earned which code.”
}
},
{
“@type”: “Question”,
“name”: “How do you handle coupon abuse via multi-account fraud?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Multi-accounting is using multiple email addresses to apply a per-customer-limit coupon repeatedly. Defense layers: (1) Device fingerprinting: track device_id (mobile) or browser fingerprint (web). Flag accounts sharing a device_id that have redeemed the same coupon. Block or require manual review for high-confidence duplicates. (2) Payment method fingerprinting: the same credit card number (or payment token) used across multiple accounts is a strong abuse signal. Stripe and Braintree expose a fingerprint (hash of the card number) without exposing the PAN — store and compare this. (3) Shipping address normalization: normalize addresses (street abbreviations, apartment vs apt) and cluster. Multiple accounts sharing a normalized address = likely the same household. (4) Velocity checks: if 5+ accounts from the same IP redeemed a coupon in one hour, flag for review. Apply progressive friction (CAPTCHA, ID verification) rather than hard blocks to avoid false positives.”
}
},
{
“@type”: “Question”,
“name”: “How do you implement a minimum order value check efficiently?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “The minimum order value is checked during coupon validation, not at redemption time. At validation: SELECT min_order_value FROM coupon_campaigns WHERE campaign_id = :id. Compare against the current cart subtotal (sum of item prices * quantities). If cart_subtotal < min_order_value: return an error with the shortfall amount ("Add $X more to qualify"). This check is cheap: one campaign lookup (cacheable in Redis for minutes). Do not cache the cart total — always compute from live CartItems to prevent stale data. At checkout re-validation (final step): re-run the check against the confirmed order total. This matters because cart contents can change between validation and checkout — an item removed might drop the total below the minimum. Always re-validate at payment, not just at cart stage."
}
}
]
}

Asked at: Shopify Interview Guide

Asked at: Stripe Interview Guide

Asked at: Airbnb Interview Guide

Asked at: DoorDash Interview Guide

Scroll to Top