David Lewin David Lewin
E-Commerce, Research & Ads

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.

Short answer

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.

How this guide was made

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 ProStripe Radar
SeesEvery order, any gatewayEvery order, any gatewayStripe payments only
SignalsAVS, CVC, Stripe risk level, IP country and distance, velocity, card countryIP, proxy and VPN detection, distance, email and phone checks, card BIN, shared blacklist, velocityStripe's network-wide model, card testing detection
CostA small server; free geolocation tiersFree for 500 orders a month, then from $29.95 a monthRadar Lite free on standard pricing; custom rules from Radar Plus
UpkeepYou tune the points and thresholdsRules in a dashboard; the model and blacklist are maintained for youLowest; little control on the free tier

2. Manual review vs automatic scoring

Manual review vs automatic scoring of a paid order
Manual review vs automatic scoring of a paid orderManual review / Automatic scoring → Waits for a person / Webhook to the scorer → Staff look it up / Rules and FraudLabs Pro → Ship or cancel / ok, review or holdManual review: order paidManual revieworder paidAutomatic scoring: order paidAutomatic scoringorder paidWaits for a person: nights and weekends included; express orders may ship firstWaits for a personnights and weekends included;express orders may ship firstWebhook to the scorer: answered in milliseconds, checks run in the backgroundWebhook to the scoreranswered in milliseconds,checks run in the backgroundStaff look it up: AVS in the Stripe dashboard, IP on a map, maybe email the buyerStaff look it upAVS in the Stripe dashboard,IP on a map, maybe email thebuyerRules and FraudLabs Pro: AVS, CVC, Stripe risk, IP country and distance, velocity, FraudLabs Pro scoreRules and FraudLabs ProAVS, CVC, Stripe risk, IPcountry and distance,velocity, FraudLabs Pro scoreShip or cancel: depends on who's on shiftShip or canceldepends on who's on shiftok, review or hold: on-hold status and a note listing every reasonok, review or holdon-hold status and a notelisting every reason
Automatic scoring doesn't remove the human; it decides which orders need one. Staff only open the orders that were flagged, and the note tells them why.

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.

SignalWhere it comes fromPoints
Stripe risk level "highest" / "elevated"The charge's outcome.risk_level, returned on every Stripe charge60 / 30
CVC check failedpayment_method_details.card.checks.cvc_check40
AVS postcode / street mismatchaddress_postal_code_check / address_line1_check; only some countries' banks support AVS25 / 10
Card issued in another country than the billing addresscard.country15
Prepaid cardcard.funding10
IP in another country than the billing addressMaxMind GeoLite web service (free account)15
Ships to another country than billingThe order15
IP more than 1,000 km / 300 km from the shipping addressGeoLite coordinates and the shipping postcode geocoded with OpenStreetMap's Nominatim15 / 5
3 or more orders from one email or IP in 24 hoursCounted by the service20
Guest checkout above your high-value line (default $500)The order10

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

  1. WooCommerce REST key: WooCommerce → Settings → Advanced → REST API, with Read/Write permission (it changes order status and adds notes).
  2. 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.
  3. 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.
  4. Geolocation (optional): a free MaxMind account for the GeoLite web service (it has a daily lookup limit), and GEOCODE=on for Nominatim, whose usage policy asks for an identifying User-Agent and no more than one request per second; set GEOCODE_UA to your store's name and email.
  5. FraudLabs Pro (optional): the API key from your account.
  6. 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 optionally HOLD_AT, REVIEW_AT and HIGH_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

Rule scores of the test orders
Rule scores of the test orders1001 clean repeat customer: 0 points; 1003 IP in Canada, guest, high value: 30 points; 1007 third order in a day, prepaid: 30 points; 1009 CVC check failed: 40 points; 1004 rules clean, FraudLabs Pro REJECT: 0 points; 1002 AVS fail, IP abroad, foreign card: 100 pointsNot heldPut on hold1001 clean repeat customer: 0 points1001 clean repeat customer0 points1003 IP in Canada, guest, high value: 30 points1003 IP in Canada, guest, high value30 points1007 third order in a day, prepaid: 30 points1007 third order in a day, prepaid30 points1009 CVC check failed: 40 points1009 CVC check failed40 points1004 rules clean, FraudLabs Pro REJECT: 0 points1004 rules clean, FraudLabs Pro REJECT0 points1002 AVS fail, IP abroad, foreign card: 100 points1002 AVS fail, IP abroad, foreign card100 points
Scores of 30 to 59 got a review note but kept their status. Order 1004 scored 0 on the rules and was held because FraudLabs Pro rejected it: the stricter decision wins.
ScenarioExpectedResult
Clean order from a returning customerNo note, no status change, fraud_check = ok 0Pass
Fraud-shaped order (above)On hold, note with every reason and FraudLabs Pro's verdictPass
IP in Canada, US billing, 332 km away, guest, $640Review note, status unchangedPass
Rules clean, FraudLabs Pro REJECTOn holdPass
Three orders from one email in a dayOnly the third flaggedPass
Stripe API returns 500Note "Fraud check could not run", not a silent passPass
Transaction is a PaymentIntent with a failed CVCRead from latest_charge, review notePass
Pending order, or one already scoredIgnoredPass
Same delivery twiceOne notePass
Wrong signature / WooCommerce's ping on save401, nothing processed / 200, nothing processedPass
Request sent to FraudLabs ProBilling, shipping, amount and payment mode includedPass
Webhook response time while Stripe takes 300 msAnswered firstPass: 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.
PlanPrice per monthOrders screened per month
MicroFree (stores under $25K in monthly sales)500
Mini$29.951,500
Small$99.955,000
Medium$249.9525,000
Large$499.95100,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

ToolRoleWhy it fits
FraudLabs ProOrder fraud scoring API and WooCommerce pluginProxy 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.

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.
Bottom line

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.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.