Live: Tor + abuse feeds refreshed every 6 hours

LayerCall

Incoming webhooks

Backend

What exists today

Any service that can POST to your endpoint can be screened. You receive the webhook, score the identity in it, then decide what to do — no integration on their side is required.

Setup

  1. 1Receive the inbound webhook at your own endpoint as usual.
  2. 2Verify the sender's signature FIRST — scoring an unverified payload scores whatever an attacker sent you.
  3. 3Extract the identity fields: IP, email, phone.
  4. 4POST them to https://www.layercall.com/v1/score/user
  5. 5Acknowledge with 200 quickly, then branch on verdict before writing anything durable.

Code

// Express — receive, verify, acknowledge, then score.

import express from "express";
import crypto from "node:crypto";

const app = express();

app.post("/hooks/signup",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    // 1. Verify before you trust ANY field in here.
    const sig = req.get("X-Signature") ?? "";
    const expected = crypto
      .createHmac("sha256", process.env.WEBHOOK_SECRET)
      .update(req.body)
      .digest("hex");

    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
      return res.sendStatus(401);
    }

    const payload = JSON.parse(req.body);

    // 2. Acknowledge immediately. Most providers retry on a slow response,
    //    and a retry storm during our 300ms is your outage, not theirs.
    res.sendStatus(200);

    // 3. Score after acknowledging.
    try {
      const r = await fetch("https://www.layercall.com/v1/score/user", {
        method: "POST",
        headers: {
          "X-Api-Key": process.env.LAYERCALL_KEY,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          ip: payload.ip,
          email: payload.email,
          phone: payload.phone,
        }),
        signal: AbortSignal.timeout(5000),
      });
      const scored = await r.json();

      if (scored.verdict === "block") {
        await quarantine(payload, scored.summary);
      }
    } catch (e) {
      // Log and move on — the record is already accepted.
      console.warn("scoring failed", e);
    }
  });

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 Incoming webhooks
allowProcess the payload normally.
reviewWrite it with a flag rather than into your main flow. The webhook has already been acknowledged, so there is no time pressure on the decision.
blockQuarantine rather than discard. A webhook you dropped is one the sender believes was delivered, and you will want the record when someone asks why their submission vanished.

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 Incoming webhooks

Scoring before verifying the signature scores the attacker's input

An unverified webhook body is attacker-controlled. If you score it first, an attacker chooses the IP and email that get checked — and gets a clean verdict attached to a payload you then trust. Verify, then score.

Acknowledging after the score invites retries

Stripe, GitHub and most providers retry when a response takes too long. Adding 300ms to an already-slow handler can push you over their timeout, and then you process the same event several times. Send 200 first.

express.json() breaks signature verification

HMAC is computed over the raw bytes. Once express.json() has parsed and re-serialised the body, key order and whitespace differ and the signature never matches. Use express.raw() on webhook routes.

Questions

Does this add latency to the sender's request?

Not if you acknowledge first, as in the snippet — the sender sees only your verification step. Scoring inline instead adds about 300ms, which is fine for a provider with a generous timeout and dangerous for one without.

What if the webhook has no IP in it?

Score what you do have. email and phone alone still produce a verdict; components_checked tells you exactly what went into it, so you can see that the IP was not part of the decision rather than guessing.