IP intelligence
One lookup places an IPv4 or IPv6 address on the map, names the network and the operator, and flags the traffic that is not a person on a home connection: cloud and datacenter ranges, Tor exits, named VPN exits, iCloud Private Relay, corporate gateway egress, and addresses on public threat blocklists, and names the verified search and AI crawlers. Every signal comes from a local database or a compiled feed, so an answer takes milliseconds and the address never leaves the server unless you ask for the registry’s abuse contact.
$ curl https://spaw.co/api/v1/ip \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{"ip": "8.8.8.8"}'
Fields and flags
Every response carries the same fields in the same order. The threat flags are tri-state: false is a checked negative and null means the signal was not evaluated, because no feed for it covers the address family. Every feed group, Tor included, carries IPv6 today.
| ip | The canonical form of the address; IPv4-mapped IPv6 input collapses to dotted-quad. |
| version | The address family. |
| reason | reserved_range for private, loopback, CGNAT, documentation and multicast space. Every other field is then null and the call is free. See CGNAT. |
| network | The CIDR prefix the location answer covers, such as 8.8.8.0/24. |
| continent | Two-letter continent code: AF, AN, AS, EU, NA, OC, SA. |
| country | ISO 3166-1 alpha-2 code. |
| country_name | The English name of the country. |
| is_eu | Whether the country is a member of the European Union. Null when the country is unknown. |
| is_eea | Whether the country is in the European Economic Area: the EU plus Iceland, Liechtenstein and Norway, where the GDPR applies directly. Null when the country is unknown. |
| privacy_regime | The comprehensive privacy law where the address is, as a lowercase code: the region first where a state or province has its own law (California ccpa, Quebec law25), otherwise the country (gdpr across the EU and EEA, uk_gdpr, fadp, lgpd, pipeda, appi, pipl, popia, …). Null means no comprehensive law is known for the place, never that there is none; read the code with the country, since pdpa names different laws in Singapore, Thailand, Malaysia and Taiwan. See the country pages. |
| sanctions | The sanctions programmes that name the country, in order: ofac_comprehensive (a comprehensive US programme: Cuba, Iran, North Korea) and ofac_programme (any country-related OFAC programme). An empty list when the country is known and no programme names it; null when the country is unknown. A fact about a place, never a verdict about a person. See the country pages. |
| fatf_list | call_for_action when the FATF lists the country as high-risk (Iran, North Korea, Myanmar), increased_monitoring when it is under increased monitoring (22 jurisdictions after the June 2026 plenary). Null means on neither list or unknown. See the country pages. |
| region | State or province name. Null when an operator-declared location disagrees with the database. |
| city | City name. Approximate from the database; exact where the operator publishes it. |
| latitude / longitude | Coordinates of the located area. Null when an operator-declared location disagrees with the database, rather than a contradicting point. |
| location_source | operator when the network operator publishes the location itself (an RFC 8805 geofeed, Apple’s relay list, a VPN provider’s server list); database for the DB-IP Lite answer. See geofeed. |
| location_confidence | low whenever is_anycast, is_mobile or is_satellite is true, whoever placed the address: the place is then an anycast instance, a carrier gateway or a ground station, not the user’s. Otherwise high when the operator declared the location itself, and graded from the weekly accuracy report’s city agreement for the country: 75 % and above high, 50 % medium, below that low. Null with fewer than 20 compared samples for the country or no report yet. |
| timezone | IANA zone such as Europe/Berlin: the curated map’s answer when the country or a region known to lie in one zone settles it, else the zone of the nearest listed place inside the located country within 250 km. Null when neither settles it. |
| timezone_source | What settled the zone: region when the map knows the region lies in one zone, country when the country keeps one clock, nearest_city when the nearest GeoNames place inside the country did, the case for split countries and regions such as Texas, Canada, Australia, Brazil, Russia and Indonesia. |
| timezone_matches | Only when the lookup carries browser_timezone: true when the browser’s zone is the located zone or keeps the same clock right now (Paris and Berlin), false when the clocks differ, null when there is nothing to compare or the zone is unknown. See time-zone mismatch. |
| currency | ISO 4217 currency of the country, from ICU region data. |
| calling_code | The country’s international calling code, such as 49. |
| asn / org | The autonomous system number and the organisation operating it. See ASN and the autonomous system pages. |
| asn_type | hosting, isp, education or government. hosting whenever is_datacenter is true or the operator name says so; null when it cannot be told. |
| connection_type | What the address is attached to, from positive evidence only and in this precedence: corporate_proxy, hosting (a datacenter range or a verified crawler), satellite, mobile, residential (a dynamic reverse-DNS name, so only with hostname: true). Null when nothing said so; it never means residential by default. |
| is_mobile | Whether the autonomous system is a listed mobile carrier, from a curated positive-only list (T-Mobile USA, Verizon Wireless, AT&T Mobility, EE, Three, Free Mobile, NTT Docomo, SK Telecom, Reliance Jio, Airtel, Vodafone Idea, the China Mobile provinces, Telkomsel, Smart, Telcel, Turkcell, MTN, Vodacom, MegaFon, …). False means not listed, never a guess; null when the ASN is unknown. Such addresses are shared behind carrier NAT and move with the handset. |
| is_satellite | Whether the autonomous system is a listed satellite ISP (Starlink, Viasat, HughesNet), whose addresses land at a ground station rather than near the subscriber. Same semantics as is_mobile. |
| registry | The regional registry that allocated the space: arin, ripencc, apnic, lacnic or afrinic, from the RIRs’ daily delegated statistics. See RIR allocation and the autonomous system pages. |
| registry_country | The country the registry recorded for the allocation. When it disagrees with the geolocation, look twice. |
| allocated_at | The allocation date the registry recorded (YYYY-MM-DD). A block allocated or transferred weeks ago is a classic fraud tell; a fact for you to weigh, not scored. |
| abuse_contact | The network’s abuse mailbox from its RDAP record, only when asked for with abuse_contact: true (a registry lookup, cached per allocation). Null otherwise or when the registry lists none. |
| network_details | Only with network_details: true: the RDAP record’s name, organisation, range, registered_at and updated_at, from the same cached registry lookup as abuse_contact. Null otherwise or when the registry could not be read. |
| hostname | Only with hostname: true: the reverse DNS (PTR) name of the address, a live DNS query with a two-second budget. Null otherwise or when there is none. |
| hostname_verified | Whether the PTR name resolves back to the address (forward-confirmed reverse DNS). A verified name under googlebot.com, google.com, search.msn.com, applebot.apple.com, yandex.* or crawl.baidu.* also proves the crawler. Null unless hostname was requested. See forward-confirmed reverse DNS. |
| hostname_kind | What the naming convention says the address is: dynamic (a pool, DHCP, PPP or dial-up name) or static (a fixed, dedicated or hosting name). Null when the name says neither, or unless hostname was requested. |
| hostname_domain | The registrable domain of the PTR name (comcast.net, amazonaws.com), so the operator behind a reverse name is one field. Null unless hostname was requested. |
| is_datacenter | Inside known hosting or cloud ranges: the providers’ own feeds, GitHub’s hosted runner ranges, plus the open X4BNet list. See datacenter IP. |
| hosting_provider | Amazon Web Services, Google Cloud, Microsoft Azure, Oracle Cloud, DigitalOcean, Linode, Vultr, Cloudflare, Fastly or GitHub Actions when the address is inside that provider’s own published ranges. |
| is_corporate_proxy | The egress of a corporate secure web gateway, from the cloud enforcement node ranges Zscaler publishes for its eight clouds. An employee behind one reaches the Internet from the gateway’s address, so the location is the gateway’s and the traffic is a workplace, not a home connection. No weight, and the datacenter weight is skipped for it. See corporate proxy. |
| corporate_proxy | The gateway operator: Zscaler. Null unless is_corporate_proxy is true. |
| is_crawler | Inside the ranges a crawler operator publishes for verification (Googlebot and Google’s special crawlers and fetchers, Bingbot, GPTBot, ChatGPT-User, OAI-SearchBot, PerplexityBot, Perplexity-User, DuckDuckBot, AhrefsBot), or proved by a verified hostname. Not a risk: no weight, and the datacenter weight is skipped for it. See the crawler pages. |
| crawler | The crawler that owns the range or the verified name: Googlebot, Bingbot, GPTBot, ChatGPT-User, OAI-SearchBot, PerplexityBot, Perplexity-User, DuckDuckBot, AhrefsBot, Applebot, YandexBot, Baiduspider, Google special crawlers or Google user-triggered fetchers. Null unless is_crawler is true. See the crawler pages. |
| crawler_kind | What the crawler does: search, ai_training, ai_assistant, seo or fetcher (Google’s special crawlers and user-triggered fetchers). |
| is_anycast | A published anycast range: CDN edges and the public resolvers. Geolocation means little for such an address. See anycast. |
| is_unallocated | No regional Internet registry has allocated or assigned the space (the five RIRs’ daily statistics). Such an address should not be on the public Internet; it scores 100. See bogon. |
| is_blocklisted | A public threat blocklist names the address’s netblock or its operator: Spamhaus DROP (netblocks hijacked or leased by spam and cybercrime operations), Spamhaus ASN-DROP (the operators behind them, matched by ASN) and abuse.ch’s Feodo Tracker list of active botnet C2 servers. Scores 80. |
| blocklist | The list that names the address: Spamhaus DROP, Spamhaus ASN-DROP or abuse.ch Feodo Tracker, the first in that order when several do. |
| is_attack_source | An attack-source list names the address: single hosts seen attacking honeypots and sensors in the last days, from Emerging Threats’ compromised-hosts list, the CINS Army list and blocklist.de, refreshed hourly. Apart from is_blocklisted because these name hosts rather than netblocks or operators and churn daily. Scores 50. Null where no list covers the family (the three are IPv4 today). See attack-source list. |
| attack_source | The list that names the address: Emerging Threats compromised hosts, CINS Army or blocklist.de, the first in that order when several do. |
| is_tor | A listed Tor exit, from the Tor Project’s bulk exit list and its Onionoo relay data, so IPv6 exits are covered too. Both lists are synced hourly. See Tor exit node. |
| tor_last_seen | When the address was last seen exiting Tor (YYYY-MM-DD): the current list’s date while it is on it, otherwise the date the thirty-day exit history last saw it. Null when the history never saw it. Exits rotate daily, and an address that left the list yesterday is still the same machine. |
| is_vpn | A known VPN exit: the Mullvad, NordVPN, Private Internet Access, IVPN, AirVPN and VPN Gate server lists, the open X4BNet list, and Cloudflare WARP by inference. False on iCloud Private Relay egress unless a named list has the address. |
| is_relay | An iCloud Private Relay egress address, from Apple’s published list. See iCloud Private Relay. |
| privacy_service | Mullvad, NordVPN, Private Internet Access, IVPN, AirVPN, VPN Gate or iCloud Private Relay when a provider’s own list names the address, and "Cloudflare WARP (inferred)" when Cloudflare’s autonomous system carries the address outside the CDN ranges it publishes and outside the public resolvers: Cloudflare publishes no WARP egress list, so the suffix says the answer is an inference. |
| is_anonymous | True when any of is_tor, is_vpn or is_relay is true; false when at least one was checked and none matched; null when none was evaluated. |
| account_list | The account’s own allow or deny list that names the address: id, name, kind (allow or deny) and the entry (address or prefix) that matched. Null when no list names it. |
| risk_score | Denylisted 100 + unallocated 100 + blocklist 80 + Tor 70 + reported_abuse 60 + attack_source 50 + datacenter 40 + reported_abuse_widely 40 + recently_tor 40 + VPN 30 + relay 10 over the signals checked, capped at 100; 0 whatever else fired when an allow list names the address. Null only when no feed evaluated the address and nobody reported or listed it. |
| risk_level | high from 60, medium from 30, low below that. |
| risk_signals | Each contributing signal with the weight applied and the dataset behind it; the weights sum to risk_score. An allow-list hit is one allowlisted entry of weight 0 and nothing beside it. |
| seen_by_you | How often this account looked the address up inside the last seven days before this call: count, first_seen_at and window_days, kept under a keyed hash of the address. Null under privacy: true and for reserved addresses. |
| sources | The datasets consulted, each with the date it was last synced. Only groups that were evaluated appear. |
Where the network operator publishes the location itself, that declaration wins over the database and location_source says operator: RFC 8805 geofeeds from DigitalOcean, Linode and Vultr, Apple’s relay egress list, and the server lists of Mullvad, NordVPN, IVPN and AirVPN name the city, Private Internet Access and VPN Gate the country. When the operator disagrees with the database on country or city, the region and the coordinates answer null instead of a point in the wrong place.
Country facts come from data the service already ships: currency from ICU region data, calling_code from libphonenumber, and timezone from the IANA zone list when the country or a region known to lie in a single zone settles it. A visitor in California gets America/Los_Angeles from the map. A visitor in Texas, which straddles two zones, gets the zone of the nearest place in the GeoNames list of populated places (CC BY 4.0, synced monthly) inside the United States, and timezone_source says nearest_city; a coordinate more than 250 km from any listed place, or whose nearest place lies across a border, answers null rather than the nearest mainland zone. Every country’s registries, regime, sanctions status, clock and measured accuracy are on the country pages.
sanctions and fatf_list read the curated compliance file that ships with the service: the country-related programmes on the OFAC sanctions programme list (checked on 2026-09-12; comprehensive for Cuba, Iran and North Korea, and the programmes for the occupied regions of Ukraine are region-level, which a country code cannot answer) and the FATF’s two lists as of the 19 June 2026 plenary (Iran, North Korea and Myanmar under a call for action; 22 jurisdictions under increased monitoring). They are facts about a place, never a verdict about a person: a visitor located in a listed country is somebody whose screening your compliance policy decides, not a fraud signal, and neither field carries a weight.
The threat blocklists are Spamhaus DROP and ASN-DROP, published by The Spamhaus Project and used with the credit its terms ask for, and abuse.ch’s Feodo Tracker (CC0). A hit names the list in blocklist. ASN-DROP can only answer once the ASN database resolves the address, so when neither a range list nor the ASN could settle it the flag answers null rather than false.
The attack-source lists are a different kind of thing and are kept apart: Emerging Threats’ compromised-hosts list (from its open ruleset, BSD-licensed, credited here as its terms ask), the CINS Army list (“provided as a simple text file, with which you can parse and use in any way you see fit”, in CINS’s words) and blocklist.de’s export (a free service that publishes no licence text and offers the files “as they are”). They name single hosts seen attacking honeypots and sensors in the last days, so an entry says a machine was misbehaving this week, not that a netblock belongs to a criminal operation; that is why a hit weighs 50 rather than 80, why the lists are refreshed hourly, and why attack_source names the list so you can weigh the publisher yourself.
The crawler ranges are the files Google, Microsoft, OpenAI, Perplexity, DuckDuckGo and Ahrefs publish so sites can verify their bots, synced daily, and the corporate gateway ranges are the cloud enforcement node lists Zscaler publishes so its customers can allow-list them. City answers improve over time from the RFC 8805 geofeeds discovered across the five registries every week, and the registry facts come from the same daily delegated statistics that answer is_unallocated. The mobile carrier and satellite operator lists are curated files that ship with the service and name each operator’s autonomous system; an operator not in them answers false, never a guess. Every source, its terms and its size today are on the data sources pages.
Risk score
The score is arithmetic over the flags in the same response, capped at 100. Recompute it, or ignore it and branch on the booleans. Three rules apply: the datacenter weight is skipped when is_relay, is_crawler or is_corporate_proxy is true, because relay egress, verified crawlers and corporate gateways sit on infrastructure by design and their operator is known; is_vpn answers false on relay egress unless a VPN provider’s own list names the address, because Apple’s first-party declaration outranks the aggregate list that also carries those ranges; and an address on one of your own allow lists scores 0 whatever else fired, with a single allowlisted signal of weight 0 saying why.
| On your own deny list | +100 |
| Unallocated space (no registry has assigned it) | +100 |
| Threat blocklist (Spamhaus DROP or ASN-DROP, abuse.ch Feodo Tracker) | +80 |
| Tor exit | +70 |
| Reported by you as abuse, fraud, a bot or a chargeback inside the window | +60 |
| Seen attacking honeypots and sensors in the last days (Emerging Threats, CINS Army, blocklist.de) | +50 |
| datacenter or cloud range | +40 |
| Reported by three or more accounts | +40 |
| Seen exiting Tor in the last seven days, no longer listed | +40 |
| VPN exit | +30 |
| iCloud Private Relay egress | +10 |
Worked examples, as the API answers them:
| Home broadband, nothing matched | none | 0, low |
| Google’s public resolver 8.8.8.8, in the aggregate datacenter list | datacenter 40 | 40, medium |
| A Mullvad exit hosted in a datacenter | datacenter 40, VPN 30 | 70, high |
| A Tor exit on a hosting provider | Tor 70, datacenter 40 | 100, high (capped) |
| A netblock on Spamhaus DROP, nothing else matched | blocklist 80 | 80, high |
| A host CINS saw attacking yesterday, on a home connection | attack_source 50 | 50, medium |
| A host CINS saw attacking yesterday, on a hosting range | attack_source 50, datacenter 40 | 90, high |
| iCloud Private Relay egress on a CDN | datacenter 0 (skipped for relay), relay 10 | 10, low |
| Googlebot on Google’s own published ranges | datacenter 0 (skipped for a crawler); is_crawler true | 0, low |
| Zscaler egress on a datacenter range | datacenter 0 (skipped for a corporate gateway); is_corporate_proxy true | 0, low |
| An address that left the Tor exit list two days ago | recently_tor 40 | 40, medium |
| Home broadband you reported as fraud last week | reported_abuse 60 | 60, high |
| Home broadband on your deny list | denylisted 100 | 100, high |
| A Tor exit on your allow list | allowlisted 0 | 0, low |
| IPv6 address, Tor list not evaluated, nothing else matched | none; is_tor null | 0, low |
| Private address 192.168.1.1 | reserved_range | null, free |
risk_signals spells the sum out so nobody re-implements it. For the relay example above it reads:
"risk_signals": [ { "signal": "datacenter", "weight": 0, "dataset": "datacenter-ranges" }, { "signal": "relay", "weight": 10, "dataset": "relay-ranges" } ], "risk_score": 10, "risk_level": "low"
Residential proxies are not detectable with open data and are not claimed. When that signal ships it will be a separately labelled field, never folded into these weights. Nor is connection_type a score: it names what positive evidence says the address is attached to, and null never means residential.
Self and browser lookups
The same lookup is available three ways from a server. POST /api/v1/ip takes the address in the body, GET /api/v1/ip/{ip} takes it in the path, and GET /api/v1/ip/me answers for the address the call came from, as seen through the proxy in front of the service. Options ride in the body or the query string.
$ curl https://spaw.co/api/v1/ip/me \ -H "Authorization: Bearer sk_live_…" $ curl "https://spaw.co/api/v1/ip/2001:4860:4860::8888?privacy=1" \ -H "Authorization: Bearer sk_live_…" $ curl "https://spaw.co/api/v1/ip/72.21.1.1?browser_timezone=America/Chicago&fields=country,timezone,timezone_matches,is_anonymous" \ -H "Authorization: Bearer sk_live_…"
Two options shape the answer rather than the lookup. browser_timezone is the IANA zone your page read from the visitor’s browser (Intl.DateTimeFormat().resolvedOptions().timeZone); the response compares it with the located zone in timezone_matches, the cheapest strong signal that a connection is not where its owner is. A zone the service does not know answers 422. fields is a comma-separated list of the field names to keep in data: unknown names are ignored, an empty list means everything, and sources is only sent when named, so a page that wants a country and a currency receives two keys rather than sixty. Both work on the three server endpoints and on the browser endpoint below. The two GET endpoints answer with Cache-Control: private, max-age=300, since nothing about an address changes within five minutes.
From a page, use a publishable key against POST /api/v1/ip/public. Create the key on the API keys page and list the domains it may run from; the endpoint checks the browser’s Origin header against that list. Leave ip out and the visitor’s own address is looked up, which is the usual case for choosing a language, a currency, or a consent banner on first paint:
const response = await fetch('https://spaw.co/api/v1/ip/public', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'pk_live_…', browser_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, fields: 'country,is_eu,currency,timezone_matches,is_anonymous', }), }); const { data } = await response.json(); if (data.is_eu) showConsentBanner(); if (data.currency) setDefaultCurrency(data.currency); if (data.is_anonymous || data.timezone_matches === false) requireEmailConfirmation();
Lookups bill the key’s owner and the response carries no meta block, so visitors never see the balance. Because the key sits in page source, give it a daily credit cap; once spent, the endpoint answers 429 KEY_SPEND_CAP_REACHED until the next day, and free answers never count toward it. A key that carries a Cloudflare Turnstile pair requires a confirmed turnstile_token with every lookup. The endpoint is throttled at 20 requests a minute per visitor.
Batch and bulk
POST /api/v1/ip/batch looks up as many as 1,000 addresses in one call, in input order, under the single-lookup billing rules: one credit buys five answered addresses, the lookup that opens a block reports credits_used 1 and the next four 0, and each item's meta.ip_lookups_prepaid says how many are left. Reserved and private ranges, unallocated space, addresses nothing knows and repeats are free and use none. Repeats inside the list are looked up once. If the balance runs out mid-list, the paid partial results come back with meta.stopped_reason: "insufficient_credits"; only a batch whose first lookup is refused answers the typed 402. A key with a daily credit cap stops the list the same way once the cap is spent, reporting meta.stopped_reason: "key_spend_cap", so one large batch can never spend past the cap; a call that starts with the cap already spent answers 429 KEY_SPEND_CAP_REACHED and runs nothing. A batch counts as one request against the rate limit.
$ curl https://spaw.co/api/v1/ip/batch \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{"ips": ["8.8.8.8", "103.124.165.2", "192.168.1.1"]}'
For log files and exports, a bulk run queues up to 100,000 addresses with POST /api/v1/ip/bulk or from the IP intelligence page in the dashboard. Poll GET /api/v1/ip/bulk/{jobId} for progress and the running low, medium and high counts, then download the CSV from GET /api/v1/ip/bulk/{jobId}/results: one row per input address, in input order, with these columns:
ip, version, reason, network, continent, country, country_name, is_eu, is_eea, privacy_regime, sanctions, fatf_list, region, city, latitude, longitude, location_source, location_confidence, timezone, timezone_source, currency, calling_code, asn, org, asn_type, connection_type, is_mobile, is_satellite, registry, registry_country, allocated_at, is_datacenter, hosting_provider, is_corporate_proxy, corporate_proxy, is_crawler, crawler, crawler_kind, is_blocklisted, blocklist, is_attack_source, attack_source, is_tor, tor_last_seen, is_vpn, is_relay, privacy_service, is_anonymous, account_list, risk_score, risk_level, credits_used
sanctions is written as its codes joined with ; and account_list as kind:name. The fields that only mean something for one live request stay out of the file: timezone_matches needs a browser, seen_by_you would count the run’s own rows, and the opt-in abuse contact, registry details and hostname fields are never fetched by a run.
Once the run has settled, its payload carries a summary added up from the same cells the CSV holds, so it can never disagree with the download: rows and reserved, the top ten countries and networks (asn, org, rows), how many rows carried each signal in signals (datacenter, corporate_proxy, crawler, tor, vpn, relay, anonymous, blocklisted, attack_source, mobile, satellite), how many hit one of your lists in listed, and the risk split. It is null until then. That is the shape of a log file: which countries, which networks, how much of the traffic was infrastructure.
Cancelling stops a queued job on the spot and a running one at its next checkpoint; rows already answered stay billed and downloadable. A run that failed part-way keeps the rows it had already written, and they stay downloadable too. With a webhook_url, the finish is POSTed there as bulk_ip_job.finished, signed with the secret creation returns once: X-Spaw-Signature-V2 carries t={unix seconds},v2={hex}, the HMAC-SHA256 of v2:{t}:{raw body}, and a receiver refuses a t more than five minutes from its own clock so a captured delivery cannot be replayed later. The original X-Spaw-Signature, over the body alone, is sent beside it until 1 March 2027. Send an Idempotency-Key header to make creation safe to retry: the same key with the same request replays the job with Idempotent-Replayed: true, the same key with a different request answers 409 IDEMPOTENCY_KEY_REUSED.
A run that stopped because your credits ran out is resumed rather than uploaded again: top up, then press Resume on the run or call POST /api/v1/ip/bulk/{jobId}/resume. The whole list runs again from the top, and the rows it already answered are repeats inside the seven-day window, so only the rows it never reached are charged. The job keeps its id, its webhook and its cumulative credits_used, while processed , the verdict counts and the summary restart and are rebuilt as the rows come back. A run that stopped for any other reason, or that is already going again, answers 409 JOB_NOT_RESUMABLE; one whose list has passed out of the 30-day retention window answers 409 JOB_INPUT_EXPIRED, and an empty balance answers 402 INSUFFICIENT_CREDITS rather than queueing a run that would stop again at once.
If a delivery fails — your endpoint was down for the half-minute the three attempts cover — ask for it again rather than falling back to polling: press Send webhook again on the run in the dashboard, or call POST /api/v1/ip/bulk/{jobId}/webhook/redeliver. The body is byte-for-byte the body the first delivery carried, rebuilt from the run's own frozen row, so the two reconcile. The signature is new, and has to be: a receiver refuses anything whose t is more than five minutes from its own clock, so verify a redelivery exactly as you verify any other delivery. Nothing deduplicates it for you — a receiver that gets both the original and the redelivery sees bulk_ip_job.finished twice, and job.id is the key to deduplicate on. A run created without a webhook_url answers 409 WEBHOOK_NOT_CONFIGURED and one that has not settled answers 409 JOB_NOT_FINISHED; a run whose last delivery succeeded is not refused, because a receiver can lose a message it has already acknowledged. The delivery is queued rather than made while you wait — one attempt takes half a minute against an endpoint that is down, which is the endpoint you are asking about — so the call answers 202 and the run reads webhook_status: "pending" until a worker has an answer, then delivered or failed with what your endpoint said in webhook_detail. The dashboard shows the same three states beside the run. It is free and capped at ten a minute counted per signed-in user: neither per key nor per account, so two keys held by one person share the one budget while two teammates have one each.
IP endpoints have their own rate limit of 50 requests per second per key, separate from the 5 per second that applies to the email endpoints.
Outcome feedback
A lookup can only say what the feeds know. What you learn afterwards, a confirmed fraudulent order, a scraper, a bot that got through, a chargeback, can be sent back so your own later lookups of that address carry it: POST /api/v1/ip/feedback takes up to 1,000 items of an address and an outcome, abuse, fraud, bot or chargeback, with an optional occurred_at. It is free and never logged as a lookup.
$ curl https://spaw.co/api/v1/ip/feedback \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{"items": [{"ip": "203.0.113.9", "outcome": "fraud"}]}'
Any report you made about an address inside the last 30 days adds reported_abuse (+60, dataset abuse-feedback) to your own lookups of it. Once three or more accounts report the same address inside 30 days, everyone's lookups of it carry reported_abuse_widely (+40); only the number of reporting accounts is ever read, never who reported or what. Neither signal turns a null score into a number: an address no feed evaluated stays unscored.
Reports are the one thing the IP product stores on purpose, and even they never hold the address: each is a salted SHA-256 hash of the canonical address with the outcome and a timestamp, which can confirm a later lookup of the same address but cannot be read back into one. Malformed and reserved addresses are skipped and counted, not rejected. Reports are kept for 90 days, and GET /api/v1/ip/feedback/summary answers that window: totals per outcome, distinct addresses, and the IPv4 and IPv6 split.
Monitored lists
Your own addresses deserve the same check as your visitors’. A monitored list ( POST /api/v1/ip/monitors) keeps up to 500 of them, egress addresses, mail servers and office connections, under a daily, weekly or monthly re-check. The first run sets the baseline; from the next run on, every address that newly lands on a threat blocklist or an attack-source list, becomes a Tor exit or scores high arrives by email with the reason, so you learn that a mail server sits on Spamhaus DROP before your recipients’ inboxes do.
An entry can be a prefix as well as an address: a CIDR of at most 256 addresses ( /24 to /32, /120 to /128) is expanded into its addresses when the list is saved, and the whole list still fits under the 500 cap. A wider prefix is refused with a 422 that says to split it, because a /16 is 65,536 lookups a run and would be a bill nobody asked for.
Each run bills every address like a single lookup, five answered addresses to the credit, and the seven-day repeat marker applies, so a daily list costs at most one credit per five addresses a week; reserved and unallocated addresses are free. GET /api/v1/ip/monitors/{id} answers the last run per address: its status (clean, flagged or reserved), the flags that fired (blocklisted, attack_source, tor, high_risk), the list that names it, and whether it changed. POST /api/v1/ip/monitors/{id}/run re-checks the list ahead of schedule, and DELETE stops it.
A monitor can post its runs as well as email about them. Set webhook_url (https) when you create it, or later with a PATCH, and the signing secret comes back once in that response and is never readable again. webhook_events decides which runs are posted: changes, the default, posts only when the run found something new — an address that is newly flagged, or one whose status changed — or stopped early, and every_run posts after every run, which also tells you the monitor is still running. The body is { "event": "ip_monitor.run", "monitor": {…}, "summary": {…}, "changes": [ … ] }, where changes names the addresses the alert email names, and X-Spaw-Signature-V2 carries t={unix seconds},v2={hex}, where the hex is the HMAC-SHA256 of v2:{t}:{raw body} keyed with the secret. Refuse anything whose t is more than five minutes from your own clock: that window is what stops a captured delivery being replayed at you later. The original X-Spaw-Signature, the HMAC of the body alone, is sent beside it until 1 March 2027 and still verifies. Connection errors and 5xx answers are retried twice, three attempts in all; webhook_status and webhook_detail on the monitor say how the last delivery went. A URL that resolves onto a private network is refused at delivery, every time, because DNS can be repointed after a URL is saved.
A monitor is changed rather than replaced: PATCH /api/v1/ip/monitors/{id} takes name, ips, cadence and the webhook fields, and applies only the ones you send. That matters because what the last run recorded per entry is the baseline every alert is compared against: deleting a monitor and creating it again throws that away, and the first run of the new one only sets a baseline — a full cycle of silence, which is the one thing a monitor is bought to prevent. Replacing the list keeps what was recorded about the addresses that stayed, deletes what was recorded about the ones you removed, and gives a new entry no baseline. Changing the cadence neither skips a run nor starts a second one: a run already scheduled sooner than the new interval still happens, and a longer interval applies from the one after it. A body naming no field it could change answers 422.
A monitor that stops being run is deleted, and the identifiers and results it stored go with it. prunes_after_days on every monitor payload says how long that takes. A monitor in active use is never pruned: each run advances last_run_at, so the window only closes on a list that has genuinely stopped being re-checked — at which point holding the details somebody gave you serves no purpose. Deleting a monitor yourself removes everything it held at once.
Your own lists
The feeds know the Internet; only you know your office egress, your partners’ ranges and the addresses that already burned you. An account keeps up to 20 lists of up to 5,000 entries each, every entry an address or a CIDR prefix (IPv4 or IPv6), of one of two kinds. An allow list zeroes the score of every address it names: risk_score 0, risk_level low, and risk_signals carrying a single allowlisted entry of weight 0 so the reader sees why. A deny list tops it: denylisted +100, which is high on its own, whether or not any feed had anything to say. Whichever list matched is named in account_list with the entry that matched, and sources gains account-lists with the date the list last changed. Deny lists are checked before allow lists, so an address on both fails closed.
$ curl https://spaw.co/api/v1/ip/lists \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{"name": "Office egress", "kind": "allow", "entries": ["203.0.113.0/24", "2001:db8:10::/48"]}'
GET /api/v1/ip/lists names the account’s lists with their entry counts and the limits in meta; POST creates one (201); GET /api/v1/ip/lists/{listId} returns it with its entries; PATCH renames it, changes its kind or replaces its entries, applying only the fields you send, and a body naming none is a 422; DELETE removes it. Entries are canonicalised and deduplicated, an entry that is neither an address nor a prefix is a 422, and creating a twenty-first list answers 409 IP_LIST_LIMIT_REACHED. The dashboard’s IP page has a Lists tab for the same thing by hand.
Lists belong to the account, not to the member who created them: any member manages any list, and they apply to every lookup the account is billed for — the API, the dashboard, batches, bulk runs, monitors and the browser widget alike. They are the one signal in the answer that is yours rather than the Internet’s, and the bulk summary counts how many rows hit one. Nothing about an entry leaves the account, and a list is deleted with it.
Networks and ranges
Two lookups answer about a network rather than an address. GET /api/v1/ip/asn/{asn} turns the ASN database inside out: every prefix the database assigns to the autonomous system, in both families, with the operator’s name, the asn_type the name settles, the registry facts the RIR statistics record for the system (registry, registry_country, allocated_at), and the operator-level flags the feeds answer without an address: is_blocklisted with the blocklist that names it (Spamhaus ASN-DROP), is_satellite and is_mobile. prefix_count is never capped; prefixes carries at most 500 per family in index order and truncated says when a family was cut. A system the index does not know answers 404 ASN_NOT_FOUND, free; a server whose index has not been built yet answers 501 ASN_INDEX_UNAVAILABLE, free. An answer costs 1 credit, and the seven-day repeat rule applies per number.
$ curl https://spaw.co/api/v1/ip/asn/14593 \ -H "Authorization: Bearer sk_live_…"
GET /api/v1/ip/range?prefix=203.0.113.0/24 answers about a prefix (a bare address is a /32 or /128): the canonical prefix, first and last address, addresses (null when 2^bits does not fit an integer), the network of the first address with network_agrees (whether the last address resolves to the same system), the country of the first address with country_agrees, the registry facts of the first address, and signals: for each of datacenter, vpn, relay, tor, anycast, allocated, blocklist, attack_source, crawler and corporate_proxy, whether any feed record overlaps the prefix (tri-state like the flags, null when no installed feed of that signal covers the family), how many ranges intersect it (capped at 10,000 per feed, truncated says so) and the providers named. It is the quick way to learn that a /24 sits on Spamhaus DROP or that a partner’s range is really a cloud’s, before adding it to a list or a monitor. A prefix inside reserved space answers reason: reserved_range with everything else null, free; an answer costs 1 credit with the repeat rule keyed by the canonical prefix.
$ curl "https://spaw.co/api/v1/ip/range?prefix=8.8.8.0/24" \ -H "Authorization: Bearer sk_live_…"
The ASN index is rebuilt from the monthly DB-IP ASN database and reported on /status as asn-prefixes; both endpoints sit under the ip key scope and the IP rate limit. The same facts are published without a key for the largest systems on the autonomous system pages, and every feed a range answer reads has its own data source page.
Privacy
An IP address is personal data, and the service treats it that way. Nothing about an IP lookup is stored: no history row, no log line with the address. The only traces are a keyed seven-day marker that makes a repeat of the same address free, and, under the same keyed hash, the count behind seen_by_you: how many times your account asked about the address inside the window and when it first did, which is a velocity signal (fifty signups from one address in an hour) that no feed could give you. Neither holds the address. Send privacy: true in the body, or ?privacy=1 on a GET, to skip both; every such call is then a fresh, billable lookup and seen_by_you answers null.
IP lookups never leave the server. Every signal is a local database or a compiled feed, so no third-party processor sees the address, with one opt-in exception: asking for abuse_contact sends the address to the Internet registry’s public RDAP service (via rdap.org) to read the network’s abuse mailbox, cached per allocation. The sources block names each dataset consulted and the date it was synced. Bulk input lists and result files are the only copies of what you sent, and both are deleted with the job after 30 days.
The security page states the same facts alongside the rest of the data handling, and a data processing agreement is available on request from [email protected].
Next: the endpoint reference for every field, example, and error code.
Guides
All guides- How Tor exit rotation defeats a static blocklist
- How accurate is free IP geolocation?
- How to verify Googlebot and GPTBot
- Why residential proxies cannot be detected from open data
- A VPN and relay policy that does not punish real people
- Block datacenter and bot signups with one IP lookup
- Gate consent and defaults by country: is_eu, timezone and currency from an IP