> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shieldlabs.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Coupon Abuse

> Learn how to detect and prevent coupon abuse (coupon fraud): enforce one-per-customer codes and catch reuse of leaked or shared single-use discount codes.

A discount code says "one per customer." A leaked single-use code says "one redemption, ever." Both promises break at the same moment: the apply-code step in the cart. One shopper claims the same offer through duplicate accounts, and a single-use code posted to a coupon forum gets burned a hundred times before you notice. This tutorial sits on the redeem action and ties each apply-code to the durable device behind it, so your own code can hold the discount to its limit. It is the code-centric companion to [promo abuse](/use-case/promo-abuse), which gates the broader reward (the signup bonus, the trial reset) at registration and claim time.

## What is coupon abuse?

Coupon abuse, also called coupon fraud or discount code abuse, is the repeated redemption of a discount that was meant to be claimed once — one shopper applying a one-per-customer code through several accounts or browsers, many people redeeming a single-use code that leaked or was shared, or one buyer stacking codes across repeated attempts. The orders look like distinct customers, but the redemptions trace back to one machine or one network, or to a code that has already been spent.

## How ShieldLabs surfaces it

Your coupon code, cart, and order stay in your commerce stack. What ShieldLabs identifies is the **session** applying the code: the per-claim **RequestID**, the cookie-scoped **VisitorID**, your hashed **UserHID** if the shopper is signed in, and the durable, server-derived **DeviceID** that survives a cleared cookie, an incognito window, and a rotated IP. A shopper who clears cookies and switches accounts between redemptions reads as a brand-new customer to a cookie or public-IP check, but the DeviceID holds steady — so your code can tell that ten "different" buyers applying a code came off one machine. ShieldLabs also returns a per-request [Risk Score (0–100)](/features/risk-scoring); when the connection is masked with a VPN, proxy, Tor, or a privacy relay, the [anonymity signals](/features/anonymity-signals) fire and the real network IP (`local_ip`) can expose the network behind a rotated public `public_ip`. Over time the dashboard grades the relationship with the **Many Accounts on One Device** and **Many Accounts on One Local IP** [patterns](/features/patterns).

The split is the whole point: ShieldLabs tells you *who the session is and how hidden it is*; **your redemption code counts how many times this code (or any code) has been spent from one DeviceID and one `local_ip.ip`, and enforces your one-per-customer rule.** Neither half works alone — the identity makes the count durable, the count makes the identity actionable.

## Prevent coupon abuse

The rule your code applies, wired up in `## Build it` below: at the apply-code step, resolve the session to its **DeviceID**, read the **score** and `signals` for masking, then in your own store count how many times this exact code has been redeemed from that device or local network, and how many distinct accounts have spent any one-per-customer code off it. Honor the discount when the device is fresh and the session is clean; require verification or refuse the discount when the per-customer cap is crossed, when a single-use code is being applied a second time from any device, or when a masked session pushes the count past your tolerance. Weigh masked sessions heavier — a shopper hiding behind a VPN to look like a new buyer is exactly the case the score is for. ShieldLabs surfaces the session and the score; your code owns the limit and the verdict, so a forum-leaked code collapses to one device-and-network footprint and holds for review, while a genuine first-time shopper checks out clean.

## Build it

