Most fraud-prevention advice includes "check where the IP address is". That's good advice, as long as you remember what it gives you. The IP country is a signal: cheap, available before a customer types anything, and often informative. It isn't a verdict. Treating it as one blocks travellers, expats, VPN users and people on corporate networks, while a careful fraudster simply uses a connection in the right country.
This guide shows how to turn geolocation into a few well-defined signals and combine them with what you already know.
What IPAPI.GR gives you, and what it doesn't
Each lookup returns location and network data: country_code, region, city, timezone, asn, isp, organization. On any paid plan, the risk object adds is_ai, is_crawler and is_scanner. They tell you whether an address belongs to a known AI crawler, crawler or scanner, so you don't have to maintain the lists yourself.
What it doesn't give you: a fraud score, a risk percentage, VPN, proxy or Tor detection, or any judgement about whether a person is honest. Anything in this article that looks like a score is one you build, from rules you can explain. That's a feature. When a customer complains, you can say exactly why their order was held.
Signals worth computing
1. IP country vs. what the customer told you
Compare the IP's country_code with the billing country, the shipping country and the country of the card (the card issuer country is reported by most payment processors).
- All agree: mildly reassuring.
- IP differs from all of them: worth a little more caution, especially combined with other signals.
- Billing and card country differ from each other: often more informative than the IP. That's data from your payment processor, not from geolocation.
Don't overweight it. Cross-border shopping, holidays and people living near borders all produce mismatches, as does a corporate network that exits in another country. In some regions, mismatches are simply normal.
2. Network type from the ASN
A checkout from a cloud server's address is unusual for most consumer shops. The asn and organization fields let you recognise hosting networks. There's no built-in flag, so you maintain a list, as described in detect data-centre and hosting IPs by ASN. Privacy relays and corporate VPNs also look like data centres, so this signal deserves friction, not rejection.
3. Impossible travel
For logins: if an account signs in from Athens, and twenty minutes later from Singapore, one of those sessions deserves a question. Use the country, or at most the region, never the coordinates. City and coordinates are approximate, and mobile carriers can place a user hundreds of kilometres away. Comparing countries between two logins, with a generous time window, is robust enough. Remember that one device can switch between IPv4 and IPv6, and that the two can geolocate differently. See IPv6 geolocation.
4. Time zone vs. browser time zone
The IP lookup's timezone can be compared with the browser's Intl.DateTimeFormat().resolvedOptions().timeZone, sent along with the form. A big difference can mean a VPN or remote access, or just a traveller who hasn't changed their settings. It's a weak signal and best used only for review queues.
5. Verified bots and scanners
On paid plans, risk.is_scanner tells you an address belongs to a known internet scanner. A scanner hitting your login form is noise to filter out of your metrics, not a customer. risk.is_crawler and risk.is_ai let you exempt real search and AI crawlers from bot defences on public pages. A request with a Googlebot user agent but is_crawler: false is not Googlebot.
6. Velocity per network
Many sign-ups or card attempts from one ASN, or one IPv6 /64, in a short window is a classic sign of automation. That's about counting, not location, and it works even when each request on its own looks normal.
Score, then act in steps
Turn the signals into points, add them up, and map ranges of the total to actions. A simple, explainable version:
| Signal | Points |
|---|---|
| IP country ≠ billing country | +2 |
| IP country ≠ billing and ≠ shipping country | +1 more |
| Hosting ASN (your list) | +3 |
| Login country changed within 1 hour | +3 |
| IP time zone ≠ browser time zone (different UTC offset) | +1 |
Lookup failed or country is "No Data" | +0 (unknown, not suspicious) |
| Total | Action |
|---|---|
| 0-2 | Proceed |
| 3-4 | Add friction: 3-D Secure, email or phone verification |
| 5+ | Hold for manual review |
These numbers are a starting point for illustration, not something we've measured. Calibrate them on your own outcomes: chargebacks, refunds, confirmed abuse. The structure matters more than the values. Unknown never counts as suspicious, signals add up instead of vetoing, and the strongest action is a human review.
Node.js
const HOSTING_ASNS = new Set(['16509', '14618', '15169', '396982', '8075', '14061', '24940', '16276', '63949', '20473']); // see the data-centre article
const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"
async function lookup(ip) {
try {
const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${process.env.IPAPI_KEY}`,
{ signal: AbortSignal.timeout(2000) });
return res.ok ? await res.json() : null;
} catch {
return null;
}
}
function utcOffset(tz, at = new Date()) {
try {
const part = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'longOffset' })
.formatToParts(at).find((p) => p.type === 'timeZoneName').value; // "GMT+02:00" or "GMT"
return part === 'GMT' ? '+00:00' : part.slice(3);
} catch {
return null;
}
}
export async function checkoutSignals({ ip, billingCountry, shippingCountry, browserTimeZone }) {
const geo = await lookup(ip);
const ipCountry = known(geo?.country_code);
const asn = known(geo?.asn);
const ipTz = known(geo?.timezone);
const reasons = [];
let score = 0;
if (ipCountry && billingCountry && ipCountry !== billingCountry) {
score += 2; reasons.push(`ip_country=${ipCountry} billing=${billingCountry}`);
if (shippingCountry && ipCountry !== shippingCountry) { score += 1; reasons.push('ip_country≠shipping'); }
}
if (asn && HOSTING_ASNS.has(asn)) { score += 3; reasons.push(`hosting_asn=${asn}`); }
if (ipTz && browserTimeZone) {
const a = utcOffset(ipTz), b = utcOffset(browserTimeZone);
if (a && b && a !== b) { score += 1; reasons.push(`tz ${ipTz}≠${browserTimeZone}`); }
}
const action = score >= 5 ? 'review' : score >= 3 ? 'step_up' : 'proceed';
return { score, action, reasons, ipCountry, asn }; // store reasons with the order
}Store the reasons with the order. When a customer asks why they had to verify, or a colleague reviews a held order, the reasons answer it.
Python
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import requests
session = requests.Session()
HOSTING_ASNS = {"16509", "14618", "15169", "396982", "8075", "14061", "24940", "16276", "63949", "20473"}
def known(v):
return None if v in (None, "", "No Data") else v # missing fields are "No Data"
def lookup(ip):
try:
r = session.get(f"https://ipapi.gr/api/lookup/{ip}", params={"key": os.environ["IPAPI_KEY"]}, timeout=2)
return r.json() if r.status_code == 200 else {}
except requests.RequestException:
return {}
def offset(tz):
try:
return datetime.now(ZoneInfo(tz)).utcoffset()
except Exception:
return None
def checkout_signals(ip, billing_country, shipping_country=None, browser_tz=None):
geo = lookup(ip)
ip_country, asn, ip_tz = known(geo.get("country_code")), known(geo.get("asn")), known(geo.get("timezone"))
score, reasons = 0, []
if ip_country and billing_country and ip_country != billing_country:
score += 2
reasons.append(f"ip_country={ip_country} billing={billing_country}")
if shipping_country and ip_country != shipping_country:
score += 1
reasons.append("ip_country!=shipping")
if asn in HOSTING_ASNS:
score += 3
reasons.append(f"hosting_asn={asn}")
if ip_tz and browser_tz and offset(ip_tz) is not None and offset(browser_tz) is not None \
and offset(ip_tz) != offset(browser_tz):
score += 1
reasons.append(f"tz {ip_tz}!={browser_tz}")
action = "review" if score >= 5 else "step_up" if score >= 3 else "proceed"
return {"score": score, "action": action, "reasons": reasons}PHP: impossible travel at login
<?php
function known($v) {
return ($v === null || $v === '' || $v === 'No Data') ? null : $v; // missing fields are "No Data"
}
function ip_country(string $ip): ?string {
$ch = curl_init('https://ipapi.gr/api/lookup/' . rawurlencode($ip) . '?key=' . urlencode(getenv('IPAPI_KEY')));
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 2]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ($body !== false && $status === 200) ? known(json_decode($body, true)['country_code'] ?? null) : null;
}
/** $last = ['country' => 'GR', 'at' => 1767225600] from the previous successful login, or null. */
function login_needs_check(?array $last, string $ip): bool {
$country = ip_country($ip);
if ($country === null || $last === null || $last['country'] === null) return false; // unknown: no extra step
$minutes = (time() - $last['at']) / 60;
return $country !== $last['country'] && $minutes < 60;
}
// After verifying the password:
if (login_needs_check($user->lastLogin, $_SERVER['REMOTE_ADDR'])) {
// ask for a second factor or confirm by email; never just lock the account
}Go
package fraud
import (
"encoding/json"
"net/http"
"net/url"
"os"
"time"
)
var client = &http.Client{Timeout: 2 * time.Second}
func known(s string) string {
if s == "No Data" { // missing fields are "No Data"
return ""
}
return s
}
type Geo struct {
CountryCode string `json:"country_code"`
ASN string `json:"asn"`
Timezone string `json:"timezone"`
}
func Lookup(ip string) (Geo, bool) {
var g Geo
resp, err := client.Get("https://ipapi.gr/api/lookup/" + url.PathEscape(ip) + "?key=" + url.QueryEscape(os.Getenv("IPAPI_KEY")))
if err != nil {
return g, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&g) != nil {
return g, false
}
g.CountryCode, g.ASN, g.Timezone = known(g.CountryCode), known(g.ASN), known(g.Timezone)
return g, true
}
func Score(g Geo, billing, shipping string, hosting map[string]bool) (int, []string) {
score, reasons := 0, []string{}
if g.CountryCode != "" && billing != "" && g.CountryCode != billing {
score += 2
reasons = append(reasons, "ip_country="+g.CountryCode+" billing="+billing)
if shipping != "" && g.CountryCode != shipping {
score++
reasons = append(reasons, "ip_country!=shipping")
}
}
if hosting[g.ASN] {
score += 3
reasons = append(reasons, "hosting_asn="+g.ASN)
}
return score, reasons
}At the edge: velocity per network
Counting attempts per ASN at the edge stops card-testing bursts before they reach your payment provider. This sketch uses Workers KV. KV is eventually consistent, so treat the count as approximate, or use a Durable Object for exact counters:
const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"
const LIMIT_PER_10_MIN = 30; // tune to your traffic
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method !== 'POST' || url.pathname !== '/checkout/pay') return fetch(request);
let asn = null;
try {
const ip = request.headers.get('CF-Connecting-IP');
const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${env.IPAPI_KEY}`,
{ signal: AbortSignal.timeout(1500) });
if (res.ok) asn = known((await res.json()).asn);
} catch { /* unknown */ }
if (!asn) return fetch(request); // no ASN: rely on your other limits
const bucket = `pay:${asn}:${Math.floor(Date.now() / 600000)}`;
const count = Number(await env.COUNTERS.get(bucket)) + 1;
await env.COUNTERS.put(bucket, String(count), { expirationTtl: 1200 });
if (count > LIMIT_PER_10_MIN) {
return new Response(JSON.stringify({ error: 'Too many payment attempts from your network. Please try again later.' }), {
status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': '600' }
});
}
return fetch(request);
}
};Large consumer ISPs and mobile carriers have millions of customers behind one ASN. Set per-ASN limits well above what any single ISP's real customers produce, or apply them only to ASNs on your hosting list.
Privacy and fairness
Fraud checks process personal data. In the EU, a few points matter in particular:
- Legitimate interest. Recital 47 of the GDPR says that processing "strictly necessary for the purposes of preventing fraud" also constitutes a legitimate interest of the controller. "Strictly necessary" is doing work in that sentence: collect what the check needs, and no more.
- Automated decisions. Article 22 gives people the right not to be subject to a decision "based solely on automated processing" that has legal or similarly significant effects on them. Refusing a purchase or closing an account can fall under that. Routing high scores to human review rather than automatic refusal is the safer design, and it's better for customers too.
- Transparency. Say in your privacy notice that you use IP-derived location and network data for fraud prevention.
- Retention. Keep the signals and reasons as long as you need them for disputes and chargebacks, then delete them.
This is general information, not legal advice. Check the rules for the markets you serve.
Fairness matters commercially as well as legally. Customers in some countries see more false positives from geography-based rules. Watch your step-up and review rates per country, and check that a rule isn't mostly catching legitimate customers from one place.
Measure whether it works
A fraud rule that's never measured gets worse over time. For each signal, track:
- How many orders or logins it fired on.
- Of those, how many turned out to be fraud (chargebacks, confirmed abuse) and how many were legitimate.
- What the friction cost: abandoned checkouts after a step-up.
Drop signals that only add friction. Raise the weight of ones that keep catching real problems. How accurate IP geolocation is has a script for comparing IP country with billing country on your own data. It's a good first look at how often mismatches happen among your honest customers.
FAQ
Does IPAPI.GR provide a fraud score?
No. It provides location and network data, plus verified AI, crawler and scanner flags on every paid plan. Scores are built by you, from rules you can explain.
Can it detect VPNs or proxies?
No. IPAPI.GR doesn't detect VPNs, proxies or Tor. A hosting ASN is a partial hint, because many VPNs run in data centres, but it also matches legitimate users.
Should I decline orders when the IP country doesn't match the billing country?
Generally no. Mismatches are common among honest customers. Use them as one input that can trigger extra verification or review.
What if the lookup fails during checkout?
Treat it as "no signal". Score it 0 and let the payment proceed under your other checks. A geolocation timeout should never cost you a legitimate sale.
Related: detect data-centre and hosting IPs by ASN · verify Googlebot and Bingbot.