Check your first signup in ten minutes.
SignupVet checks the email, phone number and IP address of a new signup and returns one risk score with the reasons behind it.
- Create a free account. 250 checks a month are free, no credit card.
- In the dashboard, create an API key. It is shown once, so store it as a secret (e.g.
SIGNUP_API_KEY). - From your server, send the signup to
POST /score. - Act on
recommendation:allow,revieworblock.
curl -X POST https://www.signupvet.com/score \
-H "Authorization: Bearer $SIGNUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "bot123@mailinator.com", "ip": "178.197.224.1"}'Authentication
Every API request needs a key. Send it as a bearer token, or in the X-API-Key header.
Authorization: Bearer sv_live_…Keys belong to your account and can be revoked in the dashboard at any time. Call the API from your server only; a key in browser code can be copied by anyone.
Code examples
All examples call /score with a 2-second timeout and fail open: if the API cannot be reached, the signup goes through. Your signup form never breaks because of us.
curl -X POST https://www.signupvet.com/score \
-H "Authorization: Bearer $SIGNUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "jane.doe@gmail.com",
"phone": "+41 79 123 45 67",
"ip": "178.197.224.1",
"context": { "timezone": "Europe/Zurich" }
}'// signup-check.js (Node 18+, no dependencies)
export async function checkSignup({ email, phone, ip, timezone }) {
try {
const res = await fetch("https://www.signupvet.com/score", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SIGNUP_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email, phone, ip, context: timezone ? { timezone } : undefined }),
signal: AbortSignal.timeout(2000),
});
if (!res.ok) return { recommendation: "allow", reason_codes: [] }; // fail open
return await res.json();
} catch {
return { recommendation: "allow", reason_codes: [] }; // timeout or network error: fail open
}
}// app/api/signup/route.ts (Next.js App Router)
import { checkSignup } from "@/lib/signup-check";
export async function POST(req: Request) {
const { email, password, timezone } = await req.json();
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
const check = await checkSignup({ email, ip, timezone });
if (check.recommendation === "block") {
return Response.json({ error: "Please sign up with a different email address." }, { status: 400 });
}
const user = await createUser({ email, password }); // your existing signup
if (check.recommendation === "review") {
await flagForReview(user.id, check.reason_codes); // e.g. no free credits until verified
}
return Response.json({ ok: true });
}# signup_check.py (pip install requests)
import os
import requests
def check_signup(email=None, phone=None, ip=None, timezone=None):
body = {k: v for k, v in {"email": email, "phone": phone, "ip": ip}.items() if v}
if timezone:
body["context"] = {"timezone": timezone}
try:
res = requests.post(
"https://www.signupvet.com/score",
headers={"Authorization": f"Bearer {os.environ['SIGNUP_API_KEY']}"},
json=body,
timeout=2,
)
res.raise_for_status()
return res.json()
except requests.RequestException:
return {"recommendation": "allow", "reason_codes": []} # fail openSending the browser time zone
Optional, but it enables the geo_mismatch check. Add a hidden field to your signup form:
<input type="hidden" name="timezone" id="timezone" />
<script>
document.getElementById("timezone").value = Intl.DateTimeFormat().resolvedOptions().timeZone;
</script>POST /score
The main endpoint for signups. Runs every check you send data for and combines them into one score. Also detects bot waves by comparing the signup with your recent signups.
| Field | Type | Description |
|---|---|---|
email | string | Email address of the new user. |
phone | string | Phone number, international (+41… or 0041…), or national together with country_hint. |
country_hint | string | ISO country code such as CH, used for national phone numbers. |
ip | string | IPv4 or IPv6 address of the user (not of your server). |
context.timezone | string | Browser time zone, e.g. Europe/Zurich. |
context.user_agent, referrer, sign_up_source, accept_language | string | Optional metadata; accepted for future checks. |
At least one of email, phone or ip is required. Channels you do not send are null in signals.
How the score is combined
- The strongest single finding counts in full (e.g. a disposable email: 85).
- Every further channel that is suspicious on its own (score ≥ 30) adds 10 (
multiple_risk_signals). - A phone number and IP from different countries add 15 (
phone_ip_country_mismatch). - The result is capped at 100.
Bot-wave detection
Each /score call is compared with your earlier signups (never with other customers'). Matches are counted in signals.pattern and raise the score, see pattern codes. The comparison uses one-way fingerprints, not the original data.
{
"request_id": "req_4f0c…",
"status": "risky",
"risk_score": 85,
"risk_level": "critical",
"recommendation": "block",
"reason_codes": ["email_syntax_valid", "mx_records_present", "disposable_domain"],
"signals": {
"email": { "domain": "mailinator.com", "disposable_domain": true, "…": "…" },
"phone": null,
"ip": { "country": "CH", "city": "Basel", "is_datacenter": false, "…": "…" },
"pattern": { "same_ip_10m": 0, "same_network_10m": 0, "email_series_1h": 0,
"same_domain_10m": 0, "phone_other_accounts_24h": 0 }
},
"confidence": 78,
"provider": "signupvet",
"latency_ms": 9,
"policy": { "allow_threshold": 30, "review_threshold": 60, "block_threshold": 80 }
}POST /verify/email
Checks only an email address. Request: email (required), ip, context (optional).
| Field | Type | Description |
|---|---|---|
signals.normalized_email | string | null | Address with lower-case domain. |
signals.domain | string | null | Domain (IDNs in punycode). |
signals.syntax_valid | boolean | Syntactically valid. |
signals.mx_records_present | boolean | null | Mail server found; null when DNS did not answer. |
signals.disposable_domain | boolean | Known throwaway provider. |
signals.role_based | boolean | Shared mailbox such as info@ or admin@. |
signals.catch_all, domain_age_days, domain_reputation | null | Reserved; not checked yet. |
POST /verify/phone
Checks only a phone number. Request: phone (required), country_hint, ip, context (optional).
| Field | Type | Description |
|---|---|---|
signals.normalized_phone | string | null | E.164 format, e.g. +41791234567. |
signals.format_valid | boolean | Valid number for its country. |
signals.country | string | null | ISO country code. |
signals.line_type | string | null | mobile, landline, voip, toll_free, premium_rate … |
signals.carrier | string | null | Network operator, when a carrier lookup ran. |
signals.is_voip | boolean | null | VoIP number. |
signals.is_prepaid | null | Reserved; not checked yet. |
POST /verify/ip
Checks only an IP address. Request: ip (required), context (optional, timezone enables geo_mismatch).
| Field | Type | Description |
|---|---|---|
signals.country, region, city | string | null | Location of the IP (city level is approximate). |
signals.asn, organization | number, string | null | Network and its operator. |
signals.is_datacenter | boolean | Datacenter or cloud network. |
signals.is_vpn | boolean | Known VPN provider. |
signals.is_tor | boolean | Tor exit node. |
signals.is_proxy | null | Reserved; not checked separately yet. |
signals.risk_categories | string[] | datacenter, vpn, tor, private_network. |
Response format
Every endpoint returns the same envelope.
| Field | Type | Description |
|---|---|---|
request_id | string | Unique id; shown in your dashboard logs. |
status | string | valid, invalid, risky or unknown. |
risk_score | number | 0–100, higher is riskier. |
risk_level | string | low (0–29), medium (30–59), high (60–79), critical (80–100). |
recommendation | string | allow (< 30), review (30–79), block (≥ 80). |
reason_codes | string[] | Why the score is what it is, see reason codes. |
signals | object | Raw data of each check. |
confidence | number | 0–100, how certain the result is. |
provider | string | Data sources used. |
latency_ms | number | Processing time. |
policy | object | Thresholds used (/score only). |
Reason codes
A score of +n is added on top of the strongest finding; plain numbers are the finding's own score. Codes with score 0 confirm that a check passed.
| Code | Meaning | Score |
|---|---|---|
email_syntax_valid | The address is syntactically valid. | 0 |
email_syntax_invalid | The address is not a syntactically valid email address. | 100 |
mx_records_present | The domain publishes MX records. | 0 |
mx_records_missing | No MX records. With an A record the domain may still receive mail (35); with neither it cannot (90). | 35 / 90 |
domain_rejects_mail | The domain publishes a null MX record (RFC 7505): it explicitly accepts no email. | 90 |
domain_not_found | The domain does not exist in DNS. | 95 |
disposable_domain | The domain belongs to a known throwaway email service. | 85 |
role_based_address | A shared or functional mailbox rather than a person. | +20 |
dns_lookup_failed | DNS did not answer in time; the result is uncertain (status unknown). | +10 |
Phone
| Code | Meaning | Score |
|---|---|---|
phone_format_valid | The number is valid for its country. | 0 |
phone_format_invalid | The number is not valid (wrong length or range). | 90 |
phone_country_unknown | A national number was sent without country_hint, so it cannot be validated. | 20 |
phone_country_mismatch | The number belongs to a different country than country_hint. | +15 |
carrier_found | The carrier lookup identified the network operator. | 0 |
carrier_lookup_failed | The carrier lookup was unavailable; the local result is used. | 0 |
line_type_mobile | Regular mobile number. | 0 |
line_type_fixed_line_or_mobile | Number range is shared by mobile and landline (e.g. US). | 0 |
line_type_landline | Landline; slightly unusual for signups. | 10 |
line_type_unknown | The number is valid but its type is unknown. | 15 |
line_type_fixed_voip | VoIP number tied to an address (carrier lookup only). | 30 |
line_type_voip | VoIP number range (local detection). | 45 |
line_type_shared_cost | Special service number. | 40 |
line_type_personal | Personal forwarding number. | 40 |
line_type_uan | Universal access number of a company. | 40 |
line_type_non_fixed_voip | Virtual number such as Google Voice (carrier lookup only). | 60 |
line_type_toll_free | Toll-free hotline, not a person. | 60 |
line_type_pager | Pager, not a person. | 60 |
line_type_voicemail | Voicemail-only number. | 60 |
line_type_premium_rate | Expensive premium-rate number. | 80 |
IP address
| Code | Meaning | Score |
|---|---|---|
tor_exit_node | The IP is a Tor exit node. | 80 |
proxy_or_vpn_suspected | The IP belongs to a known VPN provider. | 50 |
ip_datacenter_asn | The IP belongs to a datacenter or cloud network, not a home or mobile connection. | 45 |
ip_private_or_reserved | A private or reserved IP. Usually your server sent its own address instead of the user's. | 30 |
geo_mismatch | context.timezone does not belong to the IP's country. | +20 |
Combined (/score)
| Code | Meaning | Score |
|---|---|---|
multiple_risk_signals | Added once per further channel that is suspicious on its own (score ≥ 30). | +10 each |
phone_ip_country_mismatch | The phone number and the IP address belong to different countries. | +15 |
Bot waves (/score)
| Code | Meaning | Score |
|---|---|---|
signup_velocity_ip | 3 or more earlier signups from the same IP within 10 minutes. | 40 |
email_series_pattern | 2 or more other addresses of the same series (max1@, max2@ …) within 1 hour. | 40 |
signup_velocity_network | 10 or more earlier signups from the same /24 (IPv4) or /48 (IPv6) network within 10 minutes. | 30 |
signup_velocity_domain | 5 or more other addresses on the same company domain within 10 minutes (big mail providers excluded). | 30 |
phone_reused | The same phone number was used with a different email address within 24 hours. | 30 |
Errors
Errors come as {"error": {"code": "…", "message": "…"}}. Only successful checks count towards your quota.
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | Fix the request body; details lists the fields. |
| 401 | missing_api_key, invalid_api_key | Send a valid, non-revoked key. |
| 402 | quota_exceeded | Your checks are used up. Upgrade or buy a pack; treat it like a timeout and let the signup through. |
| 429 | rate_limited | Wait for the seconds in Retry-After. |
| 500 | internal_error | Retry later; fail open in the meantime. |
Limits and billing
- Every successful API call is one check, also when
/scorecovers email, phone and IP together. - Monthly checks of your plan are used first, then prepaid pack checks (which never expire).
- Rate limit: 10 requests per second per key, short bursts up to 20.
- Plans and usage are in the dashboard; prices on the pricing page.
Best practices
- Call from your server, never from the browser, so your key stays secret.
- Send the user's IP, not your server's. Behind a proxy or on Vercel, take the first address of
X-Forwarded-For. A private IP (ip_private_or_reserved) usually means this went wrong. - Fail open with a short timeout (1–2 s). A lost signup costs more than one missed bot.
- Start gentle. Block only
blockat first and logreview. Tighten once you have seen your own data. - Use
reviewfor friction, not rejection: require email confirmation, hold free credits, or ask for a phone number. - Do not show reason codes to users. A neutral message ("Please use a different email address") tells bots less.
Supabase, Clerk and other auth providers
Call /score in the server-side code that creates the account, before you call your auth provider, for example in the API route or server action behind your signup form, right before supabase.auth.signUp() or creating the user in Clerk.
If users currently sign up directly from the browser with the provider's client SDK, move the signup behind your own endpoint first. Otherwise a bot can skip the check by talking to the provider directly.
Privacy
- Full email addresses, phone numbers and IPs are never stored. Your dashboard shows masked values such as
m***@gmail.comand178.197.224.x. - Bot-wave detection uses one-way, keyed fingerprints and only compares signups within your own account.
- Checks run on our own data. Only when a paid carrier lookup is enabled is a phone number sent to that provider, and only for unclear cases.
IP Geolocation by DB-IP. Machine-readable API description: /openapi.json.