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

# Bonus Abuse

> Learn how to detect and prevent bonus abuse: link duplicate accounts claiming the same signup or deposit bonus to one device and one network.

A signup or deposit bonus is meant to be claimed once per real person. Bonus hunters get around that by spinning up a string of fresh accounts — new email, new cookie, often a private window and a rotated IP — and claiming the same bonus from each one. On the wire that looks like several "different" customers, but the bonus-claim sessions trace back to **one DeviceID** you have seen before.

## What is bonus abuse?

Bonus abuse is the repeated claiming of a per-customer promotion — a signup bonus, a deposit-match, or free credit — through duplicate accounts that pretend to be new players. It is common in iGaming and rewards programs, where the bonus has direct cash value and one person can profitably farm dozens of accounts.

## How ShieldLabs surfaces it

ShieldLabs resolves each claim 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 hunter clears cookies, opens a private window, and rotates VPN IPs between accounts precisely so each claim looks new, so counting cookies or public IPs lets the farm right through. The DeviceID holds steady underneath and lets your code see how many "new" players actually share one machine.

ShieldLabs also returns a per-request [Risk Score (0–100)](/features/risk-scoring). When a hunter masks the connection, the [anonymity signals](/features/anonymity-signals) fire, and the real network IP (`local_ip.ip`) can reveal the network behind the public `public_ip.ip` they rotate. When their countries disagree, `detection_flags.ip_mismatch` is set to `true` (informational only, surfaced for your code; it does not change the score, and can be benign on mobile networks). The durable tell is the constant `local_ip` itself, the Local IP the patterns correlate on, so a farm rotating VPN exits still collapses to one `local_ip`.

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 claim code enforces your per-customer policy.

## Stop bonus abuse

The rule your code applies, wired up in `## Build it` below: read the claim session's `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 have already claimed off that one machine or local network. Then:

* **Credit the bonus** when the score is low and the device is fresh.
* **Hold it for verification** when the claim is masked, when the **Many Accounts on One Device** or **Many Accounts on One Local IP** pattern flags the entity, or when the live account count crosses your per-customer cap.

The outcome: a hunter who clears cookies, opens a private window, and rotates VPN exits still resolves to one DeviceID and one `local_ip`, so the bonus pauses for review instead of paying out, while a genuine new player goes through.