<Steps>
  <Step title="Create a ShieldLabs account and get your keys">
    [Sign up for free](https://app.shieldlabs.ai/) and get 5,000 identifications, or log in if you already have an account. Register the storefront domain you want to identify shoppers on, then open the [Keys](/setup/keys) page. Use the **Public Key** to initialize the snippet in the browser, and keep your server-side credentials on your backend: the **Private API Key** authenticates the [History API](/api/server-api), and each webhook endpoint has its own `whsec_…` signing secret. See [Keys](/setup/keys).
  </Step>

  <Step title="Identify the session at the apply-code step">
    Add the snippet to the cart or checkout page where the coupon is entered, and re-identify on the apply-code action itself so you score the session that is actually redeeming, not a stale page load. Use `checkAuthenticatedUser` with the shopper's hashed id when they are signed in, or `checkAnonymous` for guest checkout. Pass a hash, never a raw email.

    ```html cart.html theme={null}
    <script type="module">
      const mod = await import(
        'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'
      );
      // Signed-in shopper: pass the hashed account id, never a raw email.
      // Guest checkout: use checkAnonymous(undefined, (ip, requestID) => {...}) instead.
      mod.checkAuthenticatedUser('8a9f-hashed-account-id', (ip, requestID) => {
        document.getElementById('shield-request-id').value = requestID;
      });
    </script>

    <form method="POST" action="/api/apply-coupon">
      <input type="hidden" id="shield-request-id" name="shieldRequestId" />
      <input type="text" name="couponCode" placeholder="Coupon code" />
      <button type="submit">Apply code</button>
    </form>
    ```
  </Step>

  <Step title="Read the score and gate on masking">
    The score arrives on the [webhook](/setup/webhooks) — verify the `X-Shield-Signature` HMAC, then cache it by `request_id`. Your apply-coupon endpoint reads it back with the shared `waitForScore` helper from the [Use Case Tutorials](/use-case), or falls back to a [History API](/api/server-api) read by `request_id`. Validate the code with your own commerce checks first, then hold a masked session for verification before moving on to the redemption count.

    ```js api/apply-coupon.js theme={null}
    app.post('/api/apply-coupon', async (req, res) => {
      const { accountId, couponCode, shieldRequestId } = req.body;

      // 1. Your own commerce checks first: code exists, in its valid window,
      //    not already marked spent (for single-use codes), within cart rules.
      const coupon = await lookupCoupon(couponCode);
      if (!coupon || !coupon.active) {
        return res.status(409).json({ error: 'Coupon not valid' });
      }
      if (coupon.singleUse && coupon.spent) {
        // A single-use code applied again is reuse on its face — refuse outright.
        return res.status(409).json({ error: 'Coupon already redeemed' });
      }

      // 2. Look up the ShieldLabs result for this redeeming session.
      const shield  = await waitForScore(shieldRequestId, 2000);
      const score   = shield?.score ?? 0;       // 0-100, default 0 if not yet in
      const signals = shield?.signals ?? [];    // explainable: [{ name, weight }]

      // Use the stable detection_flags to tell WHICH signal fired — a 30 from one
      // signal is not a 30 from another. These masking signals are innocent in
      // isolation, so weigh them against the redemption count below.
      const flags  = shield?.detection_flags ?? {};
      const masked = flags.vpn || flags.proxy || flags.tor || flags.privacy_relay
        || flags.browser_vpn_proxy || flags.anti_detect_browser || flags.datacenter_ip;

      // 3. YOUR code owns the verdict. Branch on the band, not a label string.
      if (score >= 60 || masked) {
        // High band or strong masking on the session applying the code.
        // Hold the discount and require verification before honoring it.
        return res.status(200).json({ requireVerification: true, reason: 'anonymity' });
      }

      // Clean / Low / Medium: carry on to the redemption-count check.
      return countAndDecide(req, res, shield, coupon);
    });
    ```

    A high score is not a fraud verdict — a real shopper on a corporate VPN or a privacy browser can land in the High band. Branch on the score band and the named [`detection_flags`](/glossary#detection-flags), and treat masking as one input to combine with the count, never as proof on its own.
  </Step>

  <Step title="Count redemptions per device and local network">
    The score says whether one session looks masked. It does not say how many times this code, or any one-per-customer code, has already been spent from the device or the local network — and that count is what catches both a duplicate-account shopper and a leaked code making the rounds. The durable **DeviceID** is the anchor (it survives a cookie clear, incognito, and IP rotation), and the real network IP (`local_ip.ip`) catches a household or office sitting behind one router even when each session shows a fresh cookie and a rotated public IP. Read the per-identifier history live and count:

    ```bash Read a device's redemption history theme={null}
    curl "https://account.shieldlabs.ai/api/v1/history/device_id/5eb7fd5c-1c5e-4a9f-9b21-7d2e8c0a1234?limit=50" \
      -H "Authorization: Bearer sec_your_private_api_key"
    ```

    ```js Hold the discount to your per-customer limit theme={null}
    async function redemptionsFromDevice(deviceId) {
      const rows = await shieldHistory('device_id', deviceId, 100);
      // Your own store records which coupon each request_id redeemed. Join the
      // ShieldLabs history (sessions off this device) to your redemption log.
      return countYourRedemptions(rows.map((r) => r.request_id));
    }

    async function countAndDecide(req, res, shield, coupon) {
      const deviceId   = shield?.device_id;
      const localIp    = shield?.local_ip?.ip;

      // The all-zero DeviceID is "no device", not a clean new one. A JS-disabled
      // or blocked browser returns it with a score of 90 or higher — a shopper that
      // strips JS would dodge the device count, so hold on the score alone.
      const NIL = '00000000-0000-0000-0000-000000000000';
      if (!deviceId || deviceId === NIL) {
        return res.status(200).json({ requireVerification: true, reason: 'no_device' });
      }

      // Count this code's redemptions off this one machine and local network.
      const fromDevice = await redemptionsFromDevice(deviceId);
      const fromLocal  = localIp ? await redemptionsFromLocalIp(localIp, coupon.code) : 0;

      // YOUR_PER_CUSTOMER_LIMIT is your policy (usually 1 for a one-per-customer
      // code). A single-use code already spent was refused upstream in step 3.
      if (fromDevice >= YOUR_PER_CUSTOMER_LIMIT || fromLocal >= YOUR_PER_NETWORK_LIMIT) {
        return res.status(200).json({ requireVerification: true, reason: 'code_already_redeemed_here' });
      }

      // Clear: honor the discount and record the redemption against this device.
      return honorDiscount(req, res, { deviceId, requestId: shield.request_id });
    }
    ```

    <Note>
      History reads through `account.shieldlabs.ai` do not consume request balance. For busy checkouts, keep your own redemption-per-device counter (incremented when you honor a discount) as the fast path and reserve live `device_id` / `local_ip.ip` reads for borderline carts.
    </Note>

    A shopper using two genuinely separate browsers shows up as two DeviceIDs, since the DeviceID is browser-bound. The local-IP count closes that gap: the same code applied repeatedly through one `local_ip.ip` is a strong shape even when each session reports a different device. Weigh both alongside your own per-code redemption caps.
  </Step>

  <Step title="Corroborate with the dashboard patterns">
    The live counts decide the cart in the moment. Over time, two dashboard [Patterns](/features/patterns) grade the same relationship **Suspicious**, then **Dangerous**, as it crosses a threshold in a rolling window — useful for building a denylist and for catching slow, patient abuse the per-cart count misses:

    * **Many Accounts on One Device** — one DeviceID tied to many accounts, the duplicate-account coupon shape.
    * **Many Accounts on One Local IP** — many accounts redeeming through one local network (`local_ip.ip`), the leaked-code-shared-around-a-network shape.

    Read these on the [dashboard Patterns tab](/features/patterns) and export the flagged devices and local IPs as a denylist your apply-coupon endpoint checks before it ever reaches the live count.
  </Step>

  <Step title="Tune to your offers">
    A one-per-customer welcome code wants a hard limit of 1; a stackable site-wide sale tolerates more. Your commerce rules own stacking policy — which codes combine in a cart — while ShieldLabs ties a burst of apply-code attempts back to one DeviceID, so a shopper stacking codes from one machine still surfaces. Start in logging-only mode, watch how real redemptions distribute across devices and networks, then set the per-device and per-network caps that match each campaign.
  </Step>
</Steps>

## Test it

You do not need a real abuse ring to see this work. Apply a one-per-customer code once in your normal browser and note the `device_id` on the webhook. Then play the abuser: clear cookies, open an incognito window, or switch to a second browser profile, sign in as a different account, and apply the same code again. The `cookie_id` and `visitor_id` change every time, but the **same `device_id` returns**, and your redemption count off that device climbs with each attempt — exactly the count your handler holds the discount on. Toggle a VPN and the anonymity signals light up on the redeeming session without changing the DeviceID, so a masked retry reads as the same machine, more hidden.

## Recommended starting thresholds

The four bands are defined in [Risk Scoring](/features/risk-scoring), and the per-band playbook lives in [Acting on the Risk Score](/guides/acting-on-risk-score). Mapped to the apply-code gate, with your redemption count layered on top:

| Signal at apply-code                                                    | Suggested action                                 |
| ----------------------------------------------------------------------- | ------------------------------------------------ |
| **Clean / Low** score, redemption count under your cap                  | Honor the discount                               |
| **Medium** score, under your cap                                        | Honor, but log and watch the device              |
| **High** score                                                          | Require verification before honoring             |
| Per-device or per-network redemption count at your cap                  | Refuse the discount, regardless of session score |
| Single-use code applied a second time (any device)                      | Refuse outright                                  |
| Device or local IP flagged **Suspicious** / **Dangerous**               | Require verification, route to review            |
| All-zero / missing DeviceID (snippet blocked or JS disabled, score 90+) | Require verification before honoring             |

Where you draw each line is yours. For the broader reward-time gate that joins accounts at signup and claim time, layer this on top of [promo abuse](/use-case/promo-abuse); to chase the duplicate accounts themselves rather than the codes they spend, see [multi-accounting](/use-case/multi-accounting).

<Card title="Next: Acting on the Risk Score" icon="arrow-right" href="/guides/acting-on-risk-score">
  The full per-band decision playbook, including signal-aware decisioning and how to combine the score with specific signals.
</Card>
