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

# Affiliate Fraud (Click & Lead Inflation)

> Learn how to detect and prevent affiliate fraud: rank referral traffic by anonymous-visit share so click and lead inflation never gets paid out.

Affiliate and paid-acquisition programs pay per click, install, or conversion, so the incentive to inflate those events with masked, recycled, or coordinated traffic is built in. ShieldLabs tags every visit with where it came from, attaches an explainable [Risk Score (0–100)](/features/risk-scoring), and links visits to a durable [DeviceID](/features/identification) so one device cannot pose as many fresh visitors. You rank your sources by that, and your own payout code decides what to pay, hold, or review.

## What is affiliate fraud?

Affiliate fraud is the inflation of clicks, leads, installs, or conversions in a partner or paid-acquisition program so a fraudster collects payouts on traffic that has no real value, often through masked IPs, recycled devices, or a single person posing as many fresh visitors. Each padded event looks like an independent visitor but traces back to a small number of real devices or networks.

## How ShieldLabs surfaces it

ShieldLabs answers the two questions affiliate quality turns on:

| Question                                | What reads it                                                            | Where                                                       |
| --------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------- |
| "Which source delivers masked traffic?" | Per-visit [Risk Score](/features/risk-scoring) + channel/UTM attribution | [Traffic Sources](/features/traffic-analytics) view         |
| "Is this one device posing as many?"    | The durable [DeviceID](/features/identification) under every visit       | [Webhook](/setup/webhooks) / [History API](/api/server-api) |

The Risk Score answers "is this visit masked, spoofed, or anonymous right now?" The DeviceID answers "have I seen this exact device before, no matter how many cookies and IPs it cycled through?" A cleared cookie mints a fresh **CookieID** and **VisitorID**, and a rotated public IP comes from any VPN or proxy — those are exactly the moves an inflating device makes between events, so the only durable key is the server-derived DeviceID, which holds across all three. When a visit masks its location, the real network IP (`local_ip`) still surfaces the network behind the exit, so rotated public IPs that share one Local IP betray a single person.

<Warning>
  A high score is not a verdict of fraud. A real person behind a corporate proxy, a VPN, or a privacy browser can land in the High band. For affiliate quality you are not judging one visit, you are reading the **distribution across a source**: a partner that sends 95% Clean and one that sends 45% High can report identical click counts, and the score is what separates them.
</Warning>

## Prevent affiliate fraud

The rule your payout code applies: on every affiliate visit and conversion, read the per-visit `score` and its `signals`, the durable `device_id`, and the channel and UTM attribution, and contrast `public_ip.country` against `local_ip.country` for the masked-network case. Then:

* **Dedup on `device_id`** inside your attribution window, so one device cannot be paid as a crowd.
* **Withhold or queue High-band conversions** for review, and approve Medium ones on a clawback delay.
* **Rank each affiliate** by its masked-traffic share and device-inflation rate, so a partner sending mostly anonymous or recycled traffic moves from auto-pay to manual review.

ShieldLabs scores the traffic and links the identity; every withhold, review, or pay decision stays in your code. The steps below build that flow.

## Build it

