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

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.role_basedbooleanShared mailbox such as info@ or admin@.
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).

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

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.
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.
  • 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.