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

# Payment Fraud at Checkout

> Learn how to detect and prevent payment fraud at checkout: read device and anonymity signals before you charge, and challenge masked orders.

The payment step is where anonymity matters most: a masked session at checkout is a stronger signal than the same session browsing a catalog. This pattern gets a **fresh** [Risk Score (0–100)](/features/risk-scoring) right before the charge, then branches on the Score and its band so your checkout code can decide what to do.

## What is payment fraud at checkout?

Payment fraud at checkout is the use of stolen cards, stolen accounts, or coordinated fake identities to push a charge through the payment step before it can be caught. The tell is concealment: the buyer hides behind a VPN, proxy, Tor, a datacenter IP, or an anti-detect browser so the session cannot be traced back to a single person or device.

## How ShieldLabs surfaces it

ShieldLabs resolves the buyer to a set of [identifiers](/features/identification) and returns a Risk Score with named [anonymity signals](/features/anonymity-signals). A naive checkout trusts the cookie, the session, or the buyer's IP — all three are trivial to reset, so a fraudster clears cookies, opens incognito, or rotates to a fresh proxy IP, and a cookie- or IP-keyed rule treats each try as a brand-new buyer. The durable **DeviceID** is derived server-side, so it survives cleared cookies, incognito, and IP rotation and recognizes the same device behind a string of "new" checkout attempts.

ShieldLabs surfaces the Score and the reasons; your checkout code owns the verdict.

## Prevent payment fraud at checkout

Read the fresh Risk Score and the durable `DeviceID` the moment the buyer reaches the payment step. The rule your code applies: gate the charge on the Score and its band, drawn tighter at payment than on a low-stakes page, and step up or hold when masking signals like **Tor**, **Anti-detect Browser**, or **Abuser Flag** fire, or when the `DeviceID` matches a device tied to past abuse. The outcome is that a buyer hiding behind a fresh proxy IP or cleared cookies still resolves to the same device, so your code can allow the clean charge, ask for 3DS or OTP on the borderline one, and route the high-band one to review.

