Documentation

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.

  1. Create a free account. 250 checks a month are free, no credit card.
  2. In the dashboard, create an API key. It is shown once, so store it as a secret (e.g. SIGNUP_API_KEY).
  3. From your server, send the signup to POST /score.
  4. Act on recommendation: allow, review or block.
Your first request
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.

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
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" }
  }'

More 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:

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.

FieldTypeDescription
emailstringEmail address of the new user.
phonestringPhone number, international (+41… or 0041…), or national together with country_hint.
country_hintstringISO country code such as CH, used for national phone numbers.
ipstringIPv4 or IPv6 address of the user (not of your server).
context.timezonestringBrowser time zone, e.g. Europe/Zurich.
context.user_agent, referrer, sign_up_source, accept_languagestringOptional 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

  1. The strongest single finding counts in full (e.g. a disposable email: 85).
  2. Every further channel that is suspicious on its own (score ≥ 30) adds 10 (multiple_risk_signals).
  3. A phone number and IP from different countries add 15 (phone_ip_country_mismatch).
  4. 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.

Response
{
  "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).

FieldTypeDescription
signals.normalized_emailstring | nullAddress with lower-case domain.
signals.domainstring | nullDomain (IDNs in punycode).
signals.syntax_validbooleanSyntactically valid.
signals.mx_records_presentboolean | nullMail server found; null when DNS did not answer.
signals.disposable_domainbooleanKnown throwaway provider.
signals.forwarding_servicebooleanAlias or forwarding service such as Hide My Email or duck.com.
signals.role_basedbooleanShared mailbox such as info@ or admin@, or a no-reply address.
signals.did_you_meanstring | nullSuggested 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_reputationnullReserved; not checked yet.

POST /verify/phone

Checks only a phone number. Request: phone (required), country_hint, ip, context (optional).

FieldTypeDescription
signals.normalized_phonestring | nullE.164 format, e.g. +41791234567.
signals.format_validbooleanValid number for its country.
signals.countrystring | nullISO country code.
signals.line_typestring | nullmobile, landline, voip, toll_free, premium_rate …
signals.carrierstring | nullNetwork operator, when a carrier lookup ran.
signals.is_voipboolean | nullVoIP number.
signals.is_prepaidnullReserved; not checked yet.

POST /verify/ip

Checks only an IP address. Request: ip (required), context (optional, timezone enables geo_mismatch).

FieldTypeDescription
signals.country, region, citystring | nullLocation of the IP (city level is approximate).
signals.asn, organizationnumber, string | nullNetwork and its operator.
signals.is_datacenterbooleanDatacenter or cloud network.
signals.is_vpnbooleanKnown VPN provider.
signals.is_torbooleanTor exit node.
signals.is_proxynullReserved; not checked separately yet.
signals.risk_categoriesstring[]datacenter, vpn, tor, private_network.

Response format

Every endpoint returns the same envelope.

FieldTypeDescription
request_idstringUnique id; shown in your dashboard logs.
statusstringvalid, invalid, risky or unknown.
risk_scorenumber0–100, higher is riskier.
risk_levelstringlow (0–29), medium (30–59), high (60–79), critical (80–100).
recommendationstringallow (< 30), review (30–79), block (≥ 80).
reason_codesstring[]Why the score is what it is, see reason codes.
signalsobjectRaw data of each check.
confidencenumber0–100, how certain the result is.
providerstringData sources used.
latency_msnumberProcessing time.
policyobjectThresholds 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.

Email

CodeMeaningScore
email_syntax_validThe address is syntactically valid.0
email_syntax_invalidThe address is not a syntactically valid email address.100
mx_records_presentThe domain publishes MX records.0
mx_records_missingNo MX records. With an A record the domain may still receive mail (35); with neither it cannot (90).35 / 90
domain_rejects_mailThe domain publishes a null MX record (RFC 7505): it explicitly accepts no email.90
domain_not_foundThe domain does not exist in DNS.95
disposable_domainThe domain belongs to a known throwaway email service.85
email_domain_typoThe 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_serviceAn 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_patternTypical form-filler input such as test@, asdf@ or a domain like asdf.com.+40
no_reply_addressAn address like noreply@ that by name does not receive mail.+40
role_based_addressA shared or functional mailbox rather than a person.+20
dns_lookup_failedDNS did not answer in time; the result is uncertain (status unknown).+10

Phone

CodeMeaningScore
phone_format_validThe number is valid for its country.0
phone_format_invalidThe number is not valid (wrong length or range).90
phone_country_unknownA national number was sent without country_hint, so it cannot be validated.20
phone_country_mismatchThe number belongs to a different country than country_hint.+15
carrier_foundThe carrier lookup identified the network operator.0
carrier_lookup_failedThe carrier lookup was unavailable; the local result is used.0
line_type_mobileRegular mobile number.0
line_type_fixed_line_or_mobileNumber range is shared by mobile and landline (e.g. US).0
line_type_landlineLandline; slightly unusual for signups.10
line_type_unknownThe number is valid but its type is unknown.15
line_type_fixed_voipVoIP number tied to an address (carrier lookup only).30
line_type_voipVoIP number range (local detection).45
line_type_shared_costSpecial service number.40
line_type_personalPersonal forwarding number.40
line_type_uanUniversal access number of a company.40
line_type_non_fixed_voipVirtual number such as Google Voice (carrier lookup only).60
line_type_toll_freeToll-free hotline, not a person.60
line_type_pagerPager, not a person.60
line_type_voicemailVoicemail-only number.60
line_type_premium_rateExpensive premium-rate number.80

IP address

CodeMeaningScore
tor_exit_nodeThe IP is a Tor exit node.80
ip_private_relayThe 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_suspectedThe IP belongs to a known VPN provider.50
ip_datacenter_asnThe IP belongs to a datacenter or cloud network, not a home or mobile connection.45
ip_private_or_reservedA private or reserved IP. Usually your server sent its own address instead of the user's.30
geo_mismatchcontext.timezone does not belong to the IP's country.+20

Combined (/score)

CodeMeaningScore
multiple_risk_signalsAdded once per further channel that is suspicious on its own (score ≥ 30).+10 each
phone_ip_country_mismatchThe phone number and the IP address belong to different countries.+15

Bot waves (/score)

CodeMeaningScore
signup_velocity_ip3 or more earlier signups from the same IP within 10 minutes.40
email_series_pattern2 or more other addresses of the same series (max1@, max2@ …) within 1 hour.40
signup_velocity_network10 or more earlier signups from the same /24 (IPv4) or /48 (IPv6) network within 10 minutes.30
signup_velocity_domain5 or more other addresses on the same company domain within 10 minutes (big mail providers excluded).30
phone_reusedThe same phone number was used with a different email address within 24 hours.30

Your own rules (/score)

CodeMeaningScore
customer_blocklistThe 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_allowlistThe 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 as mail.spammer.com.
  • Email address. Dots and +tags in the part before the @ are ignored, so jane.doe+x@company.com matches janedoe@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.

Event
{
  "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_id to match the event to your own signup record.
  • Answer with any 2xx status within 5 seconds. On a network error, a 5xx or 429 we try twice more (after 2 and 10 seconds). Events can arrive more than once; id is 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.js
// 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.

StatusCodeWhat to do
400invalid_requestFix the request body; details lists the fields.
401missing_api_key, invalid_api_keySend a valid, non-revoked key.
402quota_exceededYour 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.
429rate_limitedWait for the seconds in Retry-After.
500internal_errorRetry later; fail open in the meantime.

Limits and billing

  • Every successful API call is one check, also when /score covers 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 block at first and log review. Tighten once you have seen your own data.
  • Use review for 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.com and 178.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.