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

> Check the buyer's account, device and payment moment before you charge, and step up or hold risky orders.

The payment step is where risk matters most: a masked or automated session at checkout is a stronger signal than the same session browsing a catalog. This tutorial reads the buyer's account and a **fresh** [Risk Score (0-100)](/features/risk-scoring) right before the charge and maps the Risk Score to its band, so you can choose the action for each case.

## 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, an anti-detect browser or an automated browser so the session cannot be traced back to a single person or device.

## How ShieldLabs surfaces it

ShieldLabs ties each checkout to the buyer's account and to [everything that account is linked to](/concepts/entities). Pass the account's hashed User HID and ShieldLabs links it to the devices, visitors and public and local IPs it uses, and detects **Multi-accounting** and **Account takeover** on it. A naive checkout trusts the cookie, the session or the buyer's IP, and all three are trivial to reset. The Device ID holds through cleared cookies, incognito mode and IP changes, so a buyer who clears cookies or rotates to a fresh proxy IP between attempts still resolves to the same device. Underneath, the payment step itself is one identification: a Risk Score from 0 to 100 with every [risk signal](/features/risk-signals) named and weighted, so a VPN, proxy, Tor, browser automation or an anti-detect browser on the paying session shows up by name.

ShieldLabs stops payment fraud before the charge: it returns the Risk Score and every named risk signal on the payment step and detects High-Risk Events on the buyer's account. You choose the action for each case: charge, step up to 3-D Secure or a one-time code, or hold for review.

## Prevent payment fraud at checkout

Read the buyer's account and the fresh Risk Score the moment the buyer reaches the payment step. The rule: charge when the payment step is Trusted and the account's history is clean; step up when the payment step is Suspicious, when the device is new to an established account, or when risk signals such as **Tor**, **Anti-detect Browser**, **Browser Automation** or **Abuser Flag** fire; hold for review when the payment step or the account's history is Dangerous, or when you marked the account after an Account takeover event reached you through the API, webhooks or the analytics dashboard. A buyer behind a fresh proxy IP or cleared cookies still resolves to the same device and the same account.

## Build it

