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

# Regional Pricing Abuse

> Honor a regional price only when the buyer's network and account history match the region they claim.

Regional pricing rewards buyers in lower-cost markets with a cheaper rate. The catch is that anyone can sit behind a VPN, a proxy or iCloud Private Relay, point their session at a discount region, and claim the lower price from anywhere. This tutorial reads the account's own country history, the countries on the identification and its risk signals, so your pricing endpoint can check whether the claimed region is trustworthy enough to honor.

## What is regional pricing abuse?

Regional pricing abuse is when a buyer fakes their location, usually with a VPN, proxy, or relay that exits in a low-cost country, to claim a discounted price they are not eligible for. The geolocation looks local, but the network is masking where the person actually sits and pays.

## How ShieldLabs surfaces it

ShieldLabs ties a region claim to the buyer's account and to the network behind the moment of the claim.

* **The account.** Every identification of the account carries the country of its public IP, so its history shows where the buyer has been. ShieldLabs also detects [Impossible travel](/features/high-risk-events#impossible-travel) on the account, at Medium or High confidence, when it appears in locations it could not reach in the time between them. High-Risk Events are available in the analytics dashboard, the API and webhooks.
* **The moment.** Each identification returns two ISO countries plus its [risk signals](/features/risk-signals):
  * **`public_ip.country`**: the country of the public IP, which a VPN can set to any region.
  * **`local_ip.country`**: the country of the Local IP, the address the browser itself reports, which can differ from the public IP behind a VPN or proxy. Empty when it was not captured; the handler then compares the claim with `public_ip.country` and relies on the masking signals.

Compare the claimed region against `local_ip.country` when it is present, falling back to `public_ip.country`. `detection_flags.ip_mismatch` marks two different addresses; it adds nothing to the [Risk Score (0-100)](/features/risk-scoring) and can be ordinary on mobile networks, so the country comparison is the check that matters for a price. The Device ID holds through cleared cookies, incognito mode and IP changes, so a region-shopper who re-rolls the session resolves to the same device. You choose the price for each case.

## Prevent region-shopping

Read the account's country history, both countries on the identification and its risk signals when the buyer confirms a region. The rule: honor the discount when the claimed region matches the network country, no masking signal fires, and the account has used that region before (or has no history yet). Otherwise (the account has only been seen elsewhere, the two countries on the identification disagree, the session carries a VPN, Proxy, Tor, Privacy Relay, Datacenter IP or Browser VPN/Proxy signal, or the network country does not match the claim) fall back to the standard price or ask for a billing-country check. You gate a price, not a person.

The countries an account has used are on its [user card](/dashboard/entity-card) in the analytics dashboard, under **Linked countries** (public IP) and **Linked local countries** (Local IP), each with its identification count. A VPN can set the public IP country anywhere, so trust a region the account has used on its Local IP.

<Frame caption="The countries one account has used, in the analytics dashboard.">
  <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/user-card-linked-countries.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=5009d52d5f16758aa523d53a20487a69" alt="The user card for User HID a91f3c7e5b2d4086 in the analytics dashboard with Linked countries open: US with 10 identifications and DE with 2." width="2254" height="1138" data-path="images/dashboard/user-card-linked-countries.png" />

  <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/user-card-linked-countries-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=a090ab43da72d8dd998d4dc4e0acacd0" alt="The user card for User HID a91f3c7e5b2d4086 in the analytics dashboard in the dark theme with Linked countries open: US with 10 identifications and DE with 2." width="2254" height="1138" data-path="images/dashboard/user-card-linked-countries-dark.png" />
</Frame>

## 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="Identify when the region is set">
    Load the [snippet](/setup/snippet) on the checkout or plan-selection page. When the buyer selects or confirms a region, call `forceCheckAuthenticatedUser`: it runs an identification every time, keeps the current Session ID and restarts the five-minute window, so the countries and risk signals reflect the session at decision time. A plain `checkAuthenticatedUser` inside five minutes of the last identification in the same visit posts nothing and calls back with `{ status: "not_initialized" }`. Identify when the region is set rather than at submit, so the webhook has time to arrive before the buyer continues. Pass a **hashed or pseudonymous** user id, never a raw email or account id.

    ```html pricing.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 or resolve the country.
      // result.requestID joins the webhook.
      const identify = (region) =>
        mod.forceCheckAuthenticatedUser('a1b2c3d4hasheduserid', {
          onInitialized: (result) => {
            if (result.status !== 'initialized') return;
            document.getElementById('shieldlabs-request-id').value = result.requestID;
            document.getElementById('claimed-region').value = region; // e.g. "BR"
          },
        });

      const select = document.getElementById('region-select');
      // Identify when the buyer picks a region, and once for the preselected one.
      select.addEventListener('change', (e) => identify(e.target.value));
      identify(select.value);
    </script>

    <form id="checkout-form" method="POST" action="/api/price">
      <select id="region-select" name="region">
        <option value="US">United States</option>
        <option value="BR">Brazil</option>
      </select>
      <input type="hidden" id="shieldlabs-request-id" name="shieldlabsRequestId" />
      <input type="hidden" id="claimed-region" name="claimedRegion" />
      <button type="submit">Continue</button>
    </form>
    ```

    For a buyer who is not signed in, call `forceCheckAnonymous` instead; the account read below then has no account to read, so rely on the countries and risk signals.
  </Step>

  <Step title="Receive the webhook and cache both countries">
    ShieldLabs POSTs the webhook about 300 ms after the check, or at most about 10 seconds later when follow-up network checks run. Verify `X-Shield-Signature` on the raw body, then cache the result keyed by `request_id`. The shared [`waitForScore` helper](/use-case) does this and returns the webhook `data`, so `public_ip`, `local_ip` and [`detection_flags`](/glossary#detection-flags) are available to the pricing handler below. When it falls back to the History API, the row carries the public IP country and the History flags but no `local_ip`, so the handler prices that claim as unverified.

    A `data` excerpt where the buyer claims a Brazil price, the public IP exits in `BR`, but the Local IP puts the network in the `US`:

    ```json theme={null}
    {
      "request_id": "8f1d0c2a-7b3e-4a9c-9d2f-1e6a5b4c3d21",
      "device_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "user_hid": "a1b2c3d4hasheduserid",
      "public_ip": { "ip": "203.0.113.42", "country": "BR" },
      "local_ip": { "ip": "198.51.100.23", "country": "US" },
      "risk_score": 15,
      "signals": [
        { "name": "vpn", "weight": 15 }
      ],
      "detection_flags": { "vpn": true, "ip_mismatch": true },
      "observed_at": "2026-06-16T10:00:00Z"
    }
    ```

    The two countries disagree and a VPN signal is present: the claimed region is masked. (`ip_mismatch` is also `true`, because the two addresses differ.) Gate on the country comparison and the risk signals, not on the Risk Score alone.

    The same comparison is on the identification's [card](/dashboard/identification-card) in the analytics dashboard, under **Device and network**: the public IP and its country next to the local IP and its country.

    <Frame caption="One identification with its public IP country and local IP country, in the analytics dashboard.">
      <img className="block dark:hidden" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/identification-device-network.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=9728473b9f77a50b6296fefb2d068ad9" alt="The Device and network section of one identification in the analytics dashboard: desktop, Windows, Chrome, public IP 198.51.100.34 with Country DE, local IP 192.0.2.16 with Local country US, and connection type vpn." width="2238" height="378" data-path="images/dashboard/identification-device-network.png" />

      <img className="hidden dark:block" src="https://mintcdn.com/shieldlabs-725d18f1/JyleDzUFYU3SXP4Q/images/dashboard/identification-device-network-dark.png?fit=max&auto=format&n=JyleDzUFYU3SXP4Q&q=85&s=414fe7fa2f4420caf42fc14ae3dc34a6" alt="The Device and network section of one identification in the analytics dashboard in the dark theme: desktop, Windows, Chrome, public IP 198.51.100.34 with Country DE, local IP 192.0.2.16 with Local country US, and connection type vpn." width="2238" height="378" data-path="images/dashboard/identification-device-network-dark.png" />
    </Frame>
  </Step>

  <Step title="Gate the price on country, account and risk signals">
    Wait briefly for the identification, then weigh four things: does the claim match a country the account used in earlier sessions, is the session masked, do the two countries on the identification disagree, and does the network country match the claimed region. Any masking signal or a country mismatch is enough to stop honoring the discount. Branch on the `detection_flags` booleans and the country fields, not on label text.

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

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

      // No identification is not "verified": default to the standard price
      // rather than handing out a discount on missing data.
      if (!risk) return standard('verifying');
      // The identification must belong to the buyer who claims the price.
      if (userHid && risk.user_hid !== userHid) return standard('identification_mismatch');
      // The 999 rate-limit marker.
      if (risk.risk_score > 100) return standard('region_unverified');
      // A History fallback row has no local_ip and no browser_vpn_proxy flag.
      if (risk.source === 'history') return standard('region_unverified');

      const flags = risk.detection_flags ?? {}; // detection_flags booleans, the stable contract

      // Network-level masking that makes the exit country untrustworthy: verify before any discount.
      const masked =
        flags.vpn || flags.proxy || flags.tor || flags.privacy_relay ||
        flags.datacenter_ip || flags.browser_vpn_proxy;
      if (masked) return standard('region_unverified_masked', { verify: true });

      // Compare the claimed region to the Local IP country when captured;
      // otherwise fall back to the public IP country.
      const publicCountry = risk.public_ip?.country;
      const localCountry = risk.local_ip?.country;
      const networkCountry = localCountry || publicCountry;
      const countriesDisagree = Boolean(localCountry && publicCountry && localCountry !== publicCountry);

      // The public IP countries of the account's earlier sessions, from its newest 100
      // identifications. This session's own identifications (this page and the signed-in
      // pages before it) exit where the buyer sits now, so they cannot vouch for the claim.
      // Empty for a guest or a new account. A failed History read is unverified.
      let earlierCountries = new Set();
      if (userHid) {
        try {
          ({ countries: earlierCountries } = await accountView(userHid, {
            excludeSessionId: risk.session_id,
          }));
        } catch {
          return standard('region_unverified', { verify: true });
        }
      }
      const newToAccount = earlierCountries.size > 0 && !earlierCountries.has(claimedRegion);

      if (countriesDisagree || networkCountry !== claimedRegion || newToAccount) {
        return standard(countriesDisagree ? 'region_countries_disagree' : 'region_mismatch', {
          verify: true,
        });
      }

      // Countries line up, the session is not masked, and the account used the region in an
      // earlier session (or has no earlier session yet).
      return res.json({ price: regionalPrice(claimedRegion), region: claimedRegion });
    });
    ```

    The account check reads the account's countries with the shared `accountView` helper from the [Use Case Tutorials](/use-case#the-shared-helpers) and leaves out the current Session ID; if the History read fails, the buyer gets the standard price and a verification step. The identification for the preselected region and every signed-in page earlier in this session already exit from the country being claimed, so only earlier sessions can show that the account has been there. History reads never count against your included identifications.

    ShieldLabs returns the two countries, the Risk Score, every named risk signal and the `detection_flags` on each identification, and detects Impossible travel on your users. You choose the price for each case and act on it in your backend.
  </Step>
</Steps>

## Reading the signals for a price decision

For a checkout decision you weigh the Risk Score and its bands. For a regional-price claim the country comparison carries most of the weight, because a masked session can score in the Trusted band yet still be hiding its location. The three bands are defined in [Risk Scoring](/features/risk-scoring); here is how to read the inputs together.

| Country vs claimed region | Masking signal in `signals` | Reasonable action |
| - | - | - |
| Match | None | Honor the regional price |
| Match | VPN / Proxy / Privacy Relay present | Verify before discount; a corporate VPN can match by coincidence |
| Claim differs from every country the account has used | Any | Standard price, ask for verification |
| `local_ip.country` differs from `public_ip.country` | Any | Standard price, ask for verification |
| Mismatch (`public_ip.country` ≠ claim) | None | Standard price, ask for verification |
| Mismatch | Any present | Standard price, or hold for review |
| Unknown (no webhook yet) | Unknown | Standard price until verified |

Each entry in `signals` carries a stable slug in `name` and the weight it added in `weight`; the table gives the slug, the label and the weight. Branch on the matching `detection_flags` boolean. The full table lives in [Risk Scoring](/features/risk-scoring).

| Risk signal (`signals[].name`) | Label | Weight | Why it breaks a region claim |
| - | - | -: | - |
| `tor` | Tor | 99 | Connection exits through the Tor network, so the country is the exit node, not the buyer. |
| `browser_vpn_proxy` | Browser VPN/Proxy | 30 | An in-browser VPN or proxy extension routes the session, so the exit country was toggled, not lived in. |
| `vpn` | VPN | 15 | Traffic routes through a VPN, so the exit country is chosen, not where the buyer sits. |
| `privacy_relay` | Privacy Relay | 15 | iCloud Private Relay relays the connection, so the visible country can differ from the real one. |
| `proxy` | Proxy | 10 | IP flagged as a proxy. The geolocated country reflects the proxy, not the person. |
| `datacenter_ip` | Datacenter IP | 10 | IP is in a hosting range. A real shopper on a personal device rarely exits from a datacenter. |
| `timezone_mismatch` | Timezone Mismatch | 10 | The browser timezone disagrees with the IP timezone, a supporting tell that the geolocated country is not where the device actually is. |

<Warning>
  A legitimate buyer can trip this. A traveler abroad, a corporate VPN, or a privacy browser all detach the network country from where the customer actually lives and pays. This is why the play gates a **price decision, not a ban**: fall back to the standard price, ask for a billing-country or payment check, or hold for review. Decide on the two countries, the risk signals, the account's history and your own verification step together, never on one input alone, and let the buyer prove their region rather than locking them out.
</Warning>

## Test it

Open your pricing page behind a VPN whose exit is in a discount region, then select that region. The webhook should carry the masking signal in `signals` and a `public_ip.country` that does not match the claim, so your endpoint falls back to the standard price. Now clear cookies or reopen the page in incognito: the `device_id` returns the same, so a buyer re-rolling the session to retry the discount still resolves to one device. A different browser is a different Device ID, which is why the account's country history matters here.

ShieldLabs detects **Impossible travel** on signed-in accounts: an account appearing in locations it could not reach in the time between them, at **Medium** or **High** confidence. High-Risk Events are available in the analytics dashboard, the API and webhooks, and need the hashed User HID, which the `forceCheckAuthenticatedUser` call above passes. The countries and risk signals of the identification remain your check at the moment of the claim. When an Impossible travel event arrives for an account 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 asking for verification before its next regional price.

## Next steps

<CardGroup cols={2}>
  <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 the `signals` array carries, and the band definitions.
  </Card>

  <Card title="Checkout protection" icon="cart-shopping" href="/use-case/payment-fraud">
    The fresh-check pattern at the payment step, where the same signals and the buyer's account gate the charge.
  </Card>

  <Card title="Acting on results" icon="code-branch" href="/guides/acting-on-risk-score">
    Turn the Risk Score, the country fields, and the `signals` into allow, verify, and hold logic in your app.
  </Card>
</CardGroup>


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