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

# Loyalty Fraud

> Learn how to detect and prevent loyalty fraud: catch points and tier rewards farmed across many linked accounts instead of genuine activity.

A loyalty program rewards genuine, repeated activity, so the payoff for faking it is steady: points, tier status, member pricing, and referral credit. The fraud shape is a cluster of "different" members, each with its own login and email, that all trace back to one machine or one local network. ShieldLabs gives you the durable [DeviceID](/features/identification) that ties those accounts together, so your earning and redemption code can see how many members actually share one device.

## What is loyalty fraud?

Loyalty fraud is the gaming of a rewards or membership program — farming points, tiers, or member perks through multiple linked identities rather than real activity. One person runs several accounts to multiply signup bonuses, stack referral credit between their own profiles, or push a single identity into a higher reward tier than its genuine activity earns.

## How ShieldLabs surfaces it

ShieldLabs resolves each session to a set of [identifiers](/features/identification) and grades how many accounts cluster on one device or network. Four layers answer four different questions:

| 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](/api/webhooks) / [History API](/api/server-api) | About a second        |
| **Anonymity detection** | "Is this earning or redemption masked or anonymous right now?"                 | The `signals` array on the [webhook](/api/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](/api/webhooks) / [History API](/api/server-api)            | About a second        |
| **Patterns**            | "How many members already cluster on this device or local IP?"                 | [Dashboard Patterns](/features/patterns) + export                                        | Background (\~10 min) |

The anchor for the rest is the durable **DeviceID** — derived server-side, not stored in the browser, so a fresh cookie clear, incognito window, or rotated VPN IP does not reset it. The cookie-bound **CookieID** and **VisitorID** reset on every run; the DeviceID holds steady, and the distinct **UserHID** count behind it is the count of members behind one machine. When a farmer rotates the public IP through a VPN, the real network IP (`local_ip`) still exposes the local network behind the mask, so accounts that rotate their public IP still group on one Local IP.

## Prevent loyalty fraud

