WordPress & WooCommerce
WordPress has an HTTP API built in — wp_remote_post — so this needs no library and no Composer. Write it as a tiny plugin rather than editing a theme, or your next theme update deletes it.
Setup
- 1Define LAYERCALL_KEY in wp-config.php, above the 'stop editing' line.
- 2Create a one-file plugin in wp-content/plugins/ rather than editing functions.php.
- 3Hook woocommerce_checkout_process for checkout, or registration_errors for wp-login.
- 4wp_remote_post to https://www.layercall.com/v1/score/user with a short timeout.
- 5Fail open on is_wp_error — a plugin that blocks checkout during an outage is worse than the fraud.
Code
<?php /** * Plugin Name: Checkout screening * Description: Scores the checkout before the order is created. */ // In wp-config.php: define('LAYERCALL_KEY', 'tl_live_...'); add_action('woocommerce_checkout_process', function () { if (!defined('LAYERCALL_KEY')) { return; // not configured — do not block trade } $res = wp_remote_post('https://www.layercall.com/v1/score/user', [ 'timeout' => 5, 'headers' => [ 'X-Api-Key' => LAYERCALL_KEY, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode([ 'ip' => $_SERVER['REMOTE_ADDR'] ?? '', 'email' => sanitize_email($_POST['billing_email'] ?? ''), 'phone' => sanitize_text_field($_POST['billing_phone'] ?? ''), ]), ]); // Fail open on transport errors AND on non-2xx. if (is_wp_error($res) || wp_remote_retrieve_response_code($res) !== 200) { return; } $data = json_decode(wp_remote_retrieve_body($res), true); if (($data['verdict'] ?? '') === 'block') { wc_add_notice($data['summary'], 'error'); } if (($data['verdict'] ?? '') === 'review') { // Let the order through, but mark it for a human. add_action('woocommerce_checkout_order_processed', function ($order_id) use ($data) { $order = wc_get_order($order_id); $order->add_order_note('Risk review: ' . $data['summary']); $order->update_status('on-hold'); }); } });
What to do with each verdict
Three outcomes, and the middle one is the one worth getting right — refusing a real customer usually costs more than reviewing them.
| Verdict | In WordPress & WooCommerce |
|---|---|
| allow | Say nothing and let the order complete. |
| review | Let it through but set the order to on-hold with an order note, as above. Refusing a real customer at checkout costs a sale; holding one costs a minute of someone's time. |
| block | wc_add_notice(..., 'error') stops the checkout and shows the message. WooCommerce collects notices added during woocommerce_checkout_process and refuses to create the order if any are errors. |
What comes back
A real response, generated from the live API rather than written by hand. Branch on verdict; summary is a sentence written to be shown to a person, and components_checked tells you what actually went into the score.
Show the full response(POST /v1/score/user)
{ "risk_score": 65, "verdict": "review", "summary": "Needs review (65/100) — Tor exit node, commercial VPN and datacenter ASN.", "components": { "email": { "email": "test@guerrillamail.com", "normalized_email": "test@guerrillamail.com", "risk_score": 100, "verdict": "block", "status": "do_not_mail", "sub_status": "disposable", "deliverability_score": 0, "did_you_mean": null, "signals": { "syntax_valid": true, "mx_found": true, "is_disposable": true, "is_homograph": false, "is_role_account": true, "is_free_provider": false, "is_suspicious_handle": true, "is_tagged": false, "is_risky_tld": false, "is_new_domain": null, "has_spf": true, "has_dmarc": true, "has_website": true, "mailbox_exists": null, "is_catch_all": null, "mailbox_status": "unavailable", "has_digital_footprint": null }, "domain": "guerrillamail.com", "domain_age_days": null, "mx_provider": null, "mx_records": [ "mail.guerrillamail.com." ], "abuse_reports": 0, "digital_footprint": { "has_gravatar": false, "gravatar_profile_url": null, "breach_count": null, "seen_in_breach": null } }, "phone": { "parse_status": "ok", "phone": "+14155552671", "risk_score": 0, "signals": { "syntax_valid": true, "is_possible": true, "is_voip": false, "is_premium_rate": false, "is_toll_free": false, "assigned_area_code": true, "is_fictional": false }, "number": { "e164": "+14155552671", "country": "US", "national": "(415) 555-2671", "international": "+1 415 555 2671", "line_type": "fixed_line_or_mobile" }, "verdict": "allow", "abuse_reports": 0 }, "ip": { "ip": "185.220.101.1", "risk_score": 60, "signals": { "is_vpn": true, "is_proxy": true, "is_datacenter": true, "is_tor": true, "recent_abuse": false, "is_hijacked_netblock": false }, "geo": { "country": "DE", "city": "Berlin", "asn": "AS60729", "isp": "Stiftung Erneuerbare Freiheit" }, "vpn_provider": null, "hijacked_source": null, "verdict": "review", "abuse_reports": 0 } }, "components_checked": [ "email", "phone", "ip" ], "linkage": { "device_email_count": null, "email_device_count": 0, "email_ip_count": 0, "subnet_rate_1h": 2, "domain_rate_1h": 0 }, "actor": { "type": "unknown", "proven": false, "basis": "none", "operator": null, "trigger": null, "detail": "No signature and no device fingerprint. Drop fp.js on the page, or pass the agent's signed request, to get an answer here." }, "top_signals": [ "ip: tor exit node", "ip: commercial vpn", "ip: datacenter asn", "email: disposable domain", "email: role account", "email: machine-generated handle" ] }
Traps specific to WordPress & WooCommerce
Kinsta, WP Engine, SiteGround and anything behind Cloudflare put their own address in REMOTE_ADDR. Check for HTTP_CF_CONNECTING_IP or the host's documented header first, or every customer scores as the same datacenter IP.
It is the fastest way to add a hook and the fastest way to lose it. A one-file plugin survives theme updates and can be deactivated without editing anything.
WordPress is synchronous — the shopper waits. Keep the timeout low, and return early on any error rather than retrying, because a retry doubles the wait on exactly the request that was already slow.
Questions
Does this work without WooCommerce?
Yes. Use the registration_errors filter for account signups, or preprocess_comment for comment spam. Only the hook name changes — the wp_remote_post call is identical.
Will it slow the checkout?
By the round trip, around 300ms, on the submit — not on page load. If your host is already slow enough that this matters, screen asynchronously on woocommerce_checkout_order_processed instead and hold suspect orders rather than refusing them.