Live: Tor + abuse feeds refreshed every 6 hours

LayerCall

Supabase

Backend

What exists today

A plain fetch from an Edge Function. Keep the API key in Supabase's secrets, never in a client-side call, or anyone can read it out of your app.

Setup

  1. 1supabase secrets set LAYERCALL_API_KEY=your_key
  2. 2In an Edge Function, POST to /v1/score/user with the new user's ip, email and phone.
  3. 3Act on verdict — reject, flag for review, or require email verification.
  4. 4Call it from an auth hook or a trigger so it cannot be skipped by a client that talks to the database directly.

Code

// Deploy:  supabase functions deploy screen-signup
// Secret:  supabase secrets set LAYERCALL_API_KEY=tl_live_...

Deno.serve(async (req) => {
  const { ip, email, phone } = await req.json();

  let scored;
  try {
    const res = await fetch("https://www.layercall.com/v1/score/user", {
      method: "POST",
      headers: {
        "X-Api-Key": Deno.env.get("LAYERCALL_API_KEY")!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ ip, email, phone }),
      signal: AbortSignal.timeout(5000),
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    scored = await res.json();
  } catch {
    // Fail open: never let an outage on our side stop your signups.
    return Response.json({ verdict: "allow", degraded: true });
  }

  return Response.json({
    verdict: scored.verdict,
    risk_score: scored.risk_score,
    summary: scored.summary,
  });
});

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 Supabase
allowCreate the user as normal.
reviewCreate the user but set a flag column, and require email confirmation before granting anything that costs you money.
blockReject before the row is written. If you are inside an auth hook, return the rejection there — deleting the user afterwards leaves orphaned rows in every table with a cascade.

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 Supabase

A trigger cannot make an outbound HTTP call without pg_net

Postgres triggers run inside the database and have no network. Either enable the pg_net extension and use net.http_post, or — better — call the Edge Function from an auth hook, where you can actually block the signup rather than react to it after the row exists.

The user's IP is not visible to the database

Inside a trigger you have the row, not the request. The IP has to be passed in from wherever the request arrived — the Edge Function or your backend. Scoring a signup without the IP throws away the strongest signal in the response.

Edge Functions time out at the platform limit, not yours

Set an explicit AbortSignal.timeout as above. Without it a slow upstream ties up the function until Supabase kills it, and you get a platform error rather than a decision you can act on.

Questions

Can I call this from the browser?

No. Any key shipped to a browser is public the moment someone opens devtools, and it would be billed against your account by whoever finds it. Call it from an Edge Function or your backend.

Auth hook or database trigger?

Auth hook. A hook runs before the user exists and can refuse the signup; a trigger runs after the row is written, so the best it can do is flag or delete — and a delete has to chase every cascade you have set up since.