Live: Tor + abuse feeds refreshed every 6 hours

LayerCall

AWS Lambda

Backend

What exists today

A plain fetch — Node 18+ has it built in, so there is no dependency to add and no layer to build. The only real work is finding the caller's IP, which sits in a different place depending on how the Lambda is invoked.

Setup

  1. 1Store the key in Secrets Manager or as an encrypted environment variable.
  2. 2Read the source IP from event.requestContext (see the snippet — the path differs by payload version).
  3. 3POST to https://www.layercall.com/v1/score/user with fetch — no SDK needed on Node 18+.
  4. 4Return a 403 response object on block.
  5. 5Set the function timeout above your fetch timeout, or Lambda kills the call before your catch runs.

Code

// Node.js 18+ runtime — fetch is built in, no dependency to add.

export const handler = async (event) => {
  // Payload format 2.0 (Function URLs, HTTP API):
  //   event.requestContext.http.sourceIp
  // Payload format 1.0 (REST API):
  //   event.requestContext.identity.sourceIp
  const ip =
    event.requestContext?.http?.sourceIp ??
    event.requestContext?.identity?.sourceIp ??
    "";

  const { email, phone } = JSON.parse(event.body ?? "{}");

  let scored;
  try {
    const res = 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, email, phone }),
      signal: AbortSignal.timeout(5000),
    });
    if (!res.ok) throw new Error(String(res.status));
    scored = await res.json();
  } catch {
    scored = { verdict: "allow", degraded: true };
  }

  if (scored.verdict === "block") {
    return {
      statusCode: 403,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ error: scored.summary }),
    };
  }

  return { statusCode: 200, body: JSON.stringify({ ok: true }) };
};

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 AWS Lambda
allowReturn 200 and continue.
reviewReturn 200 but publish the record to an SQS queue or EventBridge for a downstream review consumer. Do not block the response on a human.
blockReturn 403. If this Lambda is a Cognito trigger rather than an HTTP handler, throw instead — Cognito treats a thrown error as a refusal, and a 403 object means nothing to it.

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 AWS Lambda

The IP is in two different places

Function URLs and HTTP APIs use payload format 2.0 (requestContext.http.sourceIp). REST APIs use 1.0 (requestContext.identity.sourceIp). Reading only one gives you an empty IP on half your stacks, and an empty IP scores as unknown rather than erroring — so it fails quietly.

Lambda's timeout must exceed the fetch timeout

If the function timeout is 3s and your AbortSignal is 5s, Lambda kills the invocation first. Your catch never runs, the fail-open never happens, and API Gateway returns a 502 to the user.

Secrets Manager costs a call per invocation if you fetch it inside the handler

Read the secret once at module scope, outside the handler — that runs on cold start only and is reused across warm invocations.

Questions

Do I need the AWS SDK or a fetch polyfill?

No. Node 18 and later ship fetch natively, so the handler above has zero dependencies and no layer. On the deprecated Node 16 runtime you would need node-fetch — but that runtime is past end of support and should be upgraded regardless.

Can I use this as a Cognito pre-signup trigger?

Yes, and it is a good fit — the trigger runs before the user exists. Throw an error to refuse rather than returning a 403 object; Cognito reads a thrown error as a rejection and passes the message back to your app.