<Steps>
  <Step title="Capture the source on every visit">
    Install the [snippet](/setup/snippet) on the landing pages affiliate and paid traffic arrives on. It records channel, referrer, and UTM attribution for every request, so the [Traffic Sources](/features/traffic-analytics) tables populate with no extra work. You only need to stash the `requestID` so a later conversion ties back to the scored visit, plus the inbound UTM, since the webhook and History API return the score and identity but **not** the channel/UTM.

    ```html landing.html theme={null}
    <script type="module">
      const mod = await import(
        'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'
      );
      // The browser does NOT compute the Score, DeviceID, or VisitorID; those
      // arrive by webhook / History API. The first arg is the client IP, not a
      // score. Pass undefined for the userHID slot so the callback fires.
      mod.checkAnonymous(undefined, (ip, requestID) => {
        document.cookie = `shield_rid=${requestID}; max-age=3600; SameSite=Lax`;
        const utm = new URLSearchParams(location.search).get('utm_source') || '';
        document.cookie = `shield_utm=${encodeURIComponent(utm)}; max-age=3600; SameSite=Lax`;
      });
    </script>
    ```

    Make sure inbound affiliate links carry UTM parameters. ShieldLabs records `utm_source`, `utm_medium`, `utm_campaign`, the `referrer_domain`, and the resolved `channel` per request; for paid traffic it also records `traffic_source.click_id_type` (for example `gclid`), surfaced in the Data export. Most affiliate traffic lands under the **Referral** channel, where you drill into the referrer host or `utm_source` to find the partner.
  </Step>

  <Step title="Rank sources by risk on the dashboard">
    Rank each source by the anonymous-traffic share it delivers on [Traffic Sources](/features/traffic-analytics), so you measure cost per real visit instead of cost per click. The Channels table, per-referrer drill-down, and free per-request export are walked through in [Measure Traffic Quality](/use-case/traffic-quality); this page covers the payout decision built on top of it.
  </Step>

  <Step title="Tie a conversion to its scored visit">
    When a conversion fires, read the score for the `requestID` you stashed and record it alongside the affiliate and UTM. The score arrives on the [webhook](/setup/webhooks); cache it indexed by `request_id`, or fall back to a [History API](/api/server-api) read by `request_id`. Both return the same shape: `score`, the explainable `signals` array (`[{ name, weight }]`), `device_id`, and the visit's `public_ip`. Your payout logic then withholds or routes to review instead of paying automatically.

    ```js api/conversion.js theme={null}
    app.post('/api/conversion', async (req, res) => {
      const { affiliateId, eventType } = req.body;
      const shieldRequestId = req.cookies.shield_rid;

      // Read the score for this visit (waitForScore is the shared webhook-cache
      // helper; it returns the result, History API on a miss).
      const shield = shieldRequestId ? await waitForScore(shieldRequestId, 2000) : null;
      const score = shield?.score ?? 0;        // 0-100, default 0 if not yet in
      const signals = shield?.signals ?? [];   // explainable: [{ name, weight }]

      // Record the conversion with its quality context. Always store, never drop.
      const conversion = await db.conversions.create({
        affiliateId,
        eventType,
        shieldScore: score,
        shieldSignals: signals,
        deviceId: shield?.device_id,
        country: shield?.public_ip?.country,   // public/VPN-side country
        utmSource: req.cookies.shield_utm,       // channel/UTM are client-captured
      });

      // YOUR payout logic. Bands are a guide, the thresholds are yours.
      if (score >= 60) {
        await holdForReview(conversion.id, 'high_risk_visit');          // High: withhold
      } else if (score >= 30) {
        await approveWithClawbackWindow(conversion.id);                 // Medium: delayed
      } else {
        await approvePayout(conversion.id);                            // Clean / Low: pay
      }

      return res.json({ ok: true });
    });
    ```

    <Tip>
      Store the score and its `signals` on every conversion, even the ones you pay. A single Medium visit is noise, but a partner whose conversions are 40% Medium-and-High is a reweighting decision — you cannot rank sources you did not record. The webhook's [`detection_flags`](/glossary#detection-flags) also gives boolean shortcuts (`suspicious_paid_click`, `ip_mismatch`, `anti_detect_browser`) for fast routing without re-deriving from `signals`.
    </Tip>
  </Step>

  <Step title="Dedup one device arriving under rotated IPs">
    The hardest abuse to see is a single device that clears cookies and rotates its public IP between events, so each visit looks like a fresh user from a fresh location. Cookie- and IP-based dedup both fail. The durable `device_id` defeats it: the same browser produces the same DeviceID after a cookie clear, an incognito window, or an IP rotation. Dedup conversions on `device_id` inside your attribution window, not on IP or cookie.

    ```js api/webhook.js theme={null}
    app.post('/shieldlabs/webhook', async (req, res) => {
      // Verify the X-Shield-Signature HMAC over the raw body first
      // (see /setup/webhooks), then ack fast.
      const { device_id, request_id } = req.body;
      res.status(200).end();
      if (!device_id) return;

      // Has this exact device already converted for this affiliate recently?
      const key = `affiliate:${affiliateForRequest(request_id)}:device:${device_id}`;
      const firstSeen = await store.setIfAbsent(key, request_id, { ttlSeconds: 86400 });

      if (!firstSeen) {
        // Same device_id, same affiliate, inside the window: a repeat device,
        // not a new visitor. Mark it so payout does not double-count.
        await db.conversions.markRepeatDevice(request_id, device_id);
      }
    });
    ```

    For one network behind rotated IPs, contrast the two addresses on the webhook: two conversions with different `public_ip` values but the same `local_ip` (or `local_ip.country`) are a strong tell that one person is rotating exit IPs from a single network. The `ip_mismatch` flag already marks the disagreement for your code. A person who spreads across several separate browsers shows up as several devices, so pair device dedup with the source-level risk ranking so coordinated traffic across browsers still surfaces.
  </Step>

  <Step title="Rank affiliates from the recorded conversions">
    With the score and DeviceID stored on every conversion, a daily job ranks each affiliate by the quality it actually delivered. This feeds your manual review queue and payout terms — there is no in-product rules engine; the ranking and the action both live in your code.

    ```js jobs/rank-affiliates.js theme={null}
    // Daily: summarize each affiliate's last 24h of conversions.
    async function rankAffiliates() {
      const affiliates = await db.conversions.groupBy('affiliateId', {
        last24h: true,
        select: {
          total: true,
          avgScore: true,
          mediumOrHigh: { where: { shieldScore: { gte: 30 } } },
          distinctDevices: { distinct: 'deviceId' },
        },
      });

      return affiliates
        .map((a) => ({
          affiliateId: a.affiliateId,
          conversions: a.total,
          avgScore: a.avgScore,                              // average Risk Score
          maskedShare: a.mediumOrHigh / a.total,             // Medium + High share
          deviceInflation: 1 - a.distinctDevices / a.total,  // repeat-device rate
        }))
        .sort((x, y) => y.maskedShare - x.maskedShare);      // worst sources first
    }
    ```

    A partner at the top of that list — high masked share and high device inflation — is the one to move from auto-pay to manual review, renegotiate, or hold. The decision and the threshold are yours.

    <Tip>
      Cross-check the dashboard before you act. If a `utm_source` ranks badly in your job, open [Traffic Sources](/features/traffic-analytics), filter to it, and confirm the Risk Badge and [Patterns](/features/patterns) tab agree. Two independent views landing on the same partner is a far stronger basis for a payout change than one number.
    </Tip>
  </Step>

  <Step title="Read a device's full history when you need it">
    For a borderline source, reconstruct what a single device has been doing across your whole site. Read the [History API](/api/server-api) by `device_id`.

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

    The response is `{ "data": [ ... ], "total": N }`, newest first in snake\_case. Distinct `user_hid` values on one DeviceID can mean one device behind many accounts; many countries on one DeviceID can mean a single person masking location — contrast the public `country` against the real internal IP country before you read it as real spread. The [Patterns](/features/patterns) "Many Devices on One Account" and "Many Accounts on One Device" surface this shape automatically on the dashboard.

    <Note>
      Account History reads on `account.shieldlabs.ai` are free; the Management History path bills 1 request per returned row (an empty result still bills 1). Webhooks, dashboard views, and exports are free. For high-volume affiliate flows, lean on the free webhook stream and dashboard export, and reserve billed Management reads for the cases you are about to action.
    </Note>
  </Step>
</Steps>

## Test it

Click through one of your affiliate links, then reload it in an incognito window, after clearing cookies, and again in a second browser on the same machine. The `cookie_id` and `visitor_id` change each time, but the `device_id` on the webhook stays the same across the incognito and cleared-cookie runs, so the dedup above counts those as one device. A genuinely separate machine returns a new `device_id` — the line your payout logic relies on.

## Where this fits

<CardGroup cols={2}>
  <Card title="Measure Traffic Quality" icon="chart-line" href="/use-case/traffic-quality">
    Compute cost per real visitor by source, the reporting layer this payout logic sits on top of.
  </Card>

  <Card title="Traffic Sources view" icon="signal-stream" href="/features/traffic-analytics">
    Rank channels, referrers, and UTM by risk badge and anonymous-traffic share.
  </Card>

  <Card title="Patterns" icon="diagram-project" href="/features/patterns">
    Background detection of one-device-many-accounts and many-devices-one-account shapes.
  </Card>

  <Card title="Acting on the Risk Score" icon="arrow-right" href="/guides/acting-on-risk-score">
    The full per-band decision playbook, including signal-aware decisioning.
  </Card>
</CardGroup>
