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

> Stop points, tiers and member perks from being farmed or drained through linked or taken-over accounts.

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 network, or a real member whose balance someone else drains after taking over the account. ShieldLabs links each member account to the devices and network behind it and detects Multi-accounting, Account sharing and Account takeover on your members directly. The [Device ID](/features/identification) ties the "different" members together, so you see how many of them 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, or draining a real member's points after taking over the account. 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 ties each earning or redemption to the member account behind it, detects High-Risk Events on your members, and scores the action itself. Four layers answer four questions:

| Layer | What it answers | Where you read it | Latency |
| - | - | - | - |
| **Account** | "Is this member risky, and which devices and IPs is it linked to?" | [History API](/api/server-api#read-every-identification-of-one-account) by `user_hid`: the member's worst band, its Device IDs and IPs | On demand |
| **High-Risk Events** | "Is this member multi-accounting, shared, or taken over?" | [Multi-accounting, Account sharing and Account takeover](/features/high-risk-events) on the user, at Medium or High confidence, in the analytics dashboard, the API and webhooks | When detected |
| **Device** | "How many members share this device, even after cleared cookies, incognito or a new IP?" | [History API](/api/server-api) by `device_id`: the distinct accounts behind it | On demand |
| **Identification** | "Is this earning or redemption masked or automated right now?" | `risk_score`, the named `signals` and `detection_flags` on the [webhook](/api/webhooks) | About 300 ms |

The anchor for the device count is the **Device ID**, derived server-side, so a cookie clear, a private window or a rotated VPN IP does not reset it. The distinct accounts behind one Device ID are the members behind one machine. When a farmer rotates the public IP through a VPN, the Local IP, the address the browser itself reports, can expose the network behind the mask.

## Prevent loyalty fraud

The rule to apply: on every earning and redemption action, read the member's account (its worst band and linked devices), the action's `risk_score` and named `signals`, and the `device_id`. Count the distinct accounts behind one `device_id` with the History API, and behind one `local_ip.ip` in your own webhook store, 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 [Risk Score (0-100)](/features/risk-scoring) and the [`detection_flags`](/glossary#detection-flags): a masked action 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, compare `public_ip.country` with `local_ip.country`; `detection_flags.ip_mismatch` marks two different addresses and is informational (it does not change the Risk Score). ShieldLabs stops loyalty fraud by linking the accounts and scoring each action; you choose the action for each case. The steps below wire it up.

## Build it

<Steps>
  <Step title="Identify on the action">
    Add the [snippet](/setup/snippet) to your signed-in pages and pass a hashed User HID with `checkAuthenticatedUser` on every one of them. Users, account-level risk and all four High-Risk Events are built on it. Within one visit (while a page of your site stays open in the browser, across route changes in a single-page app and across open tabs), `checkAnonymous` and `checkAuthenticatedUser` run at most one identification every five minutes for the same user. A call inside that window posts nothing, counts nothing, and its `onInitialized` handler receives `{ status: "not_initialized" }`. On a multi-page site, a full page load in the only open tab can start a new visit with its own identification. So where points are earned or a perk is claimed, call `forceCheckAuthenticatedUser` when the user starts filling the form for that action (its first focus): it runs an identification every time, keeps the current Session ID and restarts the five-minute window, so the action always posts its own request ID. `onInitialized` fires when the check starts, before the snippet has sent the identification, so do not navigate away inside it: store the request ID and let the form submit normally. Pass the member's hashed account id, 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'
      );
      const form = document.getElementById('reward-form');

      // The Device ID and Risk Score reach your server by webhook;
      // result.requestID joins them. onInitialized fires when the check starts,
      // before the snippet sends it, so it only stores the request ID and the form
      // submits normally. With no request ID, your backend holds the reward.
      // Pass the hashed account id, never a raw email.
      const identify = () => {
        mod.forceCheckAuthenticatedUser('8a9f-hashed-account-id', {
          onInitialized: (result) => {
            if (result.status === 'initialized') {
              document.getElementById('shieldlabs-request-id').value = result.requestID;
            }
          },
        });
      };
      if (form.contains(document.activeElement)) identify(); // already focused (autofocus)
      else form.addEventListener('focusin', identify, { once: true });
    </script>

    <form id="reward-form" method="POST" action="/api/loyalty/redeem">
      <input type="hidden" id="shieldlabs-request-id" name="shieldlabsRequestId" />
      <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) with `request_id`, `user_hid`, `device_id`, `visitor_id`, `risk_score`, `signals`, `detection_flags` and `observed_at`. Cache it by `request_id` and read it back with the shared [`waitForScore` helper](/use-case), which falls 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="Read the member behind the action">
    The redemption is one identification. The member behind it has a history, and for a loyalty program it is usually a long one. The shared `accountView` helper reads the member's earlier identifications from the [History API](/api/server-api#read-every-identification-of-one-account) by `user_hid`: the worst band across them shows how risky the member has been, and its distinct Device IDs and public IPs are the devices and networks it is linked to. When a Multi-accounting, Account sharing or Account takeover event arrives for a member through the API or webhooks, or when you review it in the analytics dashboard, record it against the account in your own system with the event and its confidence, and the handler in the next step reads that record.

    In the analytics dashboard, the member's card shows the same view: its band for the selected period, its High-Risk Events, each pill coloured by its confidence, and each linked device, visitor and IP with the band of the identifications it shares with the account. [User, device, visitor and IP cards](/dashboard/entity-card) describes each part.

    <Frame caption="The devices linked to one user in the analytics dashboard, with the band of the identifications they share.">
      <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/user-card-details-linked.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=15a35b82378c6d3a007dd3394904563d" alt="The Details section of the user card for User HID a91f3c7e5b2d4086 in the analytics dashboard with Linked devices open: 2 devices, one Trusted with 7 identifications and one Dangerous with 5, and the Linked local IPs counter showing 2." width="2238" height="712" data-path="images/dashboard/user-card-details-linked.png" />

      <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/user-card-details-linked-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=74cb3f5cf0a3421e0c7e3fd2c915b467" alt="The Details section of the user card for User HID a91f3c7e5b2d4086 in the analytics dashboard in the dark theme with Linked devices open: 2 devices, one Trusted with 7 identifications and one Dangerous with 5, and the Linked local IPs counter showing 2." width="2238" height="712" data-path="images/dashboard/user-card-details-linked-dark.png" />
    </Frame>
  </Step>

  <Step title="Count the accounts behind the device and decide">
    The decision combines the member, the action's risk signals and the account count behind the device. A masked action alone can be a real member on a corporate VPN; many accounts redeeming from one durable Device ID is the farm shape no single genuine member ever shows.

    ```js api/loyalty/redeem.js theme={null}
    import { app, waitForScore, band, accountView, accountsBehindDevice, NIL_DEVICE } from '../../shieldlabs-helpers.js';

    app.post('/api/loyalty/redeem', async (req, res) => {
      const { rewardId, shieldlabsRequestId } = req.body;
      const accountId = req.user.id;
      const userHid = req.user.hashedId; // the member's hashed id, as passed to the snippet

      // 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. The guard: read this action's identification. No identification is
      //    unverified, never clean.
      const risk = await waitForScore(shieldlabsRequestId, 2000);
      if (!risk) {
        return res.json({ requireVerification: true, reason: 'no_identification' });
      }
      if (risk.user_hid !== userHid) {
        return res.json({ requireVerification: true, reason: 'identification_mismatch' });
      }
      if (risk.risk_score > 100) {
        // The 999 rate-limit marker.
        return res.json({ requireVerification: true, reason: 'rate_limit_marker' });
      }

      // 3. The member: the High-Risk Event you recorded for it (your own store,
      //    written when the event arrives through the API or webhooks or when you
      //    review it in the analytics dashboard), then its earlier identifications.
      const mark = await memberMarks.get(userHid); // your own record, e.g. { event: 'Account takeover', confidence: 'high' }
      if (mark?.event === 'Account takeover') {
        // Points drain: the real member confirms it is them before any redemption.
        return mark.confidence === 'high'
          ? res.status(403).json({ error: 'redemptions_locked', reason: 'account_recovery' })
          : res.json({ requireReauthentication: true, reason: 'takeover_review' });
      }
      if (mark?.confidence === 'high') {
        return res.status(403).json({ error: 'reward_denied', reason: 'held_for_review' });
      }
      // A failed History read is unverified: hold the reward.
      const member = await accountView(userHid, { excludeRequestId: risk.request_id }).catch(() => null);
      if (!member) return res.json({ requireVerification: true, reason: 'history_unavailable' });
      if (mark || member.worstBand === 'Dangerous') {
        return res.json({ requireVerification: true, reason: 'risky_member' });
      }

      // 4. An all-zero Device ID means no usable device signals reached ShieldLabs:
      //    there is no device to count members on, so hold the reward.
      if (risk.device_id === NIL_DEVICE) {
        return res.json({ requireVerification: true, reason: 'no_device' });
      }

      // 5. Distinct member accounts behind this one device ("anonymous" is not an account).
      const accountsOnDevice = await accountsBehindDevice(risk.device_id).catch(() => null);
      if (accountsOnDevice === null) {
        return res.json({ requireVerification: true, reason: 'history_unavailable' });
      }
      if (accountsOnDevice >= YOUR_ACCOUNT_LIMIT) {
        return res.json({ requireVerification: true, reason: 'accounts_linked_to_device' });
      }

      // 6. The action itself, from the band and the IP countries, never from a
      //    label string. A masked network on a device other members use is the
      //    farm tell (local_ip is null on the History fallback).
      const countriesDiffer = Boolean(
        risk.local_ip?.country && risk.public_ip?.country
          && risk.local_ip.country !== risk.public_ip.country
      );
      if (band(risk.risk_score) === 'Dangerous' || (countriesDiffer && accountsOnDevice > 1)) {
        return res.json({ requireVerification: true, reason: 'risk_signals' });
      }
      if (band(risk.risk_score) === 'Suspicious') {
        await flagForReview(accountId, risk.device_id, risk); // Suspicious: grant, but watch
      }

      // Trusted, a clean member, one account on the device: award the reward.
      return grantReward(req, res);
    });
    ```
  </Step>

  <Step title="See the spread over time">
    The per-action check catches a redemption right now. ShieldLabs also detects three [High-Risk Events](/features/high-risk-events) that matter for loyalty programs, each at **Medium** or **High** confidence, and they are available in the analytics dashboard, the API and webhooks:

    <AccordionGroup>
      <Accordion title="Multi-accounting" icon="users-rectangle">
        Several member accounts run by one person, linked through the devices and network they share: the core farming shape. The confidence depends on the combination of evidence.
      </Accordion>

      <Accordion title="Account sharing" icon="users">
        One member account used from several distinct devices: the tier or status-abuse shape, where one identity is pushed up by activity from many people.
      </Accordion>

      <Accordion title="Account takeover" icon="user-lock">
        An existing member account appearing in a new environment that points to someone else using it: the points-drain shape, where someone else redeems the member's balance.
      </Accordion>
    </AccordionGroup>

    All three need the hashed User HID, which you pass with `checkAuthenticatedUser` on every signed-in page and with `forceCheckAuthenticatedUser` on the action. When one of these events arrives for a member, act on the account; you choose the action for each case. At the earning or redemption itself, the Risk Score and risk signals of the identification remain the input.

    On [Overview](/dashboard/overview), the Users and High-Risk Events panels count your members with each event at Medium and High confidence for the selected period, and each event tile opens [Analytics](/dashboard/analytics) with that event as a filter; switch to the **Users** tab to list the members who have it. [Investigate a risky user](/use-case/investigate-a-user) walks through the review.

    <Frame caption="Users and High-Risk Events on the Overview screen of the analytics dashboard, counted for the users active in the selected period.">
      <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/overview-users-events.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=21cb085be89557f64f5578bc0bdd2ee5" alt="The Users and High-Risk Events panels of the analytics dashboard: 1,240 users split into 1,090 Trusted, 104 Risky users and 46 High-Risk Event users; Multi-accounting 22 users (14 Medium, 8 High confidence), Account sharing 12 (8 Medium, 4 High), Impossible travel 7 (5 Medium, 2 High) and Account takeover 5 (3 Medium, 2 High)." width="866" height="1170" data-path="images/dashboard/overview-users-events.png" />

      <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/overview-users-events-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=7bbf0082db16276cc00eefb254288dcd" alt="The Users and High-Risk Events panels of the analytics dashboard in the dark theme: 1,240 users split into 1,090 Trusted, 104 Risky users and 46 High-Risk Event users; Multi-accounting 22 users (14 Medium, 8 High confidence), Account sharing 12 (8 Medium, 4 High), Impossible travel 7 (5 Medium, 2 High) and Account takeover 5 (3 Medium, 2 High)." width="866" height="1170" data-path="images/dashboard/overview-users-events-dark.png" />
    </Frame>

    <Note>
      History API reads never count against your included identifications. For high-volume earning flows, keep your own counters (members per Device ID and per `local_ip.ip` from the webhook) as the fast path, and reserve live History reads for the perks that are expensive to give away by mistake. The History API has no Local IP search, so the Local IP count always comes from your own store.
    </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 VPN can reach the Suspicious band, so decide on the Risk 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 or open a private window, 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-account count on that device climbs with each run. A second browser gets its own Device ID; the network the accounts share can still link them. Toggling a VPN lights up the risk signals and flips the matching `detection_flags`, all without changing the Device ID.