## 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="Identify the claim session">
    Add the snippet to the page where the bonus is claimed — the "claim bonus" button, the deposit form with a bonus code, the welcome-offer screen. Re-identify on the action so you score the session that is actually claiming, not a stale page load. Pass the account's hashed id, never a raw email. Use `forceCheckAuthenticatedUser` here (not `checkAuthenticatedUser`): a bonus claim is a sensitive, value-bearing action, so resetting the visit session first gives you a fresh score keyed to a requestID you can act on, per the [snippet methods](/setup/snippet).

    ```html claim-bonus.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.
      // forceCheckAuthenticatedUser resets the visit session first, so the claim
      // starts a fresh session keyed to a requestID you can act on.
      // The callback's first arg is the client IP the server saw (not a score);
      // the score is read server-side via the webhook or History API.
      mod.forceCheckAuthenticatedUser('8a9f-hashed-account-id', (ip, requestID) => {
        document.getElementById('shield-request-id').value = requestID;
      });
    </script>

    <form method="POST" action="/api/claim-bonus">
      <input type="hidden" id="shield-request-id" name="shieldRequestId" />
      <input type="text" name="bonusCode" placeholder="Bonus code" />
      <button type="submit">Claim bonus</button>
    </form>
    ```
  </Step>

  <Step title="Read the scored result on your server">
    The score arrives on the [webhook](/api/webhooks). Verify the `X-Shield-Signature` HMAC, then cache it by `request_id`. Your claim 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`. The fields that matter are `device_id`, `user_hid`, `score`, `signals`, [`detection_flags`](/glossary#detection-flags), and the two IPs — `public_ip` (public, VPN-fakeable) and `local_ip` (the live equivalent of the Local IP behind the **Many Accounts on One Local IP** pattern):

    ```json the webhook your handler caches theme={null}
    {
      "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11",
      "device_id": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f",
      "visitor_id": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d",
      "user_hid": "a1b2c3d4hasheduserid",
      "public_ip": { "ip": "203.0.113.42", "country": "NL" },
      "local_ip": { "ip": "10.4.7.19", "country": "PL" },
      "risk_score": 20,
        "signals": [
        { "name": "Datacenter IP", "weight": 10 },
        { "name": "Abuser Flag", "weight": 10 }
      ],
      "detection_flags": { "ip_mismatch": true, "datacenter_ip": true, "abuser": true },
      "observed_at": "2026-06-16T18:00:45Z"
    }
    ```
  </Step>

  <Step title="Count the accounts behind the device and decide">
    This is where your code owns the verdict. Link the related accounts on the durable `device_id`: read the [History API](/api/server-api) by `device_id` and count the distinct `user_hid` values it has touched — that count is the number of accounts behind the machine. Combine it with the session score, then decide grant, verify, or deny.

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

      // 1. Your normal bonus checks first (code valid, not already claimed by this
      //    account, within the campaign window).
      if (!(await bonusIsClaimable(bonusCode, accountId))) {
        return res.status(409).json({ error: 'bonus_not_claimable' });
      }

      // 2. Read the scored result for this session (webhook cache, then History
      //    fallback). waitForScore is the shared helper.
      const shield = await waitForScore(shieldRequestId, 2000);
      const score    = shield?.score ?? 0;        // 0-100 anonymity of this claim
      const deviceId = shield?.device_id;

      // 3. The all-zero DeviceID is "no device", not a new one. A blocked or
      //    JS-disabled browser returns it with a score of 90 or higher — gate on the
      //    score and skip the device count for it.
      const NIL = '00000000-0000-0000-0000-000000000000';
      const usableDevice = deviceId && deviceId !== NIL;

      // 4. Link related accounts on the durable device_id: how many distinct
      //    accounts has this one machine already used to claim the bonus?
      let accountsOnDevice = 0;
      if (usableDevice) {
        const rows = await shieldHistory('device_id', deviceId, 100);
        accountsOnDevice = new Set(rows.map((r) => r.user_hid).filter(Boolean)).size;
      }

      // 5. YOUR code decides. Branch on the score band and the account count,
      //    never on a signal label string.
      if (score >= 60 || accountsOnDevice >= YOUR_ACCOUNT_LIMIT) {
        // Masked claim, or one device farming many accounts: hold for verification.
        return res.status(200).json({ requireVerification: true, reason: 'bonus_already_claimed_on_device' });
      }

      // Clean enough and a fresh device: credit the bonus.
      return grantBonus(req, res);
    });
    ```

    `shieldHistory` is the shared History API read from the [Use Case Tutorials](/use-case), here keyed by `device_id`. The underlying call:

    ```bash Read a device's claim history theme={null}
    curl "https://account.shieldlabs.ai/api/v1/history/device_id/5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f?limit=100" \
      -H "Authorization: Bearer sec_your_private_api_key"
    ```

    A determined hunter using several genuinely separate browsers shows up as several devices, since the DeviceID is browser-bound. The **Many Accounts on One Local IP** [pattern](/features/patterns) closes that gap: ten accounts claiming through one local IP is a strong shape even when each reports a different device. Use the pre-computed pattern export as a fast denylist; reserve live `device_id` reads for the borderline, high-value claims.
  </Step>

  <Step title="Decide and tune">
    Verify, do not hard-deny on the score alone — a real new player on a corporate VPN, a privacy browser, or a shared household network can score high or share a device with a relative. Holding a suspicious claim for a quick verification step keeps the genuine player in while still stopping the farm. Start in logging-only mode, watch how real claims distribute, then tune against your own traffic before you tighten thresholds.
  </Step>
</Steps>

## Test it

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

## Recommended starting policy

A guide, not a rule. Layer the score with the account count and tune against your own traffic.

| Signal at the bonus claim                             | Suggested action                                      |
| ----------------------------------------------------- | ----------------------------------------------------- |
| **Clean / Low** score, no repeat device               | Grant the bonus                                       |
| **Medium** score, no pattern flag                     | Grant, but log and watch the device                   |
| **High** score                                        | Hold for verification before crediting                |
| Device or local IP flagged **Suspicious**             | Require verification, regardless of the session score |
| Device or local IP flagged **Dangerous**              | Deny the bonus and route to review                    |
| All-zero DeviceID (blocked or JS-disabled, score 90+) | Hold for verification on the score alone              |

## Next

<CardGroup cols={2}>
  <Card title="Promo Abuse" icon="ticket" href="/use-case/promo-abuse">
    The sibling reward gate: coupons, referral credit, and trial resets claimed once per customer, counted off the same durable DeviceID.
  </Card>

  <Card title="New Account Fraud" icon="user-plus" href="/use-case/new-account-fraud">
    Thin the farm at registration before it ever reaches the bonus, by joining each new account to its DeviceID.
  </Card>
</CardGroup>

Wire the [signup tutorial](/use-case/new-account-fraud) for the create-account moment and treat this page as the bonus-time gate on top of it. For the mechanics underneath: [Identifiers](/features/identification) explains why the DeviceID survives a cookie clear, [Anonymity signals](/features/anonymity-signals) lists every signal that can ride on a masked claim, [Risk Scoring](/features/risk-scoring) defines the 0-100 score and its bands, [Patterns](/features/patterns) covers the dashboard relationship grading, and [Webhooks](/api/webhooks) gives the exact payload your handler reads.
