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.
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.
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 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:
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) 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.
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 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.
The countries one account has used, in the analytics dashboard.
Start Free 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, and each webhook endpoint has its own whsec_… signing secret. See API keys and Integration.
2
Identify when the region is set
Load the 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.
pricing.html
<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.
3
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 does this and returns the webhook data, so public_ip, local_ip and 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:
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 in the analytics dashboard, under Device and network: the public IP and its country next to the local IP and its country.
One identification with its public IP country and local IP country, in the analytics dashboard.
4
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.
price.js
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 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.
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; 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.
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.
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.
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.