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

# Free Trial Abuse

> Stop one person from restarting your free trial with new accounts.

Trial abuse is one person taking your free trial again and again: a new email each run, the timer reset to zero, never a paid plan at the end. ShieldLabs links every trial account to the devices and network behind it, so you see how many "different" trial accounts one person runs, and detects Multi-accounting on those accounts directly. The [Device ID](/features/identification) holds through cleared cookies, incognito mode and IP changes between runs.

## What is free trial abuse?

Free trial abuse is when one person repeatedly creates new accounts to re-claim a product's free trial or free-tier quota without ever converting to paid. Each account looks like a distinct customer, but they all originate from one person behind a single device or local network, cycling identities to keep the free benefit running.

## How ShieldLabs surfaces it

ShieldLabs ties each trial start to the account behind it, detects when one person runs many accounts, and scores the moment itself. Four layers answer four questions:

| Layer | What it answers | Where you read it | Latency |
| - | - | - | - |
| **Account** | "Is this trial account 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 account's worst band, its Device IDs and IPs | On demand |
| **High-Risk Events** | "Is this account one of several run by one person?" | [Multi-accounting](/features/high-risk-events#multi-accounting) on the user, at Medium or High confidence, in the analytics dashboard, the API and webhooks | When detected |
| **Device** | "How many trial accounts has this device started, 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 trial start 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 from stable device characteristics, so clearing cookies, opening a private window or switching networks does not reset it. The **Cookie ID** and **Visitor ID** change when cookies are cleared; the Device ID holds, and the distinct accounts behind it are the trials one machine has started.

## Prevent free trial abuse

The trial policy: at trial start, read the new account, the trial start's `risk_score`, and the distinct accounts behind its Device ID (History API) and its Local IP (your own webhook store), and hold the trial when the count crosses your trial limit. Accounts with a **Multi-accounting** event, whether it reached you through the API or webhooks or you reviewed it in the analytics dashboard, go to verification. Fold in the [Risk Score (0-100)](/features/risk-scoring) as weight: a clean device with a first trial passes, while a machine already cycling several accounts, or a Dangerous-band trial start, gets held. When a cycler hides behind a VPN to look like a new region, the Local IP, the address the browser itself reports, can expose the network behind a faked `public_ip.country`. ShieldLabs stops trial abuse by linking the accounts and scoring the moment; you choose allow, verify or deny for each case in your signup handler. The steps below wire it up.

## Build it

<Steps>
  <Step title="Identify the new account at trial start">
    Load the [snippet](/setup/snippet) on the page where the trial begins (the signup form or the "start free trial" button). A trial belongs to an account, so identify the new account right after you create it, when the "start trial" page opens: call `forceCheckAuthenticatedUser` with its hashed id. It runs an identification every time, keeps the current Session ID and restarts the five-minute window, so the trial start 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. From then on, pass a hashed User HID with `checkAuthenticatedUser` on every signed-in page. 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. That window is why the trial start itself uses `forceCheckAuthenticatedUser`. To score the signup form itself before the account exists, use `forceCheckAnonymous` as the [signup tutorial](/use-case/new-account-fraud) shows.

    ```html start-trial.html theme={null}
    <script type="module">
      const mod = await import(
        'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'
      );
      // A fresh identification for the trial start, started when the page opens
      // so it is sent while the user reads it. 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 trial.
      // The new account's hashed id, rendered by your server after signup.
      // Never a raw email or user id.
      mod.forceCheckAuthenticatedUser('8a9f-hashed-account-id', {
        onInitialized: (result) => {
          if (result.status === 'initialized') {
            document.getElementById('shieldlabs-request-id').value = result.requestID;
          }
        },
      });
    </script>

    <form id="trial-form" method="POST" action="/api/start-trial">
      <input type="hidden" id="shieldlabs-request-id" name="shieldlabsRequestId" />
      <button type="submit">Start free trial</button>
    </form>
    ```
  </Step>

  <Step title="Read the scored result on your server">
    The Risk Score arrives on the [webhook](/api/webhooks). Verify the signature, 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`. The fields your trial gate reads:

    ```json 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": "8a9f-hashed-account-id",
      "public_ip": { "ip": "203.0.113.10", "country": "US" },
      "local_ip": { "ip": "203.0.113.10", "country": "US" },
      "risk_score": 30,
      "signals": [
        { "name": "proxy", "weight": 10 },
        { "name": "datacenter_ip", "weight": 10 },
        { "name": "abuser", "weight": 10 }
      ],
      "detection_flags": { "proxy": true, "datacenter_ip": true, "abuser": true, "ip_mismatch": false },
      "observed_at": "2026-06-16T18:00:45Z"
    }
    ```

    `risk_score` is the sum of the weights in `signals`, capped at 100; a value above 100 is the 999 rate-limit marker. The [webhooks reference](/api/webhooks) has the full schema and every [`detection_flags`](/glossary#detection-flags) boolean.
  </Step>

  <Step title="Read the trial account">
    A trial start is one identification. For a brand-new account it is also the account's whole history, so the device carries the weight: its earlier accounts are the trials it already started. When the account existed before the trial (a free tier that moves to a trial, a returning user), the shared `accountView` helper reads its earlier identifications from the [History API](/api/server-api#read-every-identification-of-one-account) by `user_hid`: the worst band across them, and the devices and networks it is linked to. An account with a Multi-accounting event, recorded in your own system when it arrives through the API or webhooks or when you review it in the analytics dashboard, goes to verification before the trial starts.

    In the analytics dashboard, the account'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.
  </Step>

  <Step title="Count the trials behind the device and decide">
    Read the trial start's `risk_score` and its named signals, then count the accounts behind the durable `device_id` with the shared `accountsBehindDevice` helper: that is how many trials the machine has already started. A high Risk Score alone can be an honest prospect on a VPN; a high Risk Score **and** several accounts behind one device is the cycling shape worth gating.

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

    app.post('/api/start-trial', async (req, res) => {
      const { shieldlabsRequestId } = req.body;
      const accountId = req.user.id;
      const userHid = req.user.hashedId; // the new account's hashed id, as passed to the snippet

      // 1. Your normal eligibility checks first (not already trialed by this
      //    account, within campaign window, terms accepted).
      if (!(await trialIsAvailable(accountId))) {
        return res.status(409).json({ error: 'trial_not_available' });
      }

      // 2. The guard: read this trial start'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 account: your Multi-accounting mark ('medium' or 'high', your own
      //    store), then its earlier identifications without this one.
      const marked = await markedAccounts.get(userHid);
      if (marked === 'high') {
        return res.status(403).json({ error: 'trial_denied', reason: 'held_for_review' });
      }
      // A failed History read is unverified: hold the trial.
      const account = await accountView(userHid, { excludeRequestId: risk.request_id }).catch(() => null);
      if (!account) return res.json({ requireVerification: true, reason: 'history_unavailable' });
      if (marked || account.worstBand === 'Dangerous') {
        return res.json({ requireVerification: true, reason: 'risky_account' });
      }

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

      // 5. Distinct accounts behind this 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_TRIAL_LIMIT) {
        return res.json({ requireVerification: true, reason: 'trials_on_device' });
      }

      // 6. The trial start itself. Branch on the band, never on a label string.
      if (band(risk.risk_score) === 'Dangerous') {
        return res.json({ requireVerification: true, reason: 'risk_signals' });
      }

      // Trusted or Suspicious, a clean account and a fresh device: grant the trial.
      return grantTrial(req, res);
    });
    ```

    ```bash Read a device's trial 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"
    ```

    For the local-network shape, keep your own count of trial accounts per `local_ip.ip` from the webhook: the History API searches by public IP (`ip`) and has no Local IP search. The Multi-accounting event also covers accounts that share a network.
  </Step>

  <Step title="See the spread over time">
    The per-trial check catches a trial right now. The standing view is the **Multi-accounting** [High-Risk Event](/features/high-risk-events#multi-accounting), which ShieldLabs detects directly with **Medium** or **High** confidence and makes available in the analytics dashboard, the API and webhooks. It covers both trial-cycling shapes:

    <AccordionGroup>
      <Accordion title="Accounts linked by device" icon="mobile-screen">
        One device linked to many distinct accounts: the core trial-cycling shape. A fresh email and a private window do not reset the Device ID.
      </Accordion>

      <Accordion title="Accounts linked by network" icon="network-wired">
        Many accounts starting trials through the same network, even when each uses a fresh cookie and a different public IP. This catches a person who spreads across several separate browsers but still sits behind one router.
      </Accordion>
    </AccordionGroup>

    When a Multi-accounting event arrives for an account through the API or webhooks, or when you review it in the analytics dashboard, record it against the account in your own system, and the handler above holds its next trial; you choose the action for each case. At the trial start itself, the Risk Score and risk signals of the identification remain the input.

    In [Analytics](/dashboard/analytics), open the **Users** tab and filter by the Multi-accounting High-Risk Event: every trial account with the event is listed with its band for the period. [Investigate a risky user](/use-case/investigate-a-user) walks through the review.

    <Frame caption="Users with a Multi-accounting event in the analytics dashboard.">
      <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/analytics-users-hre-filter.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=7e6bf860b2155e511c189dd1ee15b5f8" alt="The Users tab of the analytics dashboard filtered to users with a Multi-accounting event, the filter chip high_risk_event:multi_accounting on the chart card: 22 users, 9 Trusted, 6 Suspicious and 7 Dangerous, each with its band, identifications, devices, unique visitors and public IPs." width="2880" height="1742" data-path="images/dashboard/analytics-users-hre-filter.png" />

      <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/analytics-users-hre-filter-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=ebc96d7e462f2023a319955e3617e311" alt="The Users tab of the analytics dashboard in the dark theme filtered to users with a Multi-accounting event, the filter chip high_risk_event:multi_accounting on the chart card: 22 users, 9 Trusted, 6 Suspicious and 7 Dangerous, each with its band, identifications, devices, unique visitors and public IPs." width="2880" height="1742" data-path="images/dashboard/analytics-users-hre-filter-dark.png" />
    </Frame>

    <Note>
      History API reads never count against your included identifications. For high-volume signup flows, keep your own counters (trial accounts per Device ID and per `local_ip.ip` from the webhook) as the fast path, and reserve live History reads for the borderline trials that are expensive to give away by mistake.
    </Note>
  </Step>

  <Step title="Tune to your product">
    Start in logging-only mode, watch how your real signups distribute, then set the device and Local IP limits in your trial check to match your trial terms before you raise friction. A real prospect on a corporate VPN can fire the same risk signals, so weigh the count, the Risk Score and your own context together.
  </Step>
</Steps>

## Test it

You do not need a real farm to see this work. Start a trial once in your normal browser and note the `device_id` on the webhook. Then play the cycler: clear cookies or open a private window, and start a trial again as a different account. The `cookie_id` and `visitor_id` change every time, but the **same `device_id` returns**, and the distinct-account count on that device climbs with each run, exactly the count your gate reads. A second browser gets its own Device ID; the network the accounts share can still link them. Toggling a VPN adds the matching risk signals to the Risk Score 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.

<Frame caption="One Device ID in the analytics dashboard, with every account linked to it.">
  <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/device-card-linked-accounts.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=83e8b1b0c4f63a38e35219aecc83472e" alt="The device card for Device ID d290f1ee-6c54-4b01-90e6-d701748f0851 in the analytics dashboard: band Dangerous, 14 identifications, and Linked accounts open with 6 accounts: 3 Dangerous, 1 Suspicious and 2 Trusted." width="2254" height="1436" data-path="images/dashboard/device-card-linked-accounts.png" />

  <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/device-card-linked-accounts-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=55f17c303f9c27d58183734d642466c3" alt="The device card for Device ID d290f1ee-6c54-4b01-90e6-d701748f0851 in the analytics dashboard in the dark theme: band Dangerous, 14 identifications, and Linked accounts open with 6 accounts: 3 Dangerous, 1 Suspicious and 2 Trusted." width="2254" height="1436" data-path="images/dashboard/device-card-linked-accounts-dark.png" />
</Frame>

## Next

<CardGroup cols={2}>
  <Card title="Promo Abuse" icon="ticket" href="/use-case/promo-abuse">
    The reward-time sibling: the same device-count logic applied to coupons, signup bonuses, and referral credit.
  </Card>

  <Card title="New Account Fraud" icon="user-plus" href="/use-case/new-account-fraud">
    Join accounts to the Device ID at registration so a farm is thinned before it ever reaches a trial.
  </Card>

  <Card title="Multi-Accounting" icon="users" href="/use-case/multi-accounting">
    The general shape behind trial cycling: one person, many accounts, one machine.
  </Card>

  <Card title="Risk Scoring" icon="gauge" href="/features/risk-scoring">
    How the 0-100 Risk Score and the Trusted, Suspicious and Dangerous bands work.
  </Card>
</CardGroup>


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