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

> Learn how to detect and prevent regional pricing abuse: catch VPN-masked region switching from the network country before you grant a discount.

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 pattern reads the network country and the anonymity signals on the visit, then lets your pricing code decide 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 returns two two-letter ISO countries plus the anonymity signals on the visit, so your code can tell a real local buyer from a masked one:

* **`public_ip.country`** — resolved from the public IP, which a VPN can set to any region.
* **`local_ip.country`** — resolved from the real network IP, which can reveal the underlying network behind that VPN. Empty when the follow-up network check could not complete; treat absent real-network data as **unverified**, not clean.

When the two disagree, `detection_flags.ip_mismatch` becomes `true` — a strong sign the visible exit country is not the buyer's real one. ShieldLabs surfaces this comparison for your code; it is informational and does not by itself change the [Risk Score (0–100)](/features/risk-scoring). So a public-IP country check by itself is easy to defeat; compare the claimed region against `local_ip.country` when it is present, falling back to `public_ip.country`. The durable `DeviceID` survives cleared cookies, incognito, and IP rotation, so a repeat region-shopper who re-rolls the session resolves to the same device. ShieldLabs surfaces the countries and the signals; your code owns the price.

## Prevent region-shopping

Read both countries, the `ip_mismatch` flag, the anonymity signals, and the durable `DeviceID` when the buyer confirms a region. The rule your code applies: honor the discount only when the network country matches the claimed region and no masking signal fires; otherwise — if the two countries disagree (`ip_mismatch`), the session carries a VPN, Proxy, Tor, Privacy Relay, Datacenter IP, or Browser VPN/Proxy signal, or the exit 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.

## 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="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, force a current read with the `forceCheck*` variant: it clears the session and runs a new identify call immediately, so the country and signals reflect the session at decision time. Pass a **hashed or pseudonymous** user id to the authenticated call, 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.
      // The callback's first arg is the client IP the server saw (not a score);
      // the requestID is your join key to the server webhook.
      document.getElementById('region-select').addEventListener('change', (e) => {
        mod.forceCheckAuthenticatedUser('a1b2c3d4hasheduserid', (ip, requestID) => {
          document.getElementById('shield-request-id').value = requestID;
          document.getElementById('claimed-region').value = e.target.value; // e.g. "BR"
        });
      });
    </script>

    <form id="checkout-form" method="POST" action="/api/price">
      <input type="hidden" id="shield-request-id" name="shieldRequestId" />
      <input type="hidden" id="claimed-region" name="claimedRegion" />
      <button type="submit">Continue</button>
    </form>
    ```
  </Step>

  <Step title="Receive the webhook and cache both countries">
    Within about 1 second of the check, ShieldLabs POSTs a signed risk event to your endpoint. 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 raw webhook payload, so `public_ip`, `local_ip`, and [`detection_flags`](/glossary#detection-flags) are all available to the pricing handler below without any extra mapping.

    A geo-pricing body where the buyer claims a Brazil price, the public IP exits in `BR`, but the real network IP puts the underlying network in the `US`:

    ```json theme={null}
    {
      "request_id": "8f1d0c2a-7b3e-4a9c-9d2f-1e6a5b4c3d21",
      "device_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "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, so `ip_mismatch` is `true`, and a VPN signal is present: the claimed region is masked. Gate on the country comparison and the flag, not on the score alone.
  </Step>

  <Step title="Gate the price on country and anonymity">
    Wait briefly for the score, then weigh four things: is the session anonymized, do the two countries disagree (`ip_mismatch`), does the network country match the claimed region, and how strong is the Score. Any anonymity signal, a country mismatch, or `ip_mismatch` is enough to stop honoring the discount. Branch on the `detection_flags` booleans, not the label text, which can change.

    ```js price.js theme={null}
    app.post('/api/price', async (req, res) => {
      const { shieldRequestId, claimedRegion, 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 "verified". Default to the standard price rather
      // than handing out a discount on missing data.
      if (!shield) {
        return res.json({ price: standardPrice(userId), region: 'standard', reason: 'verifying' });
      }

      const f = shield.detection_flags ?? {}; // detection_flags booleans, the stable contract

      // Network-level masking that makes the exit country untrustworthy.
      const anonymized =
        f.vpn || f.proxy || f.tor || f.privacy_relay || f.datacenter_ip || f.browser_vpn_proxy;

      // Compare the claimed region to the real-network country when captured;
      // otherwise fall back to the public-IP country.
      const realCountry = shield.local_ip?.country || shield.public_ip?.country;
      const countryMatches = realCountry === claimedRegion;

      // Masked session: the claimed region cannot be trusted.
      if (anonymized) {
        return res.json({
          price: standardPrice(userId),
          region: 'standard',
          reason: 'region_unverified_anonymized',
        });
      }

      // Public IP and real internal IP resolve to different networks, or the
      // exit country does not match the claim.
      if (f.ip_mismatch || !countryMatches) {
        return res.json({
          price: standardPrice(userId),
          region: 'standard',
          reason: f.ip_mismatch ? 'region_ip_mismatch' : 'region_mismatch',
          verify: true,
        });
      }

      // Countries line up and the session is not masked: honor the regional price.
      return res.json({ price: regionalPrice(claimedRegion), region: claimedRegion });
    });
    ```

    The fallback, hold, and verification steps run in **your** application. ShieldLabs returns the two countries, the `score`, the `signals`, and the `detection_flags`; your pricing code owns the decision.
  </Step>
</Steps>

## Reading the signals for a price decision

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

| Country vs claimed region                   | Anonymity 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 |
| `ip_mismatch` flag set (countries disagree) | 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                                    |

The names below are the human-readable labels in `signals` for visibility and logging; branch on the matching `detection_flags` boolean, not the label text. Each adds to the Score by the weight shown; the exact weights live in the [Risk Scoring](/features/risk-scoring) table.

| Signal in `signals`   | Weight | Why it breaks a region claim                                                                                                               |
| --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| **Tor**               | 99     | Connection exits through the Tor network, so the country is the exit node, not the buyer.                                                  |
| **Browser VPN/Proxy** | 30     | An in-browser VPN or proxy extension routes the session, so the exit country was toggled, not lived in.                                    |
| **VPN**               | 15     | Traffic routes through a VPN, so the exit country is chosen, not where the buyer sits.                                                     |
| **Privacy Relay**     | 15     | iCloud Private Relay relays the connection, so the visible country can differ from the real one.                                           |
| **Proxy**             | 10     | IP flagged as a proxy. The geolocated country reflects the proxy, not the person.                                                          |
| **Datacenter IP**     | 10     | IP is in a hosting range. A real shopper on a personal device rarely exits from a datacenter.                                              |
| **Timezone Mismatch** | 10     | The browser timezone disagrees with the IP timezone, a corroborating 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 anonymity signals, 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 an `public_ip.country` that does not match the claim, so your code falls back to the standard price. Now clear cookies, reopen the page in incognito, or switch to a second browser on the same machine — the durable `DeviceID` returns the same, so a buyer re-rolling the session to retry the discount still resolves to one device. Across visits, a signed-in account that claims several discount regions in a day also surfaces on the dashboard as the **Multiple Countries on One Account** [pattern](/features/patterns).

## Next steps

<CardGroup cols={2}>
  <Card title="Signals" icon="signal" href="/features/anonymity-signals">
    Every anonymity 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 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 gate the charge.
  </Card>

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