Then search Analytics in the [analytics dashboard](/dashboard/analytics) for that Device ID and open it: **Linked accounts** lists every account you used in the test, each with the band of its identifications on that device.

## 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 |
| - | - |
| Trusted Risk Score (0-29), a clean member, one account on the device | Award the reward |
| Suspicious Risk Score (30-59), a clean member, one account on the device | Award, but log and watch the device |
| Dangerous Risk Score (60-100) | Require verification before granting |
| Member whose worst band is Dangerous | Require verification before granting |
| Many accounts on the device or Local IP (over your limit) | Require verification, regardless of the Risk Score |
| Member with a **Multi-accounting** or **Account sharing** [event](/features/high-risk-events) at **Medium** confidence | Require verification |
| Member with a **Multi-accounting** or **Account sharing** event at **High** confidence | Deny the perk and route to review |
| Member with an **Account takeover** event at **Medium** confidence | Ask the member to sign in again before redeeming, and notify them |
| Member with an **Account takeover** event at **High** confidence | Lock redemptions and start account recovery |
| No identification for the action, or an all-zero Device ID | Require verification before granting |
| Risk Score above 100 (the 999 rate-limit marker) | Require verification before granting |

## 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="Account Takeover" icon="user-shield" href="/use-case/account-takeover">
    Step up when a known member arrives on an unfamiliar device, before the points are drained.
  </Card>

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

  <Card title="High-Risk Events" icon="diagram-project" href="/features/high-risk-events">
    Multi-accounting, Account sharing, Impossible travel and Account takeover, detected on your members.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.