## 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="Force a fresh check at the payment step">
    On page load you may already run `checkAuthenticatedUser` for analytics. At the payment step you want a current read, so use the `forceCheck*` variant: it clears the session and runs a new identify call immediately, scoring the session as it is at payment time, not a stale score from page load. Pass a **hashed or pseudonymous** user id, never a raw email or account id.

    ```html checkout.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 Risk Score. The callback's first arg
      // is the client IP the server saw (not a score). Keep the requestID:
      // it is your join key to the webhook you receive server-side.
      mod.forceCheckAuthenticatedUser('a1b2c3d4hasheduserid', (ip, requestID) => {
        document.getElementById('shield-request-id').value = requestID;
      });
    </script>

    <form id="checkout-form" method="POST" action="/api/checkout">
      <input type="hidden" id="shield-request-id" name="shieldRequestId" />
      <!-- payment fields -->
      <button type="submit">Pay</button>
    </form>
    ```

    The snippet POSTs the signals to `rest.shieldlabs.ai` automatically; [installing the snippet](/setup/snippet) covers the React, Vue, Angular, Preact, and Svelte versions of the same dynamic-import pattern.
  </Step>

  <Step title="Receive the webhook and gate the charge on Score plus band">
    ShieldLabs POSTs one signed webhook per scored identification. Verify `X-Shield-Signature` on the raw body, then cache the result keyed by `request_id` so the checkout request can look it up — the shared [`waitForScore` helper](/use-case) (defined once for every tutorial) does this read, polling the cache and falling back to the [History API](/api/server-api) by `request_id`. Delivery is at-most-once with no retries, so the History fallback covers a dropped webhook; each returned row bills 1 request while the webhook is free.

    Branch on the Score and its band, which already fold in the anonymity signals. At the payment step, draw the band lines tighter than elsewhere.

    ```js checkout.js theme={null}
    app.post('/api/checkout', async (req, res) => {
      const { shieldRequestId, paymentData, userId } = req.body;

      // Wait up to ~2s for the webhook; falls back to the History API.
      const shield = await waitForScore(shieldRequestId, 2000);

      // No result yet is not the same as "clean". Hold for review rather than
      // silently letting a payment through on missing data.
      if (!shield) {
        return res.status(202).json({ status: 'review', reason: 'verifying' });
      }

      const score = shield.score;

      // High band: your code decides to block or hard-challenge.
      if (score >= 60) {
        await flagForReview(userId, shield);
        return res.status(202).json({
          status: 'verify',
          reason: 'Extra verification is required to complete this payment.',
        });
      }

      // Medium band: step up to 3DS / OTP before the charge.
      if (score >= 30) {
        return res.status(202).json({ status: 'step_up', method: '3ds' });
      }

      // Clean / Low band: proceed with the charge in your own flow.
      return processPayment(paymentData, userId, res);
    });
    ```

    The block, step-up, and review actions run in **your** application. ShieldLabs returns the Score and its `signals`; your checkout code owns the verdict.
  </Step>

  <Step title="Read the real network behind a VPN">
    For a stolen-card buyer who hides their location, the webhook carries **two** IPs. `public_ip.country` comes from the public IP, which a VPN can put anywhere; `local_ip.country` is the real network IP, which can expose the network behind the VPN. When the two disagree, `detection_flags.ip_mismatch` is set to `true` — a buyer pretending to shop from one country while their real connection sits in another.

    ```js theme={null}
    // Inside your /api/checkout handler, after you have `shield`.
    const claimed = shield.public_ip?.country;
    const real    = shield.local_ip?.country;
    if (real && claimed && real !== claimed) {
      // ip_mismatch is set too; treat this as a step-up trigger.
      return res.status(202).json({ status: 'step_up', method: '3ds' });
    }
    ```

    `local_ip.ip` is empty when the follow-up network check could not complete; the **Stun not checked** signal may fire instead (the `stun_not_checked` flag), so you do not silently lose the comparison.

    For a hard rule that does not depend on the band, branch on the [`detection_flags`](/glossary#detection-flags) object: its keys (`vpn`, `tor`, `proxy`, `datacenter_ip`, `abuser`, `os_mismatch`, `ip_mismatch`, `anti_detect_browser`, ...) are stable booleans, safer than the human-readable `signals` labels.

    ```js theme={null}
    if (shield.detection_flags.tor || shield.detection_flags.abuser) {
      // Always step up, regardless of the numeric band.
      return res.status(202).json({ status: 'verify' });
    }
    ```
  </Step>

  <Step title="Re-check on later sensitive actions">
    For a high-value order or a follow-up withdrawal, run another `forceCheck*` at that moment. Each sensitive action deserves its own fresh check rather than a reused score.

    ```js theme={null}
    mod.forceCheckAuthenticatedUser('a1b2c3d4hasheduserid', (ip, requestID) => {
      fetch('/api/post-purchase', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ requestID, action: 'high_value_order' }),
      });
    });
    ```
  </Step>
</Steps>

## Reading the Risk Score at checkout

The four bands and their ranges are defined in [Risk Scoring](/features/risk-scoring); the full action playbook is in [Acting on the Risk Score](/guides/acting-on-risk-score). The payment step is a good place to draw the same band tighter than you would elsewhere:

| Band               | At a low-stakes page | At checkout                                                   |
| ------------------ | -------------------- | ------------------------------------------------------------- |
| **Clean** (0–9)    | Pass through         | Allow, charge                                                 |
| **Low** (10–29)    | Allow, log           | Allow, log the `signals`                                      |
| **Medium** (30–59) | Second look          | Step up to 3DS or OTP before the charge                       |
| **High** (60–100)  | Review or challenge  | Hold for review or require verification, decided by your code |

The Risk Score already folds in the masking signals, so your code branches on the Score and its band rather than the label text. The names below are the human-readable labels in `signals` for visibility and logging; treat them as illustrative. The [Signals](/features/anonymity-signals) reference lists the full set with weights.

| Signal in `signals`     | Why it matters at payment                                                                                               |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Tor**                 | Connection exits through the Tor network. Rare for legitimate buyers. Usually a hard challenge or block decided by you. |
| **Anti-detect Browser** | Fingerprint-spoofing indicators. Common in coordinated payment abuse.                                                   |
| **Proxy**               | IP flagged as a proxy. One signal among several; weigh with the rest.                                                   |
| **Datacenter IP**       | IP is in a hosting range. Unusual for a real shopper on a personal device.                                              |
| **Abuser Flag**         | IP or device appears on an abuse reputation list. Corroborating, not conclusive on its own.                             |
| **OS Mismatch**         | The OS the browser claims does not match other evidence. A spoofing indicator.                                          |

Proxy, Datacenter IP, and Abuser Flag are each low weight and stack: a buyer on a flagged datacenter proxy with abuser reputation reaches the Medium band from these three together, where any one alone would not. Tor, JavaScript Disabled, Anti-detect Browser, and OS Mismatch are the high-weight single signals that push straight into High.

<Warning>
  A legitimate buyer can score high. Corporate VPNs, privacy browsers, and iCloud Private Relay all raise the Risk Score for real customers. Decide on **Score plus `signals` plus the action context**, never the number alone, and tune your thresholds gradually. A `VPN` or `Privacy Relay` signal alone is weaker evidence than `Tor` or `Abuser Flag`.
</Warning>

## Test it

Confirm the durable DeviceID holds before you wire thresholds to live charges. Run a checkout, note the `device_id` from the webhook, then repeat the visit in an incognito window, after clearing cookies, and from a second browser on the same machine: the `cookie_id` and `visitor_id` change each time, but the `device_id` stays the same — that is the identifier that links one buyer across "fresh" sessions. To see the Score react, repeat the checkout through a VPN or proxy and watch the `signals` array gain a `VPN` or `Proxy` entry with a higher Score.

Patterns like *Many Accounts on One Device* live on the [dashboard](/features/patterns) only. They grade an entity (Suspicious or Dangerous over a rolling window, typically 30 days) and are **not** part of the checkout webhook. Use the per-request `score`, `signals`, and `detection_flags` for the in-the-moment charge decision; use Patterns (exported as CSV or JSON) for the offline review of repeat offenders. The two join on `device_id`.

## Next steps

When a disputed charge lands weeks later, the same `DeviceID` you scored here becomes [chargeback-dispute evidence](/use-case/chargeback-fraud). Upstream, the same fresh-check pattern guards a [suspicious login with step-up authentication](/use-case/step-up-authentication) and a [new account at signup](/use-case/new-account-fraud), and the durable device link surfaces [one buyer running many accounts](/use-case/multi-accounting).

<CardGroup cols={2}>
  <Card title="Acting on the Risk Score" icon="code-branch" href="/guides/acting-on-risk-score">
    Turn the Score and `signals` into allow, challenge, review, and block logic in your app.
  </Card>

  <Card title="Signals" icon="signal" href="/features/anonymity-signals">
    Every signal that can appear in `signals`, in plain language, with its weight.
  </Card>

  <Card title="The Risk Score" icon="gauge" href="/features/risk-scoring">
    How the 0 to 100 score is built, what `signals` carries, and the band definitions.
  </Card>

  <Card title="Chargeback evidence" icon="receipt" href="/use-case/chargeback-fraud">
    The after-the-sale half: reconstruct the buyer's device history into a dispute package.
  </Card>
</CardGroup>