The rule your code applies: read the durable `device_id` and your own `user_hid` on every earning and redemption action, and read `local_ip.ip` for the local-network case. Count the distinct `user_hid` values behind one `device_id` (and behind one `local_ip.ip`), and when that count crosses your per-program limit, hold the perk for verification or deny it instead of paying the reward again. Weigh in the session [Risk Score (0–100)](/features/risk-scoring) and the [`detection_flags`](/glossary#detection-flags): a masked session reusing one device is the farm tell, while a clean, single-account device earns and redeems with no friction. When a farm masks its public IP, `public_ip` and `local_ip` disagree, so `detection_flags.ip_mismatch` is set to `true` (surfaced for your code; it does not change the score). ShieldLabs surfaces the count and the score; your earning and redemption handlers own the verdict. The steps below wire it up.

## Build it

<Steps>
  <Step title="Identify on the action">
    Add the [snippet](/setup/snippet) to the page where points are earned or a perk is claimed, and re-identify on the action itself so you score the live session. Pass the member's hashed account id (**UserHID**), never a raw email.

    ```html claim-reward.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.
      // `ip` is the client IP the server saw, not a score — the DeviceID and
      // Risk Score are read server-side via webhook. requestID correlates them.
      mod.checkAuthenticatedUser('8a9f-hashed-account-id', (ip, requestID) => {
        document.getElementById('shield-request-id').value = requestID;
      });
    </script>

    <form method="POST" action="/api/loyalty/redeem">
      <input type="hidden" id="shield-request-id" name="shieldRequestId" />
      <input type="text" name="rewardId" placeholder="Reward" />
      <button type="submit">Redeem points</button>
    </form>
    ```
  </Step>

  <Step title="Read the scored result on the server">
    The scored result arrives by [webhook](/api/webhooks) — `request_id`, `device_id`, `visitor_id`, `user_hid`, `score`, `signals`, `detection_flags`, `scored_at`. Cache it by `request_id` and read it back with the shared [`waitForScore` helper](/use-case), or fall back to a [History API](/api/server-api) read by `request_id`. Because the durable `device_id` is the grouping key, you also read the account's neighbours: how many distinct accounts that one device has already touched.

    ```bash The accounts behind one device 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"
    ```
  </Step>

  <Step title="Count the accounts behind the device and decide">
    The verdict combines the session's anonymity with the account count behind the device. A masked session alone can be a real member on a corporate VPN; many accounts redeeming from one durable DeviceID is the farm shape no single genuine member ever shows.

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

      // 1. Your normal program checks first (member owns the points, perk is
      //    eligible, within the campaign window).
      if (!(await rewardIsRedeemable(rewardId, accountId))) {
        return res.status(409).json({ error: 'reward_not_redeemable' });
      }

      // 2. Read the scored result for this session (snake_case webhook body).
      const shield   = await waitForScore(shieldRequestId, 2000);
      const score    = shield?.score ?? 0;        // 0-100, default to 0 if not yet in
      const deviceId = shield?.device_id;
      const flags    = shield?.detection_flags ?? {};

      // 3. The all-zero DeviceID is "no device" (JS disabled or blocked), not a
      //    new one. Skip the account count and decide on the score alone.
      const NIL = '00000000-0000-0000-0000-000000000000';
      const usableDevice = deviceId && deviceId !== NIL;

      // 4. Count distinct accounts behind this one durable device.
      let accountsOnDevice = 1;
      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 owns the verdict. Branch on the band, the count, and the
      //    IP-mismatch flag, never on a signal label string. ip_mismatch on a
      //    reused device is the masked-farm tell.
      const maskedFarm = flags.ip_mismatch && accountsOnDevice > 1;
      if (accountsOnDevice >= YOUR_ACCOUNT_LIMIT || score >= 60 || maskedFarm) {
        return res.json({
          requireVerification: true,
          reason: accountsOnDevice >= YOUR_ACCOUNT_LIMIT ? 'accounts_linked_to_device' : 'anonymity',
        });
      }
      if (score >= 30) {
        await flagForReview(accountId, deviceId, shield);   // Medium: grant, but watch
      }

      // Clean / Low, one account on the device: award the reward.
      return grantReward(req, res);
    });
    ```
  </Step>

  <Step title="See the spread over time">
    The per-session check catches a redemption right now. The standing view is [Patterns](/features/patterns), graded server-side over a rolling window and exported as CSV or JSON.

    <AccordionGroup>
      <Accordion title="Many Accounts on One Device" icon="mobile-screen">
        One device linked to many distinct accounts: the core farming shape. The grouping identity is the durable DeviceID. It grades **Suspicious**, then **Dangerous**, as the account count climbs.
      </Accordion>

      <Accordion title="Many Accounts on One Local IP" icon="network-wired">
        Many accounts earning or redeeming through the same local network, even when each rotates its public IP. Catches a person spread across several browsers behind one router.
      </Accordion>

      <Accordion title="Many Devices on One Account" icon="users">
        One member worked from a string of machines — the tier or status-abuse shape, where a single identity is pushed up by activity across many devices.
      </Accordion>
    </AccordionGroup>

    <Note>
      History reads through `account.shieldlabs.ai` are free. For high-volume earning flows, use the [Patterns](/features/patterns) export as your denylist of farm devices and local IPs, and reserve live `device_id` reads for the perks that are expensive to give away by mistake.
    </Note>
  </Step>

  <Step title="Tune to your program">
    Start in logging-only mode, watch how real members distribute, then set your limits and raise friction as conditions stack. A real member on a corporate proxy or privacy browser can land in the High band, so decide on the score plus the `detection_flags` plus the account count plus your own context, never the number alone.
  </Step>
</Steps>

## Test it

You do not need a real farm to see this work. Redeem a perk once in your normal browser and note the `device_id` on the webhook. Then play the farmer: clear cookies, open a private window, or switch to a second browser profile, and redeem again as a different member. 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. Toggling a VPN lights up the anonymity signals and flips the matching `detection_flags`, all without changing the durable DeviceID.

## Recommended starting policy

A guide, not a rule. Layer the conditions: a loyalty farm trips more than one, and friction should rise as they stack.

| Signal at earning or redemption                                                        | Suggested action                                                     |
| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Clean / Low score, one account on the device                                           | Award the reward                                                     |
| Medium score, one account on the device                                                | Award, but log and watch the device                                  |
| High score                                                                             | Require verification before granting                                 |
| Many accounts on the device or local IP (over your limit)                              | Require verification, regardless of session score                    |
| Device or local IP flagged **Suspicious** in the [Patterns](/features/patterns) export | Require verification                                                 |
| Device or local IP flagged **Dangerous**                                               | Deny the perk and route to review                                    |
| All-zero DeviceID, JavaScript-disabled visit (score **90+**)                           | Decide on the score alone, skip the account count                    |
| All-zero DeviceID, snippet blocked (no score reached the server)                       | Fall back to the History API, treat as unverifiable, route to review |

## Next

<CardGroup cols={2}>
  <Card title="Stop Multi-Accounting" icon="users" href="/use-case/multi-accounting">
    Loyalty farming is a special case of one person running many accounts.
  </Card>

  <Card title="Promo Abuse" icon="ticket" href="/use-case/promo-abuse">
    The reward-time gate for one-per-customer signup bonuses, coupons, and credits.
  </Card>

  <Card title="Referral Abuse via Sybils" icon="diagram-project" href="/use-case/sybil-attack">
    When the linked identities exist to farm referral credit between each other.
  </Card>

  <Card title="Patterns" icon="diagram-project" href="/features/patterns">
    The dashboard view that grades many-accounts-on-one-device across all members.
  </Card>
</CardGroup>
