Skip to content
guide

Stop signup fraud with 3 API checks, no captcha required

| 7 min read
Digital security shield representing signup fraud detection and scoring
Photo by Franck on Unsplash

Bots are cheaper than your captcha budget. A commercial solve-farm clears reCAPTCHA v2 at about $1 per thousand, so a scraper who wants 50,000 throwaway accounts spends less on captchas than on the VPS to run the script. Meanwhile, your real users bounce off the puzzle at a 3 to 15% clip.

A better fence is a score. Three API calls give you enough signal to let 95% of signups through without friction and challenge or block the remaining slice. Total latency under 120ms on the P99.

Three signals, three endpoints

Every signup has an IP and an email. That is enough to ask three narrow questions.

Is the IP a VPN, Tor exit, or datacenter?

Real consumer signups rarely originate in an AWS CIDR block. A Tor exit node almost never signs up for a free trial of your SaaS. The /v1/vpn-detect endpoint checks all three.

{
  "ip": "34.102.55.10",
  "is_vpn": true,
  "is_tor": false,
  "is_datacenter": true,
  "provider": "Google Cloud",
  "risk_score": 60
}

Does the email use a throwaway domain?

Disposable providers (mailinator, guerrillamail, tempmail) rotate inboxes in minutes. If a signup hits a disposable domain, the user has no intention of receiving follow-up. /v1/disposable-email/check flags 700+ known providers plus heuristic patterns.

{
  "email": "user@mailinator.com",
  "domain": "mailinator.com",
  "is_disposable": true,
  "is_free": false,
  "provider": "Mailinator"
}

Is the IP in a bogon range or on a reputation list?

Bogon IPs should never reach your server from the open internet; they indicate a spoofed header or broken proxy. Suspicious reverse DNS (hostnames with "proxy", "tor", "spam") is another cheap tell. /v1/ip-blocklist/check returns a risk level plus per-check breakdown.

{
  "ip": "185.220.101.5",
  "is_private": false,
  "is_bogon": false,
  "reverse_dns": "tor-exit-1.example.org",
  "risk_level": "high"
}

Weight and score in one function

Call all three in parallel, add weighted points, clamp to 100, and pick a verdict. Thresholds are tunable per product; start with 20 and 70 as the challenge and block cuts.

// signup-score.ts
type Verdict = 'allow' | 'challenge' | 'block';

export async function scoreSignup(input: {
  ip: string;
  email: string;
}): Promise<{ score: number; verdict: Verdict; signals: Record }> {
  const [vpn, email, blocklist] = await Promise.all([
    fetch('https://api.botoi.com/v1/vpn-detect', postJson({ ip: input.ip })).then(r => r.json()),
    fetch('https://api.botoi.com/v1/disposable-email/check', postJson({ email: input.email })).then(r => r.json()),
    fetch('https://api.botoi.com/v1/ip-blocklist/check', postJson({ ip: input.ip })).then(r => r.json()),
  ]);

  let score = 0;
  if (vpn.is_tor) score += 80;
  else if (vpn.is_datacenter) score += 50;
  else if (vpn.is_vpn) score += 35;

  if (email.is_disposable) score += 60;
  else if (email.is_free) score += 10;

  if (blocklist.risk_level === 'high') score += 40;
  else if (blocklist.risk_level === 'medium') score += 20;

  const verdict: Verdict = score >= 70 ? 'block' : score >= 20 ? 'challenge' : 'allow';
  return { score: Math.min(score, 100), verdict, signals: { vpn, email, blocklist } };
}

