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.get('SIGNUP_API_KEY', '')}"},
json=body,
timeout=2,
)
res.raise_for_status()
return res.json()
except requests.RequestException:
return {"recommendation": "allow", "reason_codes": []} # fail openMore complete examples (Express, Next.js with Supabase, FastAPI) are on GitHub: github.com/signupvet/examples. Step-by-step guides: Express, FastAPI, Next.js, Supabase, Clerk, Firebase, Better Auth.
Sending 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.forwarding_service | boolean | Alias or forwarding service such as Hide My Email or duck.com. |
signals.role_based | boolean | Shared mailbox such as info@ or admin@, or a no-reply address. |
signals.did_you_mean | string | null | Suggested address when the domain looks like a typo of a popular provider (john@gmial.com → john@gmail.com). |
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): yours if you set own thresholds, otherwise the defaults. |
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 |
email_domain_typo | The domain is one letter away from a popular provider (gmial.com → gmail.com). Typo domains often collect misdirected mail; see signals.did_you_mean. | +50 |
email_forwarding_service | An alias or forwarding service (Hide My Email, duck.com, SimpleLogin …). Used by real people for privacy, so it is a reason to review, not to block. | +30 |
email_test_pattern | Typical form-filler input such as test@, asdf@ or a domain like asdf.com. | +40 |
no_reply_address | An address like noreply@ that by name does not receive mail. | +40 |
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 |
ip_private_relay | The IP is an Apple iCloud Private Relay exit. Many ordinary Safari users have it on, so it only adds a little risk. | 10 |
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 |
Your own rules (/score)
| Code | Meaning | Score |
|---|---|---|
customer_blocklist | The email domain, email address, phone number or IP is on your own block list. The signup is blocked with score 100, whatever the other checks say. | 100 |
customer_allowlist | The email domain, email address, phone number or IP is on your own allow list. The recommendation is allow; the score still shows what the checks found. A block list entry wins over an allow list entry. | 0 |
Your own rules
On the Build, Growth and Scale plans you can adjust /score to your product in the dashboard. The rules apply to /score only, from the next request on.
Thresholds
Set from which score a signup is sent to review (default 30) and from which score it is blocked (default 80). The policy field of every /score response shows the thresholds that were used.
Block and allow lists
- Email domain, for example
spammer.com. Also covers subdomains such asmail.spammer.com. - Email address. Dots and
+tagsin the part before the @ are ignored, sojane.doe+x@company.commatchesjanedoe@company.com. - Phone number in international format, for example
+41791234567. - IP address, a single IPv4 or IPv6 address.
A block list match returns block with score 100 and customer_blocklist. An allow list match returns allow with customer_allowlist; the score still shows what the checks found. If a signup is on both lists, the block list wins.
An allowed email domain lets in anyone who types an address at that domain, without proving they own it. Keep confirming email addresses as usual.
Email addresses, phone numbers and IPs in your lists are stored like the check logs: masked plus a one-way fingerprint, never in full. Email domains are stored as entered. Up to 1,000 entries per account. If you move back to the Free plan, your rules are kept but not applied.
Webhooks
On the Build, Growth and Scale plans you can add one HTTPS endpoint in the dashboard. After a /score call that ends in block we send it a POST request with a JSON event (signup.blocked). Tick the box to also get signup.review. The API response itself is not delayed: events are sent right after it.
{
"id": "evt_4f1c…",
"type": "signup.blocked",
"created": "2026-10-29T14:03:11.402Z",
"data": {
"request_id": "req_4f1c…",
"recommendation": "block",
"risk_score": 85,
"risk_level": "critical",
"reason_codes": ["email_syntax_valid", "mx_records_present", "disposable_domain"],
"email": "s***@mailinator.com",
"phone": null,
"ip": "178.197.224.x"
}
}- Email, phone and IP are sent masked, like in your dashboard. Use
request_idto match the event to your own signup record. - Answer with any
2xxstatus within 5 seconds. On a network error, a5xxor429we try twice more (after 2 and 10 seconds). Events can arrive more than once;idis unique per event. - Only public HTTPS addresses are accepted, and redirects are not followed.
- Slack: paste a Slack incoming webhook URL (
https://hooks.slack.com/…) and you get a readable message in your channel instead of the JSON event.
Checking the signature
Every request has a SignupVet-Signature header: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of <t>.<raw request body> with your signing secret (whsec_…, shown in the dashboard). Compute it on the raw body before parsing the JSON.
// Node 18+: check the signature before you trust the event.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySignupVetEvent(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const ok = parts.v1?.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; // 5 minutes
return ok && fresh;
}The dashboard has a Send test event button (event type test) and shows the result of the last delivery.
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. We email your account address once when 80% of your monthly checks are used. |
| 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.
- Check logs are deleted automatically after 90 days.
- 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.