Vercel
A plain fetch from a Route Handler. The one Vercel-specific detail is the IP: request.ip was removed, so you read it from x-forwarded-for, and Vercel guarantees the first entry is the real client.
Setup
- 1vercel env add LAYERCALL_KEY — add it to Production, Preview and Development.
- 2In a Route Handler, take the first entry of the x-forwarded-for header as the client IP.
- 3POST ip, email and phone to https://www.layercall.com/v1/score/user
- 4Return 403 on block; write the row on allow.
- 5Keep it in a Route Handler, not middleware — middleware runs on every matched request and you only want to pay on the ones that matter.
Code
import { NextRequest, NextResponse } from "next/server"; export async function POST(req: NextRequest) { const { email, phone } = await req.json(); // req.ip no longer exists. On Vercel the first x-forwarded-for entry is // the real client — the platform appends, so later entries are proxies. const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? ""; 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), cache: "no-store", }); if (!res.ok) throw new Error(String(res.status)); scored = await res.json(); } catch { scored = { verdict: "allow", risk_score: 0, degraded: true }; } if (scored.verdict === "block") { return NextResponse.json({ error: scored.summary }, { status: 403 }); } // ... create the user, carrying scored.verdict onto the row return NextResponse.json({ ok: true, verdict: scored.verdict }); }
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 Vercel |
|---|---|
| allow | Create the account and move on. |
| review | Create it with a flag column set, and gate anything expensive — trials, credits, outbound email — behind a human or an email confirmation. |
| block | Return 403 with scored.summary. It is written to be shown to a person, so it is safe to surface directly. |
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 Vercel
Next caches fetch responses aggressively. Without no-store, a Route Handler can serve one visitor's score to the next visitor — which is both wrong and a data leak between users.
On Vercel the platform overwrites it, so the first entry is trustworthy. On a self-hosted Next behind your own proxy it is whatever the client sent, and taking [0] hands an attacker a free IP of their choosing.
Middleware matches broadly and runs before you know whether the request is one you care about. A Route Handler runs once, on the path that matters.
Questions
Does this work on other hosts?
The fetch does, everywhere. The IP line is the Vercel-specific part — on Netlify use x-nf-client-connection-ip, on Cloudflare use CF-Connecting-IP, and behind your own nginx use the real IP your proxy sets rather than the raw header.
Edge runtime or Node?
Either. The snippet uses only fetch and standard Web APIs, so it runs unchanged on both. Node is the sensible default now that Fluid Compute has largely removed the cold-start argument for Edge.