<Steps>
  <Step title="Create a ShieldLabs account and get your keys">
    [Start Free](https://app.shieldlabs.ai/) with 5,000 identifications, one time, no credit card, or log in. In the analytics dashboard, add the domain you want to protect under **Integration > Domains**, then open **Integration > API keys** and copy its keys with the copy button next to each. The **Public Key** loads the snippet in the browser. Keep the server credentials on your backend: the **Private API Key** reads the [History API](/api/server-api), and each webhook endpoint has its own `whsec_…` signing secret. See [API keys](/setup/keys) and [Integration](/dashboard/integration).
  </Step>

  <Step title="Force a fresh check at the payment step">
    On signed-in pages you already pass the hashed User HID with `checkAuthenticatedUser`. At the payment step you want a current read, so call `forceCheckAuthenticatedUser`: it runs an identification every time, keeps the current Session ID and restarts the five-minute window. A plain `checkAuthenticatedUser` runs at most one identification every five minutes for the same user within one visit, so a call right after another page's check in the same visit would post nothing and the payment would reach your backend with no request ID. 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. Keep result.requestID:
      // join key to the webhook.
      mod.forceCheckAuthenticatedUser('a1b2c3d4hasheduserid', {
        onInitialized: (result) => {
          if (result.status !== 'initialized') return;
          document.getElementById('shieldlabs-request-id').value = result.requestID;
        },
      });
    </script>

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

    The snippet POSTs the signals to `rest.shieldlabs.ai` automatically; [installing the snippet](/setup/snippet#framework-integrations) covers the framework versions of the same dynamic-import pattern. For a guest checkout with no account, call `forceCheckAnonymous` instead; the handler below then skips the account binding and the account read. [Card Testing](/use-case/card-testing) covers guest checkouts in depth.
  </Step>

  <Step title="Read the buyer's account">
    The payment is one identification. The buyer's account has a history. The shared `accountView` helper from the [Use Case Tutorials](/use-case) reads the account's identifications from the [History API](/api/server-api#read-every-identification-of-one-account) by `user_hid`, and its worst band shows how risky the account has been. To tell whether this payment comes from a device the account has used before, compare its Device ID with the devices on which the account completed a verified action, the trust list from [Returning Visitor Recognition](/use-case/returning-visitor), rather than with every device in the account's history: a taken-over session that browsed signed-in pages before paying already has identifications from its new device under this User HID. 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.

    ```js Read the buyer's account theme={null}
    // Inside /api/checkout, once you have `risk` for this payment.
    // accountView reads the account's newest 100 identifications by user_hid,
    // without this payment and without the 999 rate-limit marker.
    // account.worstBand: 'Dangerous' means the account has been risky before,
    // whatever this payment scores (null when it has no earlier identifications).
    // accountView throws when the History read fails; the full handler below
    // routes that payment to review.
    const account = await accountView(userHid, { excludeRequestId: risk.request_id });

    // A device new to an established account: the account completed verified actions
    // on other devices, not on this one. trustedDevices is your own store, filled by
    // rememberTrustedDevice (Returning Visitor Recognition) after a verified action.
    const knownDevices = await trustedDevices.list(userId); // a Set of Device IDs
    const newDevice = knownDevices.size > 0 && !knownDevices.has(risk.device_id);
    ```

    A Dangerous history, or a payment from a device that is new to an established account, is the account-level shape of a taken-over account or a stolen card. History reads never count against your included identifications.

    Open the buyer's [user card](/dashboard/entity-card) in the analytics dashboard to see its band for the period, its High-Risk Events and each linked device, visitor and IP with the band of the identifications it shares with the account.
  </Step>

  <Step title="Receive the webhook and gate the charge">
    ShieldLabs POSTs one webhook per 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. History reads and webhook delivery never count against your included identifications.

    Branch on the Risk Score of the payment and its band, which already fold in the risk signals, and on the account's history. At the payment step, draw the band lines tighter than elsewhere.

    ```js checkout.js theme={null}
    app.post('/api/checkout', async (req, res) => {
      const { shieldlabsRequestId, paymentData, userId } = req.body;
      // The same hashing you apply before passing the id to the snippet; null for a guest checkout.
      const userHid = userId ? hashAccountId(userId) : null;

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

      // No identification for this payment: hold rather than charge on missing data.
      if (!risk) {
        return res.status(202).json({ status: 'review', reason: 'no_identification' });
      }
      // A signed-in buyer's identification must be theirs. Guests send "anonymous".
      if (userHid && risk.user_hid !== userHid) {
        return res.status(202).json({ status: 'review', reason: 'identification_mismatch' });
      }
      // The 999 rate-limit marker, or no usable Device ID: hold for review.
      if (risk.risk_score > 100 || !risk.device_id || risk.device_id === NIL_DEVICE) {
        return res.status(202).json({ status: 'review', reason: 'unverified_device' });
      }
      const score = risk.risk_score;
      const flags = risk.detection_flags ?? {}; // used by the hard rules below

      // The buyer's account, without this payment, and its trusted devices (previous step).
      // A guest checkout has neither. A failed History read is unverified: hold for review.
      let account = null;
      if (userHid) {
        try {
          account = await accountView(userHid, { excludeRequestId: risk.request_id });
        } catch {
          return res.status(202).json({ status: 'review', reason: 'history_unavailable' });
        }
      }
      const knownDevices = userHid ? await trustedDevices.list(userId) : new Set();
      const newDevice = knownDevices.size > 0 && !knownDevices.has(risk.device_id);
      // Accounts you marked after a High-Risk Event reached you through the API,
      // webhooks or the analytics dashboard.
      const marked = userHid ? await markedAccounts.has(userHid) : false; // your own store

      // Dangerous payment, a Dangerous history or a marked account: hold for review.
      if (score >= 60 || account?.worstBand === 'Dangerous' || marked) {
        await flagForReview(userId, risk);
        return res.status(202).json({ status: 'review', reason: 'dangerous_or_marked' });
      }

      // Suspicious payment, or a device new to an established account: step up.
      if (score >= 30 || newDevice) {
        return res.status(202).json({ status: 'step_up', method: '3ds' });
      }

      // Trusted payment on a device from the account's trust list (or a trust list that is
      // still empty): charge in your own flow. After a successful charge, add the device
      // with rememberTrustedDevice(userId, risk).
      return processPayment(paymentData, userId, res);
    });
    ```

    `NIL_DEVICE` is the all-zero Device ID (`00000000-0000-0000-0000-000000000000`), exported by the [shared helpers](/use-case). An all-zero Device ID means no usable device signals reached ShieldLabs for that identification; the rate-limit marker (Risk Score 999) is one such case. Route it to review rather than allowing it, as the guard above does.

    ShieldLabs returns a Risk Score and every named risk signal on each identification, and detects High-Risk Events on your users. You choose the action for each case (charge, step up, review or block) and act on the result in your backend.

    In the analytics dashboard, the payment's [identification card](/dashboard/identification-card) shows its risk signals with their weights next to the risk of the account, device, visitor and IP behind it.

    <Frame caption="One identification and the risk of the user, device, visitor and IP it belongs to, in the analytics dashboard.">
      <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/identification-identity-risk.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=dbd706ac40431a2c09610aa03f4499f1" alt="The Details and Risk of the identities in this call sections of one identification in the analytics dashboard: the Visitor ID, Device ID, Cookie ID, Session ID, User HID a91f3c7e5b2d4086 and Domain, a red Multi-accounting pill, then Visitor risk Suspicious, Device risk Dangerous, User risk Dangerous and IP risk Suspicious." width="2238" height="768" data-path="images/dashboard/identification-identity-risk.png" />

      <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/identification-identity-risk-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=7963eb5cc311b0e1f302b8ba32fe1164" alt="The Details and Risk of the identities in this call sections of one identification in the analytics dashboard in the dark theme: the Visitor ID, Device ID, Cookie ID, Session ID, User HID a91f3c7e5b2d4086 and Domain, a red Multi-accounting pill, then Visitor risk Suspicious, Device risk Dangerous, User risk Dangerous and IP risk Suspicious." width="2238" height="768" data-path="images/dashboard/identification-identity-risk-dark.png" />
    </Frame>
  </Step>

  <Step title="Compare the countries behind a VPN">
    For a stolen-card buyer who hides their location, compare the two countries. The webhook carries two IP objects. `public_ip` is the public address and its country, which a VPN or proxy can put anywhere. `local_ip` is the Local IP: the address the browser itself reports, which can differ from the public IP behind a VPN or proxy and can expose the network behind the mask. `local_ip.ip` is empty when the Local IP was not captured. `detection_flags.ip_mismatch` is `true` whenever the two are different addresses. It is informational, adds nothing to the Risk Score and can be ordinary on mobile networks, so compare `local_ip.country` with `public_ip.country` rather than branching on the flag alone. A buyer whose public IP says one country while the Local IP sits in another is pretending to shop from somewhere else.

    ```js theme={null}
    // Inside your /api/checkout handler, after the guard.
    const publicCountry = risk.public_ip?.country;
    const localCountry  = risk.local_ip?.country; // empty when not captured; null on the History fallback
    if (localCountry && publicCountry && localCountry !== publicCountry) {
      return res.status(202).json({ status: 'step_up', method: '3ds' });
    }
    ```

    When the Local IP was not captured, the `stun_not_checked` risk signal can show that the network check did not complete, 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`, `anti_detect_browser`, `browser_automation`, ...) are stable booleans. Webhook `signals[].name` is a slug (`antidetect_browser`), not a display label.

    ```js theme={null}
    // `flags` from the guard: detection_flags on the webhook and on the History fallback.
    if (flags.tor || flags.abuser || flags.browser_automation) {
      // Always step up, regardless of the numeric band.
      return res.status(202).json({ status: 'step_up', method: '3ds' });
    }
    ```
  </Step>

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

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

## Reading the Risk Score at checkout

The three bands and their ranges are defined in [Risk Scoring](/features/risk-scoring); the full action playbook is in [Acting on results](/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 |
| - | - | - |
| **Trusted** (0-29) | Pass through | Allow, charge, log the `signals` |
| **Suspicious** (30-59) | Second look | Step up to 3DS or OTP before the charge |
| **Dangerous** (60-100) | Review or challenge | Hold for review or require verification |

The Risk Score already folds in the risk signals, so you branch on the Risk Score and its band rather than on individual entries. Each entry in `signals` carries a stable slug in `name` and its `weight`; the table gives the slug and the label. The [risk signals](/features/risk-signals) reference lists the full set with weights.

| Risk signal (`signals[].name`) | Label | Weight | Why it matters at payment |
| - | - | -: | - |
| `tor` | Tor | 99 | Connection exits through the Tor network. Rare for legitimate buyers. Usually a hard challenge or block. |
| `browser_automation` | Browser Automation | 60 | The browser is driven by an automation framework: a bot at checkout, common in card testing. |
| `antidetect_browser` | Anti-detect Browser | 60 | Fingerprint-spoofing indicators. Common in coordinated payment abuse. |
| `os_mismatch` | OS Mismatch | 60 | The OS the browser claims does not match other evidence. A spoofing indicator. |
| `proxy` | Proxy | 10 | IP flagged as a proxy. One signal among several; weigh it with the rest. |
| `datacenter_ip` | Datacenter IP | 10 | IP is in a hosting range. Unusual for a real shopper on a personal device. |
| `abuser` | Abuser Flag | 10 | The IP appears on an abuse reputation list. Corroborating on its own. |

Branch on `detection_flags` keys, which match the slugs except for one entry: the flag for Anti-detect Browser is `anti_detect_browser`. Proxy, Datacenter IP, and Abuser Flag are each low weight and stack: a buyer on a flagged datacenter proxy with abuser reputation reaches the Suspicious band from these three together, where any one alone would not. Tor, JavaScript Disabled, OS Mismatch, Anti-detect Browser and Browser Automation are the high-weight single signals that push straight into Dangerous.

<Warning>
  Read a high Risk Score together with its named risk signals and the user's history. A real customer on a corporate VPN can reach the Suspicious band; the signals show why, and you choose the action for each case. Corporate VPNs, privacy browsers and iCloud Private Relay all raise the Risk Score for real customers, so decide on the Risk Score, the `signals`, the account's history and the action context together, and tune your cutoffs gradually. A `vpn` or `privacy_relay` signal alone is weaker evidence than `tor`, `antidetect_browser` or `browser_automation`.
</Warning>

## High-Risk Events on the buyer's account

ShieldLabs detects [Multi-accounting](/features/high-risk-events#multi-accounting) and [Account takeover](/features/high-risk-events#account-takeover) on your users directly. Multi-accounting: several accounts run by one person, linked through the devices and network they share. Account takeover: an existing account appearing in a new environment that points to someone else using it. Each detection carries **Medium** or **High** confidence, depending on the combination of evidence, on its own axis next to the Risk Score, so an account can be Trusted on every identification and still carry an event. High-Risk Events are available in the analytics dashboard, the API and webhooks, and need the hashed User HID you pass with `checkAuthenticatedUser`. The Risk Score and risk signals of the identification remain the input at checkout. When a High-Risk Event arrives for a buyer through the API or webhooks, or when you review it in the analytics dashboard, act on the account. You choose the action for each case, for example marking the account in your own system so its next payment steps up or goes to review, as the `marked` check above does. [Acting on results](/guides/acting-on-risk-score#acting-on-high-risk-events) gives a starting point for each event.

<Frame caption="An account with an Account takeover event in the analytics dashboard.">
  <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/event-account-takeover.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=b04b1e49c0ed515ba9a26d245943ba8f" alt="The user card header for User HID 5e0c2b7d91a4f3c6 in the analytics dashboard: the Dangerous band pill, a red Account takeover pill (High confidence), 9 identifications (7 Trusted, 1 Suspicious, 1 Dangerous) and Details with 2 linked devices and 2 linked countries." width="2254" height="770" data-path="images/dashboard/event-account-takeover.png" />

  <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/event-account-takeover-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=9e36cdcc0f688a727b2d7833db623bb1" alt="The user card header for User HID 5e0c2b7d91a4f3c6 in the analytics dashboard in the dark theme: the Dangerous band pill, a red Account takeover pill (High confidence), 9 identifications (7 Trusted, 1 Suspicious, 1 Dangerous) and Details with 2 linked devices and 2 linked countries." width="2254" height="770" data-path="images/dashboard/event-account-takeover-dark.png" />
</Frame>

## Test it

Confirm the Device ID holds before you wire your cutoffs to live charges. Run a checkout, note the `device_id` from the webhook, then repeat it in an incognito window and after clearing cookies: the `cookie_id` and `visitor_id` change each time, but the `device_id` stays the same, which links one buyer across "fresh" sessions. A second browser on the same machine is a new Device ID; the User HID ties that checkout to the same account. To see the Risk Score react, repeat the checkout through a VPN or proxy and watch `signals` gain a `vpn` or `proxy` entry.

Then open the buyer's [user card](/dashboard/entity-card) in the analytics dashboard: **Linked devices** lists both browsers, each with the band of the buyer's identifications on it.

## Next steps

When a disputed charge lands weeks later, the account and the Device ID you scored here become [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 Multi-accounting event surfaces [one buyer running many accounts](/use-case/multi-accounting).

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

  <Card title="Risk signals" icon="signal" href="/features/risk-signals">
    Every risk 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 Risk Score from 0 to 100 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 account and device history into a dispute package.
  </Card>
</CardGroup>


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