> ## 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.

# Promo Abuse

> Learn how to detect and prevent promo abuse: count promo-code redemptions tied to one device to stop coupon and signup-bonus farming.

Signup farms exist for one payoff: the reward. The same person spins up fresh accounts to claim a signup bonus, burn through a coupon code, or restart a free trial. The catch happens not when the account is born but when it reaches for the reward, so this tutorial lives at the redemption endpoint. Joining accounts at registration is a separate job the [signup tutorial](/use-case/new-account-fraud) covers — wire that up for the create-account moment and treat this page as the reward-time gate on top of it.

## What is promo abuse?

Promo abuse is when one person creates many accounts to claim a reward that is meant once per customer — a signup bonus, a first-order coupon, referral credit, or a free trial reset. The accounts look like different customers, but they trace back to the same person behind one machine or one network.

## How ShieldLabs surfaces it

ShieldLabs resolves each redemption to a set of [identifiers](/features/identification): the per-claim **RequestID**, the cookie-scoped **VisitorID**, your hashed **UserHID** for the account, and the durable, server-derived **DeviceID** that survives a cookie clear, incognito, and IP rotation. A farm clears cookies and goes incognito between accounts to look new every time, so counting cookies or public IPs lets it right through — the DeviceID is what holds steady and lets your code see how many "different" customers actually share one machine or local network. ShieldLabs also returns a per-request [Risk Score (0–100)](/features/risk-scoring): when a person masks the connection with a VPN, proxy, or Tor, the [anonymity signals](/features/anonymity-signals) fire, and the real network IP (`local_ip`) can reveal the network behind the public `public_ip` they rotate. The dashboard grades the relationship over time with the **Many Accounts on One Device** and **Many Accounts on One Local IP** [patterns](/features/patterns); your redemption code enforces your per-customer policy.

ShieldLabs gives you four things at each redemption, and your code decides what to do with them:

| Layer                   | What it answers                                                                | Where you read it                                                                          | Latency               |
| ----------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | --------------------- |
| **Identification**      | "Is this the same device, even after cleared cookies, incognito, or a new IP?" | The durable `device_id` on the [webhook](/setup/webhooks) / [History API](/api/server-api) | About a second        |
| **Anonymity detection** | "Is this redemption masked or anonymous right now?"                            | The `signals` array on the [webhook](/setup/webhooks) / [History API](/api/server-api)     | About a second        |
| **Risk Score**          | "How risky is the visit overall, as one 0-100 number?"                         | `risk_score` on the [webhook](/setup/webhooks) / [History API](/api/server-api)            | About a second        |
| **Patterns**            | "How many accounts already cluster on this device or local IP?"                | [Dashboard Patterns](/features/patterns) + export                                          | Background (\~10 min) |

The signup and redemption stages answer two different questions, and you want both. Score at signup to thin the farm early (the patient farm creates accounts slowly, each clean on its own); then check again at redemption — ten "different" customers redeeming the same coupon from one machine is a shape no single clean signup ever shows.

## Gate the redemption

The rule your code applies, wired up in `## Build it` below: read the session `score` and its `signals` breakdown for masking, and read the durable **DeviceID** plus the real network IP (`local_ip.ip`) to count how many distinct **UserHID** accounts already claimed off that one machine or local network. Grant when the score is low and the device is fresh; require verification when the session is masked, when the device or local IP is flagged **Suspicious** or **Dangerous**, or when the account count crosses your per-customer cap. The outcome: a farm clearing cookies and rotating VPN exits between accounts collapses to one DeviceID and one `local_ip`, so the reward holds for review before it is granted, while a genuine first-time customer passes.

## 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 domain you want to identify visitors 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="Wire the signup gate first">
    Join accounts to the DeviceID at registration with the [signup tutorial](/use-case/new-account-fraud), so the farm is already thinned before it reaches the reward.
  </Step>

  <Step title="Score the redemption session">
    Add the snippet to the page where the reward is claimed (the cart with the coupon applied, the "start trial" screen, the bonus-claim button). Re-identify on the action itself so you score the session that is redeeming, not a stale page load. Pass the account's hashed id, never a raw email.

    ```html redeem.html theme={null}
    <script type="module">
      const mod = await import(
        'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'
      );
      // Pass the hashed account id, never a raw email or user id.
      // The callback hands you the requestID to correlate with the score.
      mod.checkAuthenticatedUser('8a9f-hashed-account-id', (ip, requestID) => {
        document.getElementById('shield-request-id').value = requestID;
      });
    </script>

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

  <Step title="Read the score, gate on masking">
    The score arrives on the [webhook](/setup/webhooks) — verify the `X-Shield-Signature` HMAC, then cache it by `request_id`. Your 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`. Hold a masked session for verification here, then carry on to the account-count check in the next step.

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

      // 1. Your normal redemption checks first (code valid, not already used by
      //    this account, within campaign window).
      if (!(await couponIsRedeemable(couponCode, accountId))) {
        return res.status(409).json({ error: 'Coupon not redeemable' });
      }

      // 2. Look up the ShieldLabs Risk Score for this session.
      const shield = await waitForScore(shieldRequestId, 2000);
      const score = shield?.score ?? 0;        // 0-100, default to 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 device account-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, never on a label string.
      if (score >= 60 || masked) {
        // High band: strong anonymity signals on the redeeming session.
        // Hold the reward and require verification before granting it.
        return res.status(200).json({ requireVerification: true, reason: 'anonymity' });
      }

      // Clean / Low / Medium: carry on to the account-count check next.
      return grantOrGate(req, res, shield);
    });
    ```

    A high score is not a fraud verdict — a real customer on a corporate proxy, VPN, or privacy browser can land in the High band. Branch on the score band and the named [`detection_flags`](/glossary#detection-flags), never on a raw label string; treat masking as one input, not proof.
  </Step>

  <Step title="Count the accounts behind the device">
    The score tells you whether this one session looks masked. It does not tell you how many accounts sit behind the device — and that count off the durable DeviceID is what gives the farm away. Two dashboard [Patterns](/features/patterns) grade exactly that relationship, **Suspicious** then **Dangerous**, as the count crosses a threshold in a rolling window:

    * **Many Accounts on One Device** — one DeviceID linked to many accounts, the classic bonus-farm shape.
    * **Many Accounts on One Local IP** — many accounts redeeming through one local IP (the real network IP, `local_ip.ip`), even when each session shows a fresh cookie and a rotated public IP. Catches a farm sitting behind one router or NAT.

    Read these on the [dashboard Patterns tab](/features/patterns) and export the flagged entities as a denylist. For the reward decision you often want the count live, not on the next pattern run, so read the [History API](/api/server-api) by `device_id` (or by `ip` for the `local_ip` value) and count distinct `user_hid`:

    ```bash Read a device's 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 Gate the reward on account count theme={null}
    // Prefer the pre-computed pattern export as a fast denylist, and reserve live
    // device_id reads for the borderline redemptions that are worth the cost.
    const flaggedDevices = await loadFlaggedDeviceIds(); // from dashboard export

    async function accountsBehindDevice(deviceId) {
      const rows = await shieldHistory('device_id', deviceId, 100);
      // Distinct hashed accounts seen on this one machine.
      return new Set(rows.map((r) => r.user_hid).filter(Boolean)).size;
    }

    async function grantOrGate(req, res, shield) {
      const deviceId = shield?.device_id;

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

      // Known farm device from the pattern export: hold the reward.
      if (flaggedDevices.has(deviceId)) {
        return res.status(200).json({ requireVerification: true, reason: 'device_linked_to_many_accounts' });
      }

      // Live count for this redemption. YOUR_ACCOUNT_LIMIT is your policy.
      if ((await accountsBehindDevice(deviceId)) >= YOUR_ACCOUNT_LIMIT) {
        return res.status(200).json({ requireVerification: true, reason: 'reward_already_claimed_on_device' });
      }

      // Clear: grant the reward.
      return grantReward(req, res);
    }
    ```

    <Note>
      History reads through `account.shieldlabs.ai` do not consume request balance. For high-volume redemption flows, lean on the pattern export as your denylist and reserve live `device_id` reads for the rewards that are expensive to give away by mistake.
    </Note>

    A determined person using several genuinely separate browsers shows up as several devices, since the DeviceID is browser-bound. The local-IP pattern closes that gap: ten accounts claiming through one `local_ip` is a strong shape even when each reports a different device. Weigh both patterns alongside your own per-code or per-campaign redemption caps.
  </Step>

  <Step title="Decide and tune">
    Grant, verify, or deny in your own code, per the policy table below. Start in logging-only mode, watch how real redemptions distribute, then raise friction where the data justifies it.
  </Step>
</Steps>

## Test it

You do not need a real farm to see this work. Claim the reward once in your normal browser and note the `device_id` on the webhook. Then play the farm: clear cookies, open a private/incognito window, or switch to a second browser profile, and redeem again as a different account. The `cookie_id` and `visitor_id` change each time, but the **same `device_id` returns**, and the distinct-`user_hid` count off that device climbs with each run — exactly the count your handler gates on. Switching networks or toggling a VPN should also light up the anonymity signals on the redeeming session without changing the DeviceID.

## 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 a reward gate, with the account count layered on top:

| Signal at redemption                                                    | Suggested reward action                               |
| ----------------------------------------------------------------------- | ----------------------------------------------------- |
| **Clean / Low** score, no pattern flag                                  | Grant the reward                                      |
| **Medium** score, no pattern flag                                       | Grant, but log and watch the device                   |
| **High** score                                                          | Require verification before granting                  |
| Device or local IP flagged **Suspicious**                               | Require verification, regardless of the session score |
| Device or local IP flagged **Dangerous**                                | Deny the reward and route to review                   |
| All-zero / missing DeviceID (snippet blocked or JS disabled, score 90+) | Require verification before granting                  |

Where you draw each line is yours, and your own per-code or per-campaign redemption caps sit alongside these as a second, simpler backstop.

<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>
