Live: Tor + abuse feeds refreshed every 6 hours

LayerCall

WordPress & WooCommerce

CMS

What exists today

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

  1. 1Define LAYERCALL_KEY in wp-config.php, above the 'stop editing' line.
  2. 2Create a one-file plugin in wp-content/plugins/ rather than editing functions.php.
  3. 3Hook woocommerce_checkout_process for checkout, or registration_errors for wp-login.
  4. 4wp_remote_post to https://www.layercall.com/v1/score/user with a short timeout.
  5. 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.

VerdictIn WordPress & WooCommerce
allowSay nothing and let the order complete.
reviewLet 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.
blockwc_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

REMOTE_ADDR is the proxy on most managed WordPress hosts

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.

Editing functions.php loses the change on theme update

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.

wp_remote_post's default timeout is 5 seconds and blocks the page

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.