function postJson(body: unknown): RequestInit {
  return {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.BOTOI_API_KEY}`,
    },
    body: JSON.stringify(body),
  };
}

A few notes on the weights. Tor is a strong signal; 80 points alone puts the request into the block band. Datacenter IPs catch a lot of bot traffic but also legitimate always-on VPN users, so 50 points lands them in challenge, not block. Disposable emails get 60 points because real users almost never pick mailinator.

Wire it into the signup route

Put the score call right before user creation. Reject the block band, require email verification on the challenge band, allow the rest through untouched.

// app/api/signup/route.ts (Next.js)
import { NextResponse } from 'next/server';
import { scoreSignup } from '@/lib/signup-score';

export async function POST(req: Request) {
  const body = await req.json();
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0] ?? '0.0.0.0';

  const result = await scoreSignup({ ip, email: body.email });

  if (result.verdict === 'block') {
    await auditLog({ type: 'signup.blocked', ip, email: body.email, score: result.score });
    return NextResponse.json({ error: 'signup_rejected' }, { status: 403 });
  }

  const user = await createUser({
    email: body.email,
    password: body.password,
    requiresEmailVerification: result.verdict === 'challenge',
    riskScore: result.score,
  });

  return NextResponse.json({ user });
}

Audit every decision

You cannot tune the thresholds without data. Persist the score and raw signals with every signup.

// Persist the score and signals with the user row for later calibration.
await db.signupAudit.insert({
  userId: user.id,
  ip,
  score: result.score,
  verdict: result.verdict,
  signals: result.signals,
  createdAt: new Date(),
});

Calibrate weekly against churn and abuse

Run a report once a week. Does a higher score predict higher churn, refund rate, or abuse reports? If score 60 signups churn at 80% and score 10 signups churn at 4%, your weights are calibrated and you can tighten the block threshold. If the gradient is flat, re-weight.

-- rerun weekly: does the score predict churn and abuse?
SELECT
  FLOOR(score / 10) * 10 AS score_bucket,
  COUNT(*) AS signups,
  ROUND(100.0 * SUM(CASE WHEN churned THEN 1 ELSE 0 END) / COUNT(*), 1) AS churn_pct,
  ROUND(100.0 * SUM(CASE WHEN abuse_reported THEN 1 ELSE 0 END) / COUNT(*), 1) AS abuse_pct
FROM signup_audit
WHERE created_at > NOW() - INTERVAL '90 days'
GROUP BY 1
ORDER BY 1;

The goal is a monotonic curve. Each score bucket should have worse outcomes than the one below it. If not, add a signal (device fingerprint, signup velocity from the same /24) or drop a weight that is not pulling its own weight.

Where this fits vs. captcha

Approach Friction for real users Cost to attacker P99 latency
reCAPTCHA v2 3-15% drop-off $1 per 1,000 solves 300-800ms
hCaptcha Enterprise 2-8% drop-off $1-2 per 1,000 250-600ms
3-signal score 0% for 95% of users Must acquire clean IP + real mailbox 80-120ms

A score does not replace captcha when you need bot-proof login flows for a US bank. It is the right default for SaaS signup, newsletter, and trial gates.

What this does not cover

  • Credential-stuffing on login: use a passkey or rate limit by account.
  • Coordinated signup bursts from residential proxies: add velocity limits per /24 and per email-domain-plus-signup-minute.
  • Users who complete email verification with a throwaway address: set a 24-hour freeze on feature access for the challenge band.

Get an API key and drop it in

Grab a free key at botoi.com/api/signup. The free tier covers 1,000 scores per day, enough to run calibration against a recent week of signups. Paid plans start at $9/month for 10,000 requests per day.

Full reference for each endpoint: VPN Detect API, Disposable Email Check API, and IP Blocklist Check API.

Frequently asked questions

Why not use a captcha instead?
Captchas cost conversion (typical drop: 3 to 15% of real users) and commercial CAPTCHA farms solve reCAPTCHA v2 for under $1 per thousand. Scoring with signals skips the friction for 95% of signups and only challenges the suspicious slice.
What signals go into the score?
Three: VPN/Tor/datacenter IP flags, disposable or throwaway email domain, and IP blocklist hits (bogons, suspicious hostnames). Each returns in under 50ms. Weight them to get a 0-100 risk score, then decide per threshold.
Does blocking datacenter IPs hurt real users?
Corporate and consumer IPs do not match datacenter CIDR blocks. The only real users affected are people on always-on VPNs, which is usually a minority you can route to a secondary verification path rather than block outright.
How do I handle the low-signal middle?
Score above 70: block. Score under 20: allow. Between 20 and 70: require email verification or a step-up (SMS code, passkey). You shift friction onto the risky band instead of the whole funnel.
Can I test the scoring against historical signups?
Yes. Replay the last 90 days of signups through the three endpoints, join the score with churn, refund, and abuse-report columns, and calibrate your thresholds before you enforce anything. Each endpoint stays stable on repeated calls with the same input.

Try this API

VPN Detect API — interactive playground and code examples

More guide posts

Start building with botoi

150+ API endpoints for lookup, text processing, image generation, and developer utilities. Free tier, no credit card.