The reference for VerifIP, the IP reputation and fraud-scoring API you call from your server at https://verifip.hextner.com: every endpoint, response field and signal weight, batch rules, response headers, retries, rate limits and error codes. New to the API? Start with the quickstart.
Every endpoint takes Authorization: Bearer YOUR_API_KEY (your VerifIP API key (under API Keys → VerifIP)). Batch endpoints take 1–100 items and are for paid plans only. Every endpoint and field is in the OpenAPI spec; GET /health needs no key.
VerifIP endpoints and methods
| Method | Path | Input | Notes |
|---|---|---|---|
| GET | /v1/check | ip (query) | IPv4 or IPv6. Score, verdict, flags, geo, ASN, signal_breakdown. |
| POST | /v1/check/batch | {"ips": [...]} (1–100) | Paid plans only. IPv4 or IPv6. Results in input order. Local data only (no reverse DNS, no external reputation lookup), so a batch score can be lower than /v1/check. |
| GET | /v1/email | email (query) | Syntax, MX, disposable, free and role-based, domain age. Bad syntax is a 200: valid_syntax false, risk_score 100 (invalid_syntax). No verdict field: decide on risk_score. |
| POST | /v1/email/batch | {"emails": [...]} (1–100) | Paid plans only. ~15 uncached unique domains get full lookups; see Batch rules. |
| GET | /v1/phone | phone (query, international format with country code, e.g. +14155552671; send + as %2B) | Validity, country, line type, VoIP. The number must be international (E.164 style, leading +country code); a national number such as 9876543210 is not valid. Invalid is a 200: valid false, phone "", risk_score 100. |
| POST | /v1/phone/batch | {"phones": [...]} (1–100) | Paid plans only. |
| GET | /v1/url | url (query, URL-encoded) | PhishTank, domain age, TLS (ssl_valid + ssl_status); Google Safe Browsing and the URLhaus API when configured. A scheme is optional (http:// is assumed). |
| POST | /v1/url/batch | {"urls": [...]} (1–100) | Paid plans only. ~15 uncached unique domains get full lookups; see Batch rules. |
| GET | /v1/whois | ip (query) | RDAP network registration. No risk score. |
| GET | /v1/assess | any of ip, email, phone, url (query) | One call (1 unit), up to four entities. overall_risk = max(ip × 1.0, url × 0.9, email × 0.8, phone × 0.7), rounded down. IP and URL are validated as on their own endpoints (400 invalid_ip / invalid_url); a malformed email or phone is scored 100, not refused. 503 when any entity could not be assessed. |
| POST | /v1/report | {"ip", "is_fraud", "category", "comment"} | Stored for review; not used in scoring. Body: ip (required), is_fraud (boolean, default false), category (spam, phishing, brute_force, scraping, bot, other, or null), comment (≤ 1,000 characters). Any field error is a 400, not billed; an accepted report is 1 unit. |
Example request for each endpoint
GET /v1/check?ip=185.220.101.1 (illustrative; live values change)
{
"ip": "185.220.101.1", "ip_version": 4,
"fraud_score": 43, "verdict": "challenge", "threat_confidence": "low",
"threat_categories": ["tor", "proxy", "attacker"],
"is_tor": true, "is_proxy": true, "is_datacenter": true,
"blocklist_count": 3, "country_code": "DE", "asn": 60729,
"signal_breakdown": { "tor_exit": 25, "proxy_detected": 15, "datacenter_ip": 10,
"blocklist_consensus": 12, "known_attacker": 15, "firehol_l2": 15 }
}POST /v1/check/batch
{"ips":["1.1.1.1","8.8.8.8","not-an-ip"]}
{
"results": [
{ "ip": "1.1.1.1", "fraud_score": 0, "verdict": "allow", ... },
{ "ip": "8.8.8.8", "fraud_score": 0, "verdict": "allow", ... },
{ "ip": "not-an-ip", "fraud_score": 0, "invalid": true, "error": "..." }
]
}GET /v1/email?email=user@mailinator.com
{
"email": "user@mailinator.com", "risk_score": 35,
"valid_syntax": true, "mx_found": true, "is_disposable": true,
"is_free_provider": false, "is_role_based": false,
"domain": "mailinator.com", "domain_age_days": 8494,
"signal_breakdown": { "disposable_domain": 35 }
}POST /v1/email/batch
{"emails":["a@gmail.com","b@mailinator.com"]}
{ "results": [ { "email": "a@gmail.com", "risk_score": 5, ... }, ... ] }GET /v1/phone?phone=%2B917006636358
{
"phone": "+917006636358", "risk_score": 0, "valid": true,
"country_code": "IN", "carrier": "", "line_type": "mobile",
"is_voip": false, "signal_breakdown": {}
}POST /v1/phone/batch
{"phones":["+14155552671","+447911123456"]}
{ "results": [ { "phone": "+14155552671", "valid": true, ... }, ... ] }GET /v1/url?url=https%3A%2F%2Fexample.com%2Flogin
{
"url": "https://example.com/login", "risk_score": 0,
"is_phishing": false, "is_malware": false, "in_phishtank": false,
"spamhaus_dbl": false, "domain_age_days": 11374,
"ssl_valid": true, "ssl_status": "valid", "signal_breakdown": {}
}
A listed URL looks like this (illustrative):
{ "risk_score": 50, "is_phishing": true, "in_phishtank": true,
"domain_age_days": 3, "signal_breakdown": { "phishtank": 35, "new_domain": 15 } }POST /v1/url/batch
{"urls":["https://example.com","https://example.org/login"]}
{ "results": [ { "url": "https://example.com", "risk_score": 0, ... }, ... ] }GET /v1/whois?ip=34.87.12.111
{
"ip": "34.87.12.111", "ip_version": 4, "network_cidr": "34.80.0.0/12",
"network_handle": "NET-34-64-0-0-1", "network_name": "GOOGLE-CLOUD", "org_name": "Google LLC",
"abuse_contact": "network-abuse@google.com", "rir": "ARIN",
"allocation_date": "2018-04-09", "country_code": "US",
"asn": 396982, "asn_org": "Google LLC"
}GET /v1/assess?ip=34.87.12.111&email=user@gmail.com
{
"overall_risk": 10,
"ip": { "fraud_score": 10, "verdict": "allow", "threat_confidence": "none", ... },
"email": { "risk_score": 5, "is_free_provider": true, ... }
}POST /v1/report
{"ip":"185.220.101.1","is_fraud":true,"category":"spam","comment":"Bulk spam"}
{ "request_id": "…", "status": "accepted", "message": "Fraud report submitted successfully." }What fields does /v1/check return?
| Field | Type | Description |
|---|---|---|
| request_id | string | Unique request identifier (UUID). |
| ip | string | The IP address that was checked. |
| fraud_score | integer | 0 (no evidence) to 100. Use for ordering; enforce on verdict. |
| is_proxy | boolean | IP is a known open proxy. |
| is_vpn | boolean | IP belongs to a VPN provider. |
| is_tor | boolean | IP is a Tor exit node. |
| is_datacenter | boolean | IP belongs to a hosting/cloud provider. |
| is_bogon | boolean | Unroutable/reserved IP (bogon). |
| is_known_attacker | boolean | One listing is enough: on blocklist.de, CINS Army or GreenSnow, or in a Spamhaus DROP range. |
| is_botnet_c2 | boolean | Known botnet command-and-control server. |
| is_malware_host | boolean | Hosts malware or malicious content. |
| is_compromised | boolean | Known compromised system. |
| cloud_provider | string? | Cloud provider that owns the range (AWS, Google, Cloudflare, Fastly), or null. |
| is_good_bot | boolean | Verified search engine bot (Googlebot, Bingbot). |
| good_bot_name | string? | Bot name: googlebot, bingbot, google_special_crawler. |
| is_icloud_relay | boolean | Apple iCloud Private Relay egress IP. |
| blocklist_count | integer | How many of the five consensus blocklists list the address (0–5). |
| threat_categories | string[] | Labels for the flags that fired: tor, vpn, proxy (classification) and botnet_c2, malware, attacker, compromised, bogon, blocklisted (accusation). Do not treat a non-empty list as hostile: a Tor exit alone is ["tor"] with verdict allow. |
| country_code | string | ISO 3166-1 alpha-2 country code. |
| country_name | string | Country name in English (e.g. Germany); empty when geo is unknown. |
| region, city, timezone | string | Region, city, and IANA timezone. |
| isp / asn | string / int | ISP name and Autonomous System Number. |
| connection_type | string | Data Center · Residential · Mobile · Education · Corporate · Unknown |
| hostname | string | Reverse DNS hostname. Empty on batch entries (no reverse DNS) and when the lookup did not finish (then rdns is in enrichment_incomplete). |
| signal_breakdown | map | Individual signal scores contributing to fraud_score. |
Verdict, freshness and completeness
| Field | Type | Description |
|---|---|---|
| verdict | string | allow, challenge or block. Enforce on this. Absent on a degraded 503. |
| threat_confidence | string | none, low, medium or high. none exactly when verdict is allow. |
| ip_version | integer | 4 or 6. An IPv4-mapped address is 4. |
| signals_unavailable | string[] | IPv6 only: IPv4 sources with no IPv6 data, so the score is built from fewer lists. |
| degraded | boolean | true only on a 503: the threat database was unreachable, so there is no verdict. |
| enrichment_incomplete | string[] | Optional lookups this answer lacks (e.g. geo, asn, rdns). Empty fields are unknown, not negative. |
| stale_sources | string[] | Feeds past their refresh budget, then freshness_unknown when freshness could not be read. |
| is_scanner | boolean | Known research scanner. Reported, not scored. |
| threat_categories | string[] | Labels for the flags that fired. tor, vpn and proxy classify the address (what it is); botnet_c2, malware, attacker, compromised, bogon and blocklisted accuse it (what it has done). Every challenge or block has at least one accusing label; blocklisted means accusing evidence with no category of its own. is_scanner is never listed. Empty means no labelled flag fired (hosting alone has no label). |
| connection_type | string | Data Center, Residential, Mobile, Education, Corporate (no other keyword matched) or Unknown (no ASN organisation to classify). |
| invalid (batch entry) | boolean | Batch entry: your input was not accepted. Not billed. |
| degraded (batch entry) | boolean | Batch entry: we failed to check it. Not billed. |
Email answer · /v1/email
| Field | Type | Description |
|---|---|---|
| request_id | string | Unique request identifier (UUID); same as the X-Request-ID header. |
| string | The address, normalised (lower case; IDN domains in punycode). As sent when the syntax is invalid. | |
| risk_score | integer | 0–100, the sum of the signals below, capped at 100. There is no verdict field. |
| valid_syntax | boolean | false: the address does not parse; risk_score is 100 and nothing else was checked. |
| mx_found | boolean | The domain accepts mail (MX, or an implicit A/AAAA). Also false when the lookup failed; then mx is in enrichment_incomplete and no_mx is not scored. |
| is_disposable | boolean | Domain is a known disposable-mail provider. |
| is_free_provider | boolean | Domain is a free webmail provider (gmail.com, outlook.com …). |
| is_role_based | boolean | Local part is a role address (info@, admin@, hello@ …). |
| domain_age_days | integer | Days since the domain was registered (RDAP); -1 when unknown. |
| domain_age_unknown | string | Why domain_age_days is -1, when that is permanent: hosted_platform, no_rdap_service, no_registration_date, not_registered. Absent otherwise. |
| domain | string | The domain part; empty when the syntax is invalid. |
| signal_breakdown | map | The signals that fired and their weights. |
| degraded | boolean | Present (true) only when the disposable/free/role tables could not be read: a single call answers 503; in a batch the entry is marked and not billed. Fail closed. |
| enrichment_incomplete | string[] | mx, domain_age, external_lookups (batch budget spent), batch_deadline (batch time limit). Present only when non-empty. |
| Signal | Weight | Description |
|---|---|---|
| invalid_syntax | 100 | Address does not parse. Returned alone; nothing else is checked. |
| no_mx | 40 | Domain accepts no mail. Not scored when the DNS lookup failed. |
| disposable_domain | 35 | Known disposable-mail domain. |
| new_domain | 20 | Domain registered under 30 days ago. |
| young_domain | 10 | Domain registered 30–179 days ago. |
| role_based | 15 | Role address (info@, admin@ …). |
| free_provider | 5 | Free webmail provider. |
Phone answer · /v1/phone
| Field | Type | Description |
|---|---|---|
| request_id | string | Unique request identifier (UUID). |
| phone | string | The number in E.164 form (+14155552671). Empty when the number is not valid. |
| risk_score | integer | 0–100, the sum of the signals below, capped at 100. There is no verdict field. |
| valid | boolean | The number is valid for its country. Needs international format with the country code; a national number (9876543210) is not valid. false scores 100. |
| country_code | string | ISO 3166-1 alpha-2 region of the number (US, IN, GG …). Empty for non-geographic numbers (+800, +882 …). |
| carrier | string | Always empty today: no carrier data is available. |
| line_type | string | mobile, landline, voip, toll_free, premium_rate, shared_cost, personal, pager or unknown. Regions that do not separate fixed and mobile (US, Canada) report mobile. |
| is_voip | boolean | line_type is voip. |
| signal_breakdown | map | The signals that fired and their weights. |
| Signal | Weight | Description |
|---|---|---|
| invalid_phone | 100 | Not a valid number. Returned alone. |
| voip_number | 30 | VoIP number. |
| toll_free | 10 | Toll-free number. |
URL answer · /v1/url
| Field | Type | Description |
|---|---|---|
| request_id | string | Unique request identifier (UUID). |
| url | string | The URL as sent. |
| risk_score | integer | 0–100, the sum of the signals below, capped at 100. There is no verdict field. |
| is_phishing | boolean | On PhishTank, or matched by Google Safe Browsing (when configured). |
| is_malware | boolean | Listed by the URLhaus API (when configured). |
| safe_browsing_threat | string | Comma-separated Safe Browsing threat types; present only on a match. |
| in_phishtank | boolean | URL is in the PhishTank feed. |
| spamhaus_dbl | boolean | Always false; kept for compatibility. |
| domain_age_days | integer | Days since the registered domain was registered; -1 when unknown. |
| domain_age_unknown | string | Why domain_age_days is -1, when that is permanent: hosted_platform, no_rdap_service, no_registration_date, not_registered. |
| ssl_valid | boolean | true exactly when ssl_status is valid. |
| ssl_status | string | valid: an HTTPS request succeeded under Cloudflare’s strict certificate validation (trusted chain, hostname match, not expired); revocation is not checked. For a host itself behind Cloudflare, its edge certificate is what is tested. invalid (scored no_ssl), unknown (probe did not finish; not scored), not_probed (not scored). |
| signal_breakdown | map | The signals that fired and their weights. |
| degraded | boolean | Present (true) only when the PhishTank data could not be read: a single call answers 503; in a batch the entry is marked and not billed. Fail closed. |
| stale_sources | string[] | phishtank when that feed is past its refresh budget, or freshness_unknown. |
| enrichment_incomplete | string[] | safe_browsing, urlhaus (lookup failed — not the same as not listed), domain_age, ssl, external_lookups, batch_deadline. |
| Signal | Weight | Description |
|---|---|---|
| safe_browsing | 40 | Google Safe Browsing match. Only when Safe Browsing is configured. |
| phishtank | 35 | Listed on PhishTank. |
| urlhaus | 35 | URLhaus API match. Only when the URLhaus API is configured. |
| new_domain | 15 | Domain registered under 30 days ago. |
| no_ssl | 15 | HTTPS failed (ssl_status invalid). Not scored when the probe did not finish or was not made. |
WHOIS answer · /v1/whois
| Field | Type | Description |
|---|---|---|
| ip | string | The address, canonical. |
| ip_version | integer | 4 or 6. |
| network_cidr | string | CIDR notation (8.8.8.0/24). A block that is not one prefix is several, joined with ", " — split before parsing. |
| network_handle | string | The registry’s identifier for the network (ARIN NET-34-64-0-0-1; RIPE and APNIC give an address range). |
| network_name | string | Registry network name. |
| org_name | string | Registrant organisation. |
| abuse_contact | string | Abuse e-mail address, when the registry gives one. |
| rir | string | ARIN, RIPE, APNIC, LACNIC or AFRINIC. |
| allocation_date | string | YYYY-MM-DD; empty if none. |
| country_code | string | The registry’s country for the network (else the registrant’s). Not geolocation. |
| asn | integer | Origin AS number. |
| asn_org | string | AS organisation. |
| enrichment_incomplete | string[] | rdap (lookup failed: the RDAP fields are empty because we could not ask) or asn. |
Assess answer · /v1/assess
| Field | Type | Description |
|---|---|---|
| overall_risk | integer | max(ip × 1.0, url × 0.9, email × 0.8, phone × 0.7), rounded down. |
| ip | object | The /v1/check answer, when ip was given. |
| object | The /v1/email answer, when email was given. | |
| phone | object | The /v1/phone answer, when phone was given. |
| url | object | The /v1/url answer, when url was given. |
| degraded | boolean | Present (true) on a 503: an entity could not be assessed, so overall_risk is an under-estimate. Fail closed. |
| stale_sources | string[] | Union of the parts’ stale_sources. |
| enrichment_incomplete | string[] | The parts’ markers prefixed with the entity (ip.geo, url.ssl), sorted. |
Report body and answer · /v1/report
| Field | Type | Description |
|---|---|---|
| ip (request) | string | Required. IPv4 or IPv6, same rules as /v1/check. |
| is_fraud (request) | boolean | Optional; default false. |
| category (request) | string? | Optional: spam, phishing, brute_force, scraping, bot, other, or null. |
| comment (request) | string? | Optional; at most 1,000 characters. |
| status (answer) | string | accepted. |
| message (answer) | string | Fraud report submitted successfully. |
Batch response format
| Field | Type | Description |
|---|---|---|
| results | array | One entry per input, in input order: the single-endpoint answer, or an entry marked invalid or degraded (see Batch rules). 200; 503 when no entry could be answered (invalid ones aside); 400 when every entry is invalid. |
Health · GET /health
| Field | Type | Description |
|---|---|---|
| status | string | ok (200) or degraded (503, with X-VerifIP-Degraded). |
| version | string | Build identifier (edge-1.0.0). |
| d1 | string | Geo/ASN database: ok, error, timeout or not configured. |
| d1_threats | string | Threat database: ok only when a real shard was read and decoded; otherwise undecodable, missing, error, timeout or not configured. |
| supabase_auth | string | configured or not configured. |
| uptime_seconds | integer | Seconds since this server instance first answered /health; absent on that first call. |
Signal breakdown · weights
Only signals that fired appear in signal_breakdown. Correlated signals are combined, so the score is not their sum. Sources are listed on Data sources.
| Signal | Weight | Description |
|---|---|---|
| bogon | 100 | Unroutable/reserved IP — instant block. |
| botnet_c2 | 40 | Botnet C2 server (abuse.ch ThreatFox). |
| tor_exit | 25 | Tor exit node (Tor Project exit list, refreshed every 6h). |
| malware_host | 25 | Malware hosting (abuse.ch URLhaus, ThreatFox). |
| blocklist_consensus | up to 20 | 4 per list, across blocklist.de, CINS Army, GreenSnow, Emerging Threats and IPsum. |
| blocklist_corroboration | 11 | blocklist_count of 4 or more: at least three independent operators list it. |
| vpn_detected | 20 | X4BNet VPN ranges, or a VPN-pattern reverse-DNS hostname. |
| firehol_l1 | 20 | FireHOL level 1 (highest severity). |
| spamhaus_listed | 20 | In a Spamhaus DROP range (imported daily). |
| proxy_detected | 15 | FireHOL proxy/anonymous lists, iplocate open proxies. |
| known_attacker | 15 | On blocklist.de, CINS Army or GreenSnow, or in a Spamhaus DROP range. |
| firehol_l2 | 15 | FireHOL level 2. |
| compromised | 10 | Known compromised host (Emerging Threats). |
| datacenter_ip | 10 | X4BNet datacenter ranges. |
| firehol_l3 | 10 | FireHOL level 3. |
| icloud_relay | -10 | Apple iCloud Private Relay (reduces score). |
| good_bot | -50 | Google or Bing crawler, matched against their published ranges (reduces score). |
How should I read the fraud score?
How do batch requests work?
1–100 items, paid plans only (403 plan_required on Free, not billed). Each item must be a string of at most 64 characters (IPs, phones), 320 (emails) or 2,048 (URLs); otherwise the whole request is a 400. A batch is one unit of your burst rate, and it reserves its full length from your quota up front (refused whole with 429 if it does not fit); only answered entries are billed. Results come back in input order. Entries without an answer are marked — never read their zeros as clean. If every entry is invalid the batch is a 400; if no entry could be answered (invalid ones aside) it is a 503; neither is billed. IP batches use local data only (no reverse DNS, no external reputation lookup). Email and URL batches do full lookups for about 15 uncached unique domains, marking the rest external_lookups, and stop waiting on lookups 2 seconds after the batch starts, marking what was still running batch_deadline. Neither is billed; resubmit those entries.
| Entry marker | Billed | Meaning |
|---|---|---|
| invalid: true + error | No | Your input was not accepted. |
| degraded: true + error | No | We failed to check it (sets X-VerifIP-Degraded). |
| enrichment_incomplete: ["external_lookups"] | No | Email/URL: answered from local data only. Resubmit it. |
| enrichment_incomplete: [..., "batch_deadline"] | No | Email/URL: a lookup was still running at the batch time limit. Resubmit or check singly. |
| (full answer) | Yes, 1 each | Scored normally, including entries with other enrichment_incomplete markers (mx, domain_age, ssl …). |
Does VerifIP support IPv6?
/v1/check, /v1/check/batch, /v1/whois, /v1/assess (its ip) and /v1/report accept IPv6. IPv6 answers set ip_version: 6 and list in signals_unavailable the sources that have no IPv6 data, so the score is built from fewer lists. blocklist_count is always 0 for IPv6. Link-local, unique-local (fc00::/7), multicast, loopback, unspecified and documentation (2001:db8::/32, 3fff::/20) addresses are refused with 400 invalid_ip; an IPv4-mapped address (::ffff:a.b.c.d) is answered as the IPv4 it carries.
Which response headers does VerifIP send?
| Header | Meaning |
|---|---|
| X-VerifIP-Units-Billed | Quota units this request was charged. A single call: 1, also when it returns a 5xx (including a degraded 503); 0 when it is refused as invalid (400), refused by your quota (429 rate_limit_exceeded) or when the quota counter was unreachable (503). A batch: the entries billed (not invalid or degraded entries, and not entries marked external_lookups or batch_deadline); 0 for a 5xx or a refused 400. 0 for a burst 429, capacity_unavailable or a free-plan batch’s 403 plan_required. Absent on 401, an authentication 503, the 403 key errors (key_disabled, public_key_not_allowed, key_type_not_allowed), 404 and 405, which are never billed. |
| X-RateLimit-Limit / -Remaining / -Reset | Monthly quota, what is left after this request’s settled charge, and when it resets (Unix time: 00:00:00 UTC on the 1st). Remaining is approximate between exact counts (it can differ by a few requests across server instances); it is exact near your limit, and a quota refusal is only ever made from an exact count. |
| X-RateLimit-Daily-Limit / -Daily-Remaining | Free plan only: the 1,000/day cap and what is left (approximate, like Remaining). Resets 00:00 UTC. |
| X-RateLimit-Burst-Limit / -Burst-Period | Your plan’s burst rate and its window in seconds (10). Approximate, a soft brake: counted per Cloudflare location and per machine with delayed sync, so several connections together can briefly exceed it. Pace client-side; only the monthly quota is exact. Lower while paid keys are held to a fair share. On every metered response, success or refusal. |
| X-VerifIP-Degraded | true when the answer (or, on a batch, at least one entry) was produced without a database it depends on, and on every other 503 (capacity_unavailable, service_unavailable). Branch on the status and error code; a 503 whose body has degraded: true has no verdict, so fail closed. |
| X-VerifIP-Incomplete | Batch responses only: true when at least one entry carries enrichment_incomplete. Single answers: read the enrichment_incomplete field. |
| Retry-After | Seconds to wait, rounded up. On every 429 (burst: 10, or 60 for the per-key ceiling; quota: until the window resets), on capacity_unavailable (free-plan shed: until 00:00 UTC) and on some quota-counter 503s (1). |
| X-Request-ID | On every response: a UUID, the same as request_id in a successful answer’s body. Quote it when contacting support. |
| Allow | On a 405: the method the endpoint takes (GET or POST). |
When should I retry, and what are the rate limits?
When to retry
Wait the Retry-After header (every 429 and capacity_unavailable); since API 1.4.2 the body repeats it as retry_after. A retry is a new request: it is billed like one.
| Response | What to do |
|---|---|
| 429 burst_limit_exceeded | Wait the Retry-After header (10 s; 60 s for the per-key ceiling of about 3,000 requests/60 s), then retry. Pace with X-RateLimit-Burst-Limit / X-RateLimit-Burst-Period. |
| 429 rate_limit_exceeded | Monthly quota (or the Free daily cap) is spent. Not billed. Do not retry before the Retry-After header’s seconds have passed: that is when the window resets. |
| 503 capacity_unavailable | Load shedding; nothing ran, not billed. Wait the Retry-After header (free plan: until 00:00 UTC), then retry. |
| 503 service_unavailable | Authentication, quota or storage briefly unreachable. Retry with back-off, honouring Retry-After when present. |
| 503 with degraded: true | No error field and no verdict, and a single call is billed 1 — so is each retry. Retry once or twice with back-off, then fail closed. |
| 500 / 502 / 504 | Retry with exponential back-off and jitter. Do not blindly retry an accepted POST /v1/report: a retry stores a duplicate and is billed again. |
What are the rate limits on each plan?
| Plan | Monthly limit | Daily limit | Burst rate (req / 10s, approximate) | Price |
|---|---|---|---|---|
| Free | 10,000 | 1,000 | 10 | $0 |
| Starter | 50,000 | — | 30 | $49/mo |
| Growth | 250,000 | — | 100 | $199/mo |
| Scale | 1,000,000 | — | 300 | $699/mo |
| Enterprise | Custom volume | — | Custom (per agreement) | Custom |
The Free plan is also capped at 1,000 requests/day (reset 00:00 UTC) so the monthly quota can't be used up in a single day. Paid plans have no daily cap — only the monthly quota applies (reset 00:00 UTC on the 1st). Requests beyond the quota are refused with HTTP 429 rate_limit_exceeded, which is not billed, until the window resets or you upgrade. A batch reserves its full length up front, so a batch that does not fit in what is left is refused whole (429, not billed). One /v1/assess call is 1 unit, however many entities it checks.
Every metered response carries X-RateLimit-Burst-Limit and X-RateLimit-Burst-Period. Burst rates are a soft brake, not an exact limit: they are enforced per Cloudflare location and per machine, with counts shared after a short delay, so traffic spread over several connections can briefly exceed them, while a single connection is held close to the figure. The monthly quota is exact and is the commercial limit. Pace with your own client-side limiter and honour Retry-After rather than relying on 429s to slow you down. Independently, the edge blocks a single source IP above about 350 requests per 10 seconds. A batch is one burst unit whatever its length. Each key is also held to about 3,000 requests per 60 seconds (429 burst_limit_exceeded, Retry-After 60) against runaway loops and leaked keys. At very high load, free-plan requests may be refused 503 capacity_unavailable until 00:00 UTC, and paid keys may drop to a fair-share burst limit; neither is billed. A request body that is not valid JSON is a 400 invalid_request, not billed.
What do VerifIP error codes mean?
Errors are JSON: { "error": "<code>", "message": "<text>" }. Wait the Retry-After header (every 429 and capacity_unavailable); since API 1.4.2 the body repeats it as retry_after. A degraded 503 has no error field: it carries the partial answer with degraded: true. Every 503 sets X-VerifIP-Degraded: true, so branch on the HTTP status and the error code first, and treat the header as a monitoring signal. Every response carries X-Request-ID; quote it to support.
| error | HTTP | When |
|---|---|---|
| invalid_ip | 400 | ip missing or not an IP address; a private (10/8, 172.16/12, 192.168/16, fc00::/7), loopback, link-local, multicast or unspecified (0.0.0.0, ::) address; IPv6 documentation space (2001:db8::/32, 3fff::/20); an IPv6 zone identifier (%eth0); a deprecated IPv4-compatible IPv6 address. Reserved IPv4 ranges such as 203.0.113.0/24 are not refused: they are answered is_bogon, score 100, block. Not billed. |
| invalid_email | 400 | email missing or empty. A malformed address is not an error: it is a 200 with valid_syntax false and risk_score 100 (signal invalid_syntax). Not billed. |
| invalid_phone | 400 | phone missing or empty. A number that does not parse is not an error: it is a 200 with valid false, phone "" and risk_score 100 (signal invalid_phone). Not billed. |
| invalid_url | 400 | url missing, unparseable or without a host; a bare IPv4 host; a host with no dot; an internal or reserved name (localhost, metadata, *.local, *.internal, *.localhost, *.test, *.example, *.invalid). Not billed. |
| invalid_request | 400 | Body is not valid JSON ("Request body is not valid JSON.") or (since API 1.4.2; earlier versions answered a null batch body with 500) not a JSON object; a batch array missing, empty or over 100; a batch entry that is not a string or is longer than the cap (ip 64, email 320, phone 64, url 2048 characters); every batch entry invalid; /v1/assess with none of ip, email, phone, url; a /v1/report field error (an unacceptable ip is invalid_ip). Not billed. |
| invalid_api_key | 401 | Missing or invalid API key. |
| unauthorized | 401 | Console endpoints: no Authorization header. |
| invalid_token | 401 | Console endpoints: the session token is invalid or expired. Sign in again. |
| wrong_auth_type | 401 | Console endpoints: an API key (vip_…) was sent where a session token is required. |
| user_not_found | 401 | Console endpoints: the session is valid but the account no longer exists. |
| key_disabled | 403 | Key deactivated. Not billed. |
| public_key_not_allowed | 403 | A DetectBT publishable key (vip_pub_…) on the data API. Use your VerifIP API key (under API Keys → VerifIP). Not billed. |
| key_type_not_allowed | 403 | Any other key that is not a VerifIP API key, such as a DetectBT secret key (sk_live_…). Use your VerifIP API key (under API Keys → VerifIP). Not billed. |
| plan_required | 403 | Batch endpoint on the Free plan. Not billed. |
| not_found | 404 | No such endpoint. Answered before the key is checked. Not billed. |
| method_not_allowed | 405 | Wrong method for the endpoint; the Allow header names the right one. Not billed. |
| burst_limit_exceeded | 429 | Your plan’s burst rate (per 10 s), or the per-key ceiling of about 3,000 requests per 60 s. Both are approximate soft limits, not a guaranteed cut-off, so pace client-side. Not billed. Wait the Retry-After header (10 s, or 60 for the ceiling); the body repeats it as retry_after. |
| rate_limit_exceeded | 429 | Monthly quota (or the Free plan’s 1,000/day cap) spent. Not billed. Wait the Retry-After header: the seconds until the window resets (00:00 UTC on the 1st, or 00:00 UTC for the daily cap); since API 1.4.2 the body repeats it as retry_after. Do not retry before then. |
| internal_error | 500 | Server error. Retry with back-off. |
| capacity_unavailable | 503 | Load shedding; nothing ran. Not billed. Wait the Retry-After header (the body repeats it as retry_after): for free-plan requests shed at high load it is the seconds to 00:00 UTC; for paid keys held to a fair share, 10. Also sets X-VerifIP-Degraded. |
| service_unavailable | 503 | Authentication, the quota counter or report storage could not be reached. Not billed when authentication or the quota counter was unreachable; a report-storage 503 is billed 1. Retry with back-off (Retry-After: 1 when sent). Also sets X-VerifIP-Degraded. |
| (none; body has degraded: true) | 503 | Degraded answer: a database behind the answer could not be read, so the flags are defaults and there is no verdict. Fail closed. Billed 1 for a single call, 0 for a batch. |
This product includes GeoLite Data created by MaxMind, available from https://www.maxmind.com. IP blocklist data: The Spamhaus Project (DROP). Phishing data: PhishTank, CC BY-SA 2.5. Malware data: abuse.ch. Hextner uses the IP2Proxy LITE database for IP geolocation. Full notices: Data sources.