Scoring fraudulent WooCommerce orders: manual rules vs FraudLabs Pro
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
A fraudulent order costs the goods, the shipping, the refund and a dispute fee when the real cardholder files a chargeback, and too many chargebacks put your payment account at risk. Score every paid order before it ships: read the AVS and CVC checks and risk level your payment processor already returns, compare the buyer's IP location with the billing and shipping address, and count repeat orders from one email or IP. Our tested Node.js webhook service does this with no dependencies, puts risky orders on hold with a note listing every reason, and can add FraudLabs Pro's score, which brings proxy detection, email and phone checks and a shared blacklist that hand-written rules can't match. FraudLabs Pro's free plan covers 500 orders a month; Stripe Radar only sees Stripe payments.
Card fraud on a small store usually looks like a good day: a new customer, a large order, express shipping. Three weeks later the real cardholder disputes the charge. You lose the goods and the shipping, refund the money, and pay a dispute fee on top (Stripe charges $15 per dispute on its standard US pricing, returned if you win). Enough of these and your payment processor starts asking questions. The fix is to score every paid order before it ships. This guide builds that scoring with plain rules, then compares it with FraudLabs Pro's risk scoring.
We tested the webhook service on our machine with Node.js 24 against one mock server standing in for the WooCommerce REST API, Stripe, FraudLabs Pro, the MaxMind GeoLite web service and Nominatim, built from each API's documentation. The webhook signature and delivery behavior were checked against WooCommerce 11.2.1's source code. We didn't run it against a live store or live APIs, and the test orders are made up, so there are no accuracy figures here: only whether each rule fires when it should. FraudLabs Pro's plans and API fields come from its pricing page and API reference, Stripe Radar's from Stripe's pricing page, all checked in October 2026.
1. The short answer
| Manual rules (our script) | FraudLabs Pro | Stripe Radar | |
|---|---|---|---|
| Sees | Every order, any gateway | Every order, any gateway | Stripe payments only |
| Signals | AVS, CVC, Stripe risk level, IP country and distance, velocity, card country | IP, proxy and VPN detection, distance, email and phone checks, card BIN, shared blacklist, velocity | Stripe's network-wide model, card testing detection |
| Cost | A small server; free geolocation tiers | Free for 500 orders a month, then from $29.95 a month | Radar Lite free on standard pricing; custom rules from Radar Plus |
| Upkeep | You tune the points and thresholds | Rules in a dashboard; the model and blacklist are maintained for you | Lowest; little control on the free tier |
2. Manual review vs automatic scoring
Manual review works at ten orders a day, if the same careful person does it every day. It breaks when volume grows, at weekends, and when the warehouse ships before anyone looks. Scoring runs on every order the moment it's paid and leaves a written reason, so the decision is consistent and can be checked later.
3. The signals and how much they weigh
None of these signals proves fraud alone. Travelers, gifts and expats trip them all the time. They add up, and the thresholds decide what happens: 30 points or more adds a review note, 60 or more puts the order on hold.
| Signal | Where it comes from | Points |
|---|---|---|
| Stripe risk level "highest" / "elevated" | The charge's outcome.risk_level, returned on every Stripe charge | 60 / 30 |
| CVC check failed | payment_method_details.card.checks.cvc_check | 40 |
| AVS postcode / street mismatch | address_postal_code_check / address_line1_check; only some countries' banks support AVS | 25 / 10 |
| Card issued in another country than the billing address | card.country | 15 |
| Prepaid card | card.funding | 10 |
| IP in another country than the billing address | MaxMind GeoLite web service (free account) | 15 |
| Ships to another country than billing | The order | 15 |
| IP more than 1,000 km / 300 km from the shipping address | GeoLite coordinates and the shipping postcode geocoded with OpenStreetMap's Nominatim | 15 / 5 |
| 3 or more orders from one email or IP in 24 hours | Counted by the service | 20 |
| Guest checkout above your high-value line (default $500) | The order | 10 |
The IP distance is the weakest signal here: IP geolocation is city-level at best, and a mobile connection can appear hundreds of kilometers from the buyer. That's why it's worth 5 or 15 points, not 50. The strongest signals come from the payment itself: a failed CVC or AVS check, and Stripe's own risk level.
4. The webhook service
WooCommerce sends a signed webhook when an order changes. The service checks the signature, answers straight away, and then scores the order in the background. It acts only once an order is processing, meaning paid: with Stripe, orders are created as pending and turn processing when the payment succeeds. Every scored order gets a fraud_check field, so an order is never scored twice, even after a restart or when staff approve a held order and save it again.
// Scores WooCommerce orders once they're paid ("Order updated" webhook, status processing) and puts risky ones on hold
// with a note. The verdict is saved in a fraud_check order field, so each order is checked once, across restarts.
// Rules use Stripe's AVS/CVC checks and risk level, IP geolocation (MaxMind GeoLite web service), the distance
// from the IP to the shipping address (OpenStreetMap Nominatim) and order velocity. With FLP_KEY set, FraudLabs Pro
// screens the order too and the stricter of the two decisions wins. Node 20+, no dependencies.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const env = process.env;
const HOLD_AT = Number(env.HOLD_AT ?? 60), REVIEW_AT = Number(env.REVIEW_AT ?? 30);
const API = {
stripe: env.STRIPE_API ?? 'https://api.stripe.com', flp: env.FLP_API ?? 'https://api.fraudlabspro.com',
geoip: env.GEOIP_API ?? 'https://geolite.info', geocode: env.GEOCODE_API ?? 'https://nominatim.openstreetmap.org',
};
const wcAuth = 'Basic ' + Buffer.from(`${env.WC_KEY}:${env.WC_SECRET}`).toString('base64');
// ponytail: velocity counts live in memory, lost on restart and not shared between instances; use Redis if you run several
const seen = new Set(), recent = new Map();
async function getJSON(url, headers = {}) {
const r = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
if (!r.ok) throw new Error(`${r.status} from ${new URL(url).host}`);
return r.json();
}
async function stripeCharge(id) {
if (!env.STRIPE_KEY || !/^(ch|py|pi)_/.test(id ?? '')) return null;
const h = { Authorization: `Bearer ${env.STRIPE_KEY}` };
if (id.startsWith('pi_')) return (await getJSON(`${API.stripe}/v1/payment_intents/${id}?expand[]=latest_charge`, h)).latest_charge;
return getJSON(`${API.stripe}/v1/charges/${id}`, h);
}
async function ipLocation(ip) {
if (!env.MAXMIND_ACCOUNT || !ip) return null;
const auth = 'Basic ' + Buffer.from(`${env.MAXMIND_ACCOUNT}:${env.MAXMIND_KEY}`).toString('base64');
const g = await getJSON(`${API.geoip}/geoip/v2.1/city/${ip}`, { Authorization: auth });
return { country: g.country?.iso_code, lat: g.location?.latitude, lon: g.location?.longitude };
}
async function geocode(a) {
if (env.GEOCODE !== 'on' || !a.postcode) return null;
const q = new URLSearchParams({ format: 'jsonv2', limit: '1', postalcode: a.postcode, city: a.city, countrycodes: a.country });
// Nominatim's policy: an identifying User-Agent and at most 1 request per second; fine for order webhooks
const [hit] = await getJSON(`${API.geocode}/search?${q}`, { 'User-Agent': env.GEOCODE_UA ?? 'woo-fraud-score/1.0' });
return hit ? { lat: Number(hit.lat), lon: Number(hit.lon) } : null;
}
const km = (a, b) => {
const r = (d) => (d * Math.PI) / 180, h = Math.sin(r(b.lat - a.lat) / 2) ** 2 +
Math.cos(r(a.lat)) * Math.cos(r(b.lat)) * Math.sin(r(b.lon - a.lon) / 2) ** 2;
return 12742 * Math.asin(Math.sqrt(h));
};
function velocity(order) {
const now = Date.now(), keys = [order.billing.email?.toLowerCase(), order.customer_ip_address].filter(Boolean);
let most = 0;
for (const k of keys) {
const list = (recent.get(k) ?? []).filter((t) => now - t < 86400e3);
list.push(now);
recent.set(k, list);
most = Math.max(most, list.length);
}
return most;
}
// Each rule adds points and a reason. Signals that are missing (no Stripe charge, no geo) add nothing.
export function score(order, { charge, ip, shipGeo, orders24h }) {
const out = [], add = (pts, why) => out.push([pts, why]);
const b = order.billing, s = order.shipping?.country ? order.shipping : b, card = charge?.payment_method_details?.card;
const checks = card?.checks ?? {};
if (charge?.outcome?.risk_level === 'highest') add(60, 'Stripe risk level: highest');
if (charge?.outcome?.risk_level === 'elevated') add(30, 'Stripe risk level: elevated');
if (checks.cvc_check === 'fail') add(40, 'CVC check failed');
if (checks.address_postal_code_check === 'fail') add(25, 'AVS: postcode does not match');
if (checks.address_line1_check === 'fail') add(10, 'AVS: street does not match');
if (card?.country && card.country !== b.country) add(15, `card issued in ${card.country}, billing in ${b.country}`);
if (card?.funding === 'prepaid') add(10, 'prepaid card');
if (ip?.country && ip.country !== b.country) add(15, `IP in ${ip.country}, billing in ${b.country}`);
if (b.country !== s.country) add(15, `ships to ${s.country}, billing in ${b.country}`);
if (ip?.lat != null && shipGeo) {
const d = Math.round(km(ip, shipGeo));
if (d > 1000) add(15, `IP is ${d} km from the shipping address`);
else if (d > 300) add(5, `IP is ${d} km from the shipping address`);
}
if (orders24h > 2) add(20, `${orders24h} orders from this email or IP in 24 h`);
if (!order.customer_id && Number(order.total) >= Number(env.HIGH_VALUE ?? 500)) add(10, 'guest checkout, high value');
const total = Math.min(100, out.reduce((n, [p]) => n + p, 0));
return { total, reasons: out.map(([p, w]) => `+${p} ${w}`) };
}
async function fraudLabs(o) {
if (!env.FLP_KEY) return null;
const b = o.billing, s = o.shipping?.country ? o.shipping : b;
const body = new URLSearchParams({
key: env.FLP_KEY, format: 'json', ip: o.customer_ip_address, email: b.email, user_phone: b.phone ?? '',
first_name: b.first_name, last_name: b.last_name, bill_addr: b.address_1, bill_city: b.city, bill_state: b.state ?? '',
bill_zip_code: b.postcode, bill_country: b.country, ship_first_name: s.first_name, ship_last_name: s.last_name,
ship_addr: s.address_1, ship_city: s.city, ship_state: s.state ?? '', ship_zip_code: s.postcode, ship_country: s.country,
user_order_id: String(o.id), amount: o.total, currency: o.currency,
quantity: String(o.line_items?.reduce((n, i) => n + i.quantity, 0) ?? 1),
payment_mode: /stripe|card/.test(o.payment_method) ? 'creditcard' : o.payment_method === 'ppcp-gateway' ? 'paypal' : 'others',
});
const r = await fetch(`${API.flp}/v2/order/screen`, { method: 'POST', body, signal: AbortSignal.timeout(8000) });
if (!r.ok) throw new Error(`FraudLabs Pro ${r.status}`);
return r.json();
}
async function wc(path, method, body) {
const r = await fetch(`${env.WC_URL}/wp-json/wc/v3/${path}`, {
method, body: JSON.stringify(body), headers: { Authorization: wcAuth, 'Content-Type': 'application/json' },
});
if (!r.ok) throw new Error(`WooCommerce ${r.status} on ${path}`);
}
async function handle(order) {
const [charge, ip] = await Promise.all([stripeCharge(order.transaction_id), ipLocation(order.customer_ip_address)]);
const shipGeo = ip ? await geocode(order.shipping?.postcode ? order.shipping : order.billing) : null;
const rules = score(order, { charge, ip, shipGeo, orders24h: velocity(order) });
const flp = await fraudLabs(order).catch((e) => ({ error: e.message }));
let verdict = rules.total >= HOLD_AT ? 'hold' : rules.total >= REVIEW_AT ? 'review' : 'ok';
if (flp?.fraudlabspro_status === 'REJECT') verdict = 'hold';
else if (flp?.fraudlabspro_status === 'REVIEW' && verdict === 'ok') verdict = 'review';
const lines = [`Fraud check: ${verdict.toUpperCase()}, rule score ${rules.total}/100`, ...rules.reasons];
if (flp?.fraudlabspro_score != null) lines.push(`FraudLabs Pro: ${flp.fraudlabspro_status}, score ${flp.fraudlabspro_score} (id ${flp.fraudlabspro_id})`);
if (flp?.error) lines.push(`FraudLabs Pro unavailable: ${flp.error}`);
const update = { meta_data: [{ key: 'fraud_check', value: `${verdict} ${rules.total}` }] };
await wc(`orders/${order.id}`, 'PUT', verdict === 'hold' ? { ...update, status: 'on-hold' } : update);
if (verdict !== 'ok') await wc(`orders/${order.id}/notes`, 'POST', { note: lines.join('\n') });
console.log(`order ${order.id}: ${verdict} ${rules.total}${flp?.fraudlabspro_status ? ' flp=' + flp.fraudlabspro_status : ''}`);
}
createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c)).on('end', () => {
const raw = Buffer.concat(chunks), sig = Buffer.from(req.headers['x-wc-webhook-signature'] ?? '');
const want = Buffer.from(createHmac('sha256', env.WEBHOOK_SECRET).update(raw).digest('base64'));
if (req.method !== 'POST' || sig.length !== want.length || !timingSafeEqual(sig, want)) {
res.writeHead(401).end();
return;
}
res.writeHead(200).end(); // answer first: WooCommerce disables a webhook after more than 5 failed deliveries in a row
if (raw.toString().startsWith('webhook_id=')) return; // the ping sent when the webhook is saved
const id = req.headers['x-wc-webhook-delivery-id'], order = JSON.parse(raw);
// Only paid orders, once each: Stripe orders are created as pending and turn processing when the payment succeeds
if (order.status !== 'processing' || order.meta_data?.some((m) => m.key === 'fraud_check') || seen.has(order.id)) return;
seen.add(order.id); // covers re-deliveries that arrive before the fraud_check field is saved
handle(order).catch((e) => {
// A failed check must not look like a pass: leave a note so someone looks at the order
console.error(`order ${order.id} (delivery ${id}): ${e.message}`);
wc(`orders/${order.id}/notes`, 'POST', { note: `Fraud check could not run: ${e.message}` }).catch(() => {});
});
});
}).listen(Number(env.PORT ?? 8080), () => console.log(`listening on ${env.PORT ?? 8080}`));
Setting it up
- WooCommerce REST key: WooCommerce → Settings → Advanced → REST API, with Read/Write permission (it changes order status and adds notes).
- Webhook: Settings → Advanced → Webhooks → Add webhook. Topic Order updated, delivery URL
https://fraud.yourdomain.com/, a long random secret, API version WP REST API Integration v3. - Stripe: a restricted key with read access to Charges and PaymentIntents is enough. The script reads the order's transaction ID, which the Stripe gateway sets to a charge or PaymentIntent ID; both are handled.
- Geolocation (optional): a free MaxMind account for the GeoLite web service (it has a daily lookup limit), and
GEOCODE=onfor Nominatim, whose usage policy asks for an identifying User-Agent and no more than one request per second; setGEOCODE_UAto your store's name and email. - FraudLabs Pro (optional): the API key from your account.
- Run it behind HTTPS (Caddy or nginx in front), with the settings in environment variables:
WEBHOOK_SECRET,WC_URL,WC_KEY,WC_SECRET,STRIPE_KEY,MAXMIND_ACCOUNT,MAXMIND_KEY,FLP_KEY, and optionallyHOLD_AT,REVIEW_ATandHIGH_VALUE. A $5 VPS, or the server that already runs your webhook workers, is plenty.
Two details from WooCommerce's source: deliveries are signed with base64 HMAC-SHA256 in the X-WC-Webhook-Signature header, and WooCommerce disables a webhook after more than 5 failed deliveries in a row. That's why the service answers before calling three APIs, and why a failed check leaves a note on the order instead of failing the delivery.
This is the note the service left on the fraud-shaped test order, which was put on hold:
Fraud check: HOLD, rule score 100/100
+30 Stripe risk level: elevated
+25 AVS: postcode does not match
+15 card issued in GB, billing in US
+15 IP in VN, billing in US
+15 IP is 14389 km from the shipping address
+10 guest checkout, high value
FraudLabs Pro: REJECT, score 91 (id FLP1002)
5. Test results
| Scenario | Expected | Result |
|---|---|---|
| Clean order from a returning customer | No note, no status change, fraud_check = ok 0 | Pass |
| Fraud-shaped order (above) | On hold, note with every reason and FraudLabs Pro's verdict | Pass |
| IP in Canada, US billing, 332 km away, guest, $640 | Review note, status unchanged | Pass |
| Rules clean, FraudLabs Pro REJECT | On hold | Pass |
| Three orders from one email in a day | Only the third flagged | Pass |
| Stripe API returns 500 | Note "Fraud check could not run", not a silent pass | Pass |
| Transaction is a PaymentIntent with a failed CVC | Read from latest_charge, review note | Pass |
| Pending order, or one already scored | Ignored | Pass |
| Same delivery twice | One note | Pass |
| Wrong signature / WooCommerce's ping on save | 401, nothing processed / 200, nothing processed | Pass |
| Request sent to FraudLabs Pro | Billing, shipping, amount and payment mode included | Pass |
| Webhook response time while Stripe takes 300 ms | Answered first | Pass: 2 to 16 ms |
6. FraudLabs Pro: what the API adds
FraudLabs Pro takes the same order data (IP, email, phone, billing and shipping address, amount, payment mode) and returns a score from 1 to 100 and a status of APPROVE, REVIEW or REJECT. What it adds over hand-written rules:
- Signals you can't easily build: proxy, VPN and data-center IP detection, the IP-to-address distance computed for you, free and disposable email detection, ship-forwarder addresses, and card BIN checks.
- A blacklist shared across merchants, plus your own, so a fraudster who hit another store is already known.
- Rules and a review screen in a dashboard, so staff can tune thresholds without editing code.
- An official WooCommerce plugin. For most stores the plugin is the simplest route; the webhook service fits headless stores, several stores feeding one review process, or when you want your own rules next to its score.
| Plan | Price per month | Orders screened per month |
|---|---|---|
| Micro | Free (stores under $25K in monthly sales) | 500 |
| Mini | $29.95 | 1,500 |
| Small | $99.95 | 5,000 |
| Medium | $249.95 | 25,000 |
| Large | $499.95 | 100,000 |
Some response fields depend on the plan: the triggered rules list starts at Mini, disposable email detection at Small, and card issuer details and disposable phone detection at Medium. The free plan still returns the score, status, IP geolocation, proxy flag, address matching and blacklist hits.
7. Where Stripe Radar fits
If every payment goes through Stripe, Radar is already screening it. Radar Lite comes free with Stripe's standard pricing and blocks payments its model rates highest risk, including card testing. Custom rules, risk scores and manual review queues start at Radar Plus; Stripe's pricing page lists Radar Standard, Plus and Pro from $10, $14 and $20 a month for businesses, with pay-as-you-go options, and the packaging changed during 2026, so check it for your account. Our script reads Radar's risk_level as one signal among the others.
Radar's limit is that it only sees Stripe. PayPal, cash on delivery and bank transfer orders never reach it, and it doesn't see your velocity across payment methods. FraudLabs Pro and the rules here score every order, whatever the gateway.
8. Our opinion: trade-offs
- Stripe only, low volume: Radar Lite plus the rules. The script costs nothing to run, and the strongest signals (CVC, AVS, Radar's risk level) are already in the charge.
- Several gateways, or fraud is already costing you: FraudLabs Pro. Proxy detection, email checks and the shared blacklist catch what simple rules miss, and the free 500-order plan is enough to see whether it does on your own orders.
- Keep your own rules next to the API. Velocity and "large guest order with express shipping" are things you know about your store that a general model may weigh differently. The stricter-decision-wins logic in the script lets both have a say.
- Use 3D Secure on risky payments. With most card networks, a payment authenticated with 3D Secure moves liability for fraud chargebacks to the card issuer. Stripe can request it adaptively; that's often worth more than another rule.
Related: stopping card-testing bots before they reach checkout, catching card skimmers on the checkout page and why Stripe and PayPal hold funds.
9. Tools
Top picks
| Tool | Role | Why it fits |
|---|---|---|
| Order fraud scoring API and WooCommerce plugin | Proxy and VPN detection, email and address checks and a shared blacklist across every gateway, with a free plan for 500 orders a month. |
Alternatives: free tools and references
We don't earn anything from these links.
- Stripe Radar: built into Stripe; Lite is free on standard pricing.
- MaxMind GeoLite: free IP geolocation, as a web service or a downloadable database.
- Nominatim: OpenStreetMap's geocoder; read its usage policy first.
- WooCommerce webhooks and orders API: the payloads the service reads and writes.
- Stripe charge object: the AVS, CVC and risk fields.
10. Pitfalls
- On hold doesn't mean refunded. With Stripe's default capture the money is already taken. If you cancel a held order, refund it promptly; a refund before a dispute avoids the dispute fee. Authorize-and-capture-later avoids the refund altogether, if your gateway setup supports it.
- Blocking whole countries. It stops some fraud and many real customers. Let country mismatches add points, not decide.
- Treating IP distance as proof. Mobile networks, VPNs and corporate gateways put real buyers far from home.
- Telling the buyer they look like a fraudster. If you need to check, ask neutrally for confirmation of the order, or a photo of the card with all but the last four digits covered.
- Velocity counts in memory. They reset on restart and aren't shared between instances. Run one instance, or move the counts to Redis.
- Nobody works the review queue. A held order that waits three days becomes a cancelled order from a real customer. Decide who checks flagged orders, and how often.
- Not feeding results back. When a chargeback arrives, look at the order's note: which signals were there, and which were missing? That's how the points get better.
Score every paid order before it ships. The payment's own CVC and AVS results and Stripe's risk level are the strongest signals and cost nothing; IP country, distance and velocity add context. Our webhook service puts that into a note on every risky order and holds the worst. FraudLabs Pro adds what rules can't, such as proxy detection, email checks and a shared blacklist, across every gateway, free for the first 500 orders a month.