API reference
Plain REST over https, JSON in and out, typed errors, and one credit balance across every endpoint. Every response names the datasets that produced it.
base url · https://spaw.co/api/v1
Endpoints
Grouped by product, and inside each product by the kind of endpoint. Every operation has its own page with request and response fields, examples in every language we ship a client for, and the error codes it can answer. The same contract is published as an OpenAPI document at /openapi.json.
Email verification
| method | path | summary |
|---|---|---|
| Lookups | ||
| POST | /api/v1/email | Verify an email address |
| GET | /api/v1/email/domain/{domain} | Inspect a domain's mail setup |
| Batch & browser | ||
| POST | /api/v1/email/batch | Verify up to 50 addresses in one call |
| POST | /api/v1/email/public | Verify an address from the browser with a publishable key |
| Bulk jobs | ||
| GET | /api/v1/email/bulk | List the account's bulk verification runs |
| POST | /api/v1/email/bulk | Queue a bulk verification run |
| GET | /api/v1/email/bulk/{jobId} | Poll a bulk job |
| POST | /api/v1/email/bulk/{jobId}/cancel | Cancel a bulk job |
| POST | /api/v1/email/bulk/{jobId}/resume | Resume a bulk job that ran out of credits |
| POST | /api/v1/email/bulk/{jobId}/webhook/redeliver | Send a finished run's completion webhook again |
| GET | /api/v1/email/bulk/{jobId}/results | Download a bulk job's results as CSV |
| Monitored lists | ||
| GET | /api/v1/email/monitors | List the account's monitors |
| POST | /api/v1/email/monitors | Monitor a list on a schedule |
| GET | /api/v1/email/monitors/{monitorId} | Read a monitor and its last results |
| PATCH | /api/v1/email/monitors/{monitorId} | Change a monitor |
| DELETE | /api/v1/email/monitors/{monitorId} | Stop monitoring a list |
| POST | /api/v1/email/monitors/{monitorId}/run | Re-verify a monitored list now |
| GET | /api/v1/email/domain-monitors | List the account's domain monitors |
| POST | /api/v1/email/domain-monitors | Monitor a list of domains on a schedule |
| GET | /api/v1/email/domain-monitors/{monitorId} | Read a domain monitor and its last results |
| PATCH | /api/v1/email/domain-monitors/{monitorId} | Change a domain monitor |
| DELETE | /api/v1/email/domain-monitors/{monitorId} | Stop monitoring a domain list |
| POST | /api/v1/email/domain-monitors/{monitorId}/run | Re-check a monitored domain list now |
| Sending policy | ||
| GET | /api/v1/email/policy | Read the account's sending policy |
| PUT | /api/v1/email/policy | Change one or more rules of the sending policy |
| DELETE | /api/v1/email/policy | Restore the default sending policy |
| Outcome feedback | ||
| POST | /api/v1/email/feedback | Report delivery outcomes |
| GET | /api/v1/email/feedback/summary | Measured accuracy |
| POST | /api/v1/email/feedback/{provider}/{feedbackKey} | Receive bounce webhooks from an email provider |
| Suppression lists | ||
| GET | /api/v1/email/suppressions | List the suppression list |
| POST | /api/v1/email/suppressions | Import known-bad addresses |
| DELETE | /api/v1/email/suppressions | Remove addresses from the suppression list |
| DELETE | /api/v1/email/suppressions/{suppressionId} | Remove a suppression entry |
| History | ||
| GET | /api/v1/email/history | List the account's verification history |
Phone intelligence
| method | path | summary |
|---|---|---|
| Lookups | ||
| POST | /api/v1/phone | Validate and enrich a phone number |
| GET | /api/v1/phone/block/{country}/{prefix} | Report a number range from the regulator's table |
| Batch & browser | ||
| POST | /api/v1/phone/batch | Validate up to 1,000 numbers in one call |
| POST | /api/v1/phone/public | Validate a number from the browser with a publishable key |
| Bulk jobs | ||
| GET | /api/v1/phone/bulk | List the account's bulk phone lookup runs |
| POST | /api/v1/phone/bulk | Queue a bulk phone lookup run |
| GET | /api/v1/phone/bulk/{jobId} | Poll a bulk phone job |
| POST | /api/v1/phone/bulk/{jobId}/cancel | Cancel a bulk phone job |
| POST | /api/v1/phone/bulk/{jobId}/resume | Resume a bulk job that ran out of credits |
| POST | /api/v1/phone/bulk/{jobId}/webhook/redeliver | Send a finished run's completion webhook again |
| GET | /api/v1/phone/bulk/{jobId}/results | Download a bulk phone job's results |
| Monitored lists | ||
| GET | /api/v1/phone/monitors | List the account's phone monitors |
| POST | /api/v1/phone/monitors | Monitor a phone list on a schedule |
| GET | /api/v1/phone/monitors/{monitorId} | Read a phone monitor and its last results |
| PATCH | /api/v1/phone/monitors/{monitorId} | Change a monitor |
| DELETE | /api/v1/phone/monitors/{monitorId} | Stop monitoring a phone list |
| POST | /api/v1/phone/monitors/{monitorId}/run | Re-check a monitored phone list now |
| Sending policy | ||
| GET | /api/v1/phone/policy | Read the account's SMS policy |
| PUT | /api/v1/phone/policy | Change one or more rules of the SMS policy |
| DELETE | /api/v1/phone/policy | Restore the default SMS policy |
| Outcome feedback | ||
| POST | /api/v1/phone/feedback/{provider}/{feedbackKey} | Receive delivery status webhooks from an SMS provider |
| POST | /api/v1/phone/feedback | Report SMS outcomes |
| GET | /api/v1/phone/feedback/summary | Reported SMS outcomes |
| Suppression lists | ||
| GET | /api/v1/phone/suppressions | List the phone suppression list |
| POST | /api/v1/phone/suppressions | Import known-dead numbers |
| DELETE | /api/v1/phone/suppressions | Remove numbers from the phone suppression list |
| DELETE | /api/v1/phone/suppressions/{suppressionId} | Remove a phone suppression entry |
IP intelligence
| method | path | summary |
|---|---|---|
| Lookups | ||
| POST | /api/v1/ip | Locate and risk-score an IP address |
| GET | /api/v1/ip/{ip} | Look up an address by path |
| GET | /api/v1/ip/me | Look up the address the call came from |
| GET | /api/v1/ip/asn/{asn} | Read an autonomous system and its prefixes |
| GET | /api/v1/ip/range | Read what the feeds say about a whole prefix |
| Batch & browser | ||
| POST | /api/v1/ip/batch | Look up up to 1,000 addresses in one call |
| POST | /api/v1/ip/public | Look up an address from the browser with a publishable key |
| Bulk jobs | ||
| GET | /api/v1/ip/bulk | List the account's bulk IP lookup runs |
| POST | /api/v1/ip/bulk | Queue a bulk IP lookup run |
| GET | /api/v1/ip/bulk/{jobId} | Poll a bulk IP job |
| POST | /api/v1/ip/bulk/{jobId}/cancel | Cancel a bulk IP job |
| POST | /api/v1/ip/bulk/{jobId}/resume | Resume a bulk job that ran out of credits |
| POST | /api/v1/ip/bulk/{jobId}/webhook/redeliver | Send a finished run's completion webhook again |
| GET | /api/v1/ip/bulk/{jobId}/results | Download a bulk IP job's results |
| Monitored lists | ||
| GET | /api/v1/ip/monitors | List the account's IP monitors |
| POST | /api/v1/ip/monitors | Monitor your own addresses on a schedule |
| GET | /api/v1/ip/monitors/{monitorId} | Read an IP monitor and its last results |
| PATCH | /api/v1/ip/monitors/{monitorId} | Change a monitor |
| DELETE | /api/v1/ip/monitors/{monitorId} | Stop monitoring an IP list |
| POST | /api/v1/ip/monitors/{monitorId}/run | Re-check a monitored IP list now |
| Your lists | ||
| GET | /api/v1/ip/lists | List the account's allow and deny lists |
| POST | /api/v1/ip/lists | Create an allow or deny list |
| GET | /api/v1/ip/lists/{listId} | Read a list and its entries |
| PATCH | /api/v1/ip/lists/{listId} | Change a list |
| DELETE | /api/v1/ip/lists/{listId} | Remove a list |
| Outcome feedback | ||
| POST | /api/v1/ip/feedback | Report IP outcomes |
| GET | /api/v1/ip/feedback/summary | Reported IP outcomes |
Address verification
| method | path | summary |
|---|---|---|
| Lookups | ||
| POST | /api/v1/address | Validate and enrich a postal address |
| GET | /api/v1/address/postcode/{postcode} | List the addresses at a postcode |
| GET | /api/v1/address/streets | List the streets at a postcode |
| Batch & browser | ||
| POST | /api/v1/address/batch | Validate up to 100 addresses in one call |
| POST | /api/v1/address/public | Validate an address from the browser with a publishable key |
| Bulk jobs | ||
| GET | /api/v1/address/bulk | List the account's bulk address lookup runs |
| POST | /api/v1/address/bulk | Queue a bulk address lookup run |
| GET | /api/v1/address/bulk/{jobId} | Poll a bulk address job |
| POST | /api/v1/address/bulk/{jobId}/cancel | Cancel a bulk address job |
| POST | /api/v1/address/bulk/{jobId}/resume | Resume a bulk job that ran out of credits |
| POST | /api/v1/address/bulk/{jobId}/webhook/redeliver | Send a finished run's completion webhook again |
| GET | /api/v1/address/bulk/{jobId}/results | Download a bulk address job's results |
| Monitored lists | ||
| GET | /api/v1/address/monitors | List the account's address monitors |
| POST | /api/v1/address/monitors | Monitor a list of addresses on a schedule |
| GET | /api/v1/address/monitors/{monitorId} | Read an address monitor and its last results |
| PATCH | /api/v1/address/monitors/{monitorId} | Change a monitor |
| DELETE | /api/v1/address/monitors/{monitorId} | Stop monitoring an address list |
| POST | /api/v1/address/monitors/{monitorId}/run | Re-check a monitored address list now |
| Sending policy | ||
| GET | /api/v1/address/policy | Read the account's shipping policy |
| PUT | /api/v1/address/policy | Change one or more rules of the shipping policy |
| DELETE | /api/v1/address/policy | Restore the default shipping policy |
| Outcome feedback | ||
| POST | /api/v1/address/feedback | Report what happened to post you sent |
| GET | /api/v1/address/feedback/summary | Reported postal outcomes |
| Suppression lists | ||
| GET | /api/v1/address/suppressions | List the address suppression list |
| POST | /api/v1/address/suppressions | Import known-dead addresses |
| DELETE | /api/v1/address/suppressions | Remove addresses from the address suppression list |
| DELETE | /api/v1/address/suppressions/{suppressionId} | Remove an address suppression entry |
Business verification
| method | path | summary |
|---|---|---|
| Lookups | ||
| POST | /api/v1/entity | Check a business identifier against the registers |
| Batch & browser | ||
| POST | /api/v1/entity/batch | Check up to 500 identifiers in one call |
| Monitored lists | ||
| GET | /api/v1/entity/monitors | List business monitors |
| POST | /api/v1/entity/monitors | Save a business monitor |
| GET | /api/v1/entity/monitors/{monitorId} | Get a business monitor |
| PATCH | /api/v1/entity/monitors/{monitorId} | Change a monitor |
| DELETE | /api/v1/entity/monitors/{monitorId} | Delete a business monitor |
| POST | /api/v1/entity/monitors/{monitorId}/run | Run a business monitor now |
Cross-signal consistency
| method | path | summary |
|---|---|---|
| Lookups | ||
| POST | /api/v1/consistency | Check several signals against each other |
| Bulk jobs | ||
| GET | /api/v1/consistency/bulk | List the account's bulk consistency runs |
| POST | /api/v1/consistency/bulk | Queue a bulk consistency run |
| GET | /api/v1/consistency/bulk/{jobId} | Poll a bulk consistency job |
| POST | /api/v1/consistency/bulk/{jobId}/cancel | Cancel a bulk consistency job |
| POST | /api/v1/consistency/bulk/{jobId}/resume | Resume a bulk consistency job that ran out of credits |
| POST | /api/v1/consistency/bulk/{jobId}/webhook/redeliver | Send a finished run's completion webhook again |
| GET | /api/v1/consistency/bulk/{jobId}/results | Download a bulk consistency job's results |
Account & status
| method | path | summary |
|---|---|---|
| Account | ||
| GET | /api/v1/account | Read the account and credit balance |
| GET | /api/v1/account/requests/{requestId} | Look up one request by its id |
| GET | /api/v1/account/ledger | Read the credit ledger |
| Service status | ||
| GET | /api/v1/status/data | Health, latency and data freshness |
Webhooks
| method | path | summary |
|---|---|---|
| Testing | ||
| POST | /api/v1/webhooks/test | Send a test webhook |
Authentication
Every request carries an API key as a bearer token. Create and revoke keys from the dashboard; a key is shown once at creation and stored hashed.
Authorization: Bearer sk_live_…
Content-Type: application/json There are two kinds of keys. Secret keys authenticate servers and unlock the full API. Publishable pk_ keys are safe in browser page source: they can only run single lookups through the four browser endpoints, POST /api/v1/email/public, /api/v1/phone/public, /api/v1/ip/public and /api/v1/address/public, and only from the domains they are locked to. Both are managed on the dashboard's API keys page.
Billing
You pay for answers, not attempts. One balance covers every endpoint, and anything you paid to check once is free to check again for a week.
| outcome | credits |
|---|---|
| Fresh lookup with a usable answer | 1 credit |
| Five fresh IP lookups with a usable answer | 1 credit |
| Email answer whose mailbox check was uncertain (unverified) | 0 |
| Undeliverable email verdict within the fair-use allowance | 0 |
| Undeliverable email verdict past the fair-use allowance | 1 credit |
| Invalid input or no match | 0 |
| Repeat of a recent lookup, within 7 days | 0 |
| Rejected or failed request | 0 |
| Monthly free grant, every account | +100 |
Responses include meta.credits_used, meta.credits_remaining, and meta.cache_hit so your own accounting never drifts from ours; an IP answer also carries meta.ip_lookups_prepaid and an email answer meta.fair_use_billed.
IP lookups are billed in blocks of five: the answered IP lookup that opens a block reports credits_used 1 and leaves four prepaid on the account, and the next four report 0. Undeliverable email verdicts that needed a mailbox check are free up to a fair-use allowance of 500 in any 30 days, or the number of deliverable and risky email answers billed in those 30 days when that is more. Past the allowance each one costs 1 credit and its meta.fair_use_billed is true. Verdicts settled without a mailbox check (bad syntax, no working mail server, a username the provider never allows) and repeats are always free and never counted.
Errors & rate limits
Failures return { success: false, error: { code, message } } with a stable code to branch on. Every key may make 5 requests per second (50 for IP and phone endpoints, each in its own bucket); a batch or bulk call counts as one request. A key created with a default country supplies it to phone lookups that name none. A key's own scopes, daily cap and address allowlist answer KEY_SCOPE_DENIED, KEY_SPEND_CAP_REACHED and KEY_IP_NOT_ALLOWED before anything is billed; see key controls. The daily cap also stops a batch part-way: when it runs out mid-list the call keeps the items already paid for and reports meta.stopped_reason: "key_spend_cap" with a 200.
| error.code | http | when |
|---|---|---|
| UNAUTHENTICATED | 401 | The Authorization header is missing, malformed, or carries a key that does not exist or was revoked. |
| VALIDATION_FAILED | 422 | One or more fields are missing, malformed, or over a limit. error.errors lists each field with its messages. |
| RATE_LIMITED | 429 | The API allows 5 requests per second per key; each browser endpoint allows 20 lookups a minute per visitor, and the published sandbox key allows 30 a minute per address. |
| NOT_FOUND | 404 | The bulk job, monitor or suppression entry named in the path does not exist, or belongs to another account. Nothing is charged. |
| INSUFFICIENT_CREDITS | 402 | The account has no credits left, so the lookup did not run. Free credits renew at the start of each month; packs can be bought from the Billing page. |
| RESULTS_NOT_READY | 409 | Results were requested for a bulk job that is still queued or running. |
| JOB_ALREADY_FINISHED | 409 | A cancel was requested for a bulk job that already reached a terminal status: completed, cancelled, or failed. |
| JOB_CANCELLED | 409 | Results were requested for a job that was cancelled before any address was processed, so there is no results file. |
| JOB_FAILED | 409 | Results were requested for a job that failed before a single row was written, so there is no results file. |
| JOB_NOT_RESUMABLE | 409 | A resume was asked for a run that did not stop because the credit balance ran out, that has not settled yet, or that is already going again. |
| JOB_INPUT_EXPIRED | 409 | A resume was asked for a run whose uploaded list has passed out of the retention window, so there is nothing left to re-run. |
| IP_LIST_LIMIT_REACHED | 409 | A new allow or deny list was asked for when the account already holds the maximum number of lists. |
| JOB_NOT_FINISHED | 409 | A redelivery of the completion webhook was asked for a run that is still queued or running. |
| WEBHOOK_NOT_CONFIGURED | 409 | A redelivery of the completion webhook was asked for a run created without a `webhook_url`, so there is no address to post to and no secret to sign with. |
| INVALID_PUBLISHABLE_KEY | 401 | The pk_ key sent to the browser endpoint is unknown. |
| ORIGIN_NOT_ALLOWED | 403 | The Origin header of the browser request does not match any domain the publishable key allows, or the header is missing. |
| KEY_IP_NOT_ALLOWED | 403 | The secret key was created with an allowlist of source addresses or CIDR ranges, and this request came from elsewhere. |
| KEY_ACCOUNT_MISMATCH | 403 | A secret key belongs to the account it was created on. The person who holds this one is no longer a member of that account, so the key no longer authorises anything. |
| KEY_SCOPE_DENIED | 403 | The secret key was created with a limited set of scopes (email, ip, phone, address, entity, consistency, account), and the endpoint belongs to a family it does not cover. |
| KEY_SPEND_CAP_REACHED | 429 | The key, publishable or secret, was created with a daily credit cap and has reached it today. The counter resets each day. |
| SANDBOX_ENDPOINT_NOT_ALLOWED | 403 | The request carried the published sandbox key, which answers a handful of read-only lookups and nothing else. |
| SANDBOX_VALUE_NOT_ALLOWED | 403 | The endpoint is one the sandbox answers, but only for the sample values the documentation prints, and the request asked about something else. |
| SANDBOX_UNAVAILABLE | 503 | The request carried the published sandbox key on a deployment where the sandbox is turned off. |
| TURNSTILE_FAILED | 403 | The key was created with a Turnstile site key and secret, and the lookup carried no token or one Cloudflare did not confirm. |
| UNKNOWN_FEEDBACK_PROVIDER | 404 | The URL POST /api/v1/email/feedback/{provider}/{key} accepts postmark, ses, mailgun and sendgrid only. |
| INVALID_FEEDBACK_KEY | 401 | The fb_ key in a bounce webhook URL does not exist or was rotated. |
| EMAIL_NOT_VERIFIED | 403 | The key is valid, but its account has not opened the verification link yet, so the API and the MCP server refuse to spend for it. |
| IDEMPOTENCY_KEY_REUSED | 409 | POST /api/v1/email/bulk saw an Idempotency-Key it already knows, but the addresses or webhook URL differ from the request that first used it. |
| ADDRESS_LIST_UNAVAILABLE | 501 | GET /api/v1/address/postcode/{postcode} answers from a licensed address-list partner or from an open register's street listing, and neither covers the country asked about, or the installed listing cannot speak for that postcode. Nothing is charged. |
| ADDRESS_STREETS_NOT_FOUND | 404 | GET /api/v1/address/streets found nothing to report: no address register Spaw syncs carries a street listing for the country, the registers list no street at that postcode, or the listing does not cover it. Nothing is charged. |
| ASN_NOT_FOUND | 404 | GET /api/v1/ip/asn/{asn} found nothing to report: the ASN database assigns no prefix to that number. Nothing is charged. |
| ASN_INDEX_UNAVAILABLE | 501 | GET /api/v1/ip/asn/{asn} needs the index `lookup:index-asns` builds from the ASN database, and it is not installed. Nothing is charged. |
| PHONE_BLOCK_NOT_FOUND | 404 | GET /api/v1/phone/block/{country}/{prefix} found nothing to report: either no numbering plan Spaw syncs covers the country, or no block in it starts with the prefix. Nothing is charged. |
| METHOD_NOT_ALLOWED | 405 | The path exists but not for this verb — a GET where the endpoint takes POST, most often. The response carries an Allow header naming the methods it does take. Nothing is charged. |
| SERVER_ERROR | 500 | Something went wrong on this side. The failure is logged against the request_id in the response, which is the fastest way for support to find it. |
Each code has its own page under error codes, and every verdict reason under verdict reasons.
Every response carries an X-Request-Id header, repeated as meta.request_id on successful lookups and error.request_id on typed errors. Quote it when you write to support: it matches the id on every log line the request produced.
GET /api/v1/account
Account
Your current credit balance and monthly grant — useful for dashboards and pre-flight checks before a big run.
POST /api/v1/email
Email verification
Syntax, live DNS, open domain lists, domain auth records, and a mailbox-level SMTP check — the full answer in every lookup, at one credit for a deliverable or risky verdict whose mailbox check was definite. An uncertain (unverified) answer is free, and an undeliverable one is free within fair use. No mail is ever sent.
$ curl https://spaw.co/api/v1/email \ -H "Authorization: Bearer sk_live_…" \ -d '{"email": "[email protected]"}'
Request
| string · required | The address to verify, at most 254 characters. | |
| callback_url | string · optional | An https URL. When the answer comes back unverified (greylisted, timed out, provider unavailable), the address is re-checked after 5 and 20 minutes and the settled verdict is POSTed here. |
| callback_secret | string · required with callback_url | 16 to 128 characters. Signs the callback exactly as the bulk webhook is signed: X-Spaw-Signature-V2 carries t={unix seconds},v2={hex}, the HMAC-SHA256 of v2:{t}:{raw body}. |
With a callback pair the response also carries meta.settling: true when a re-check was scheduled, false when the answer was already final. The callback body is { event: "email.settled", request_id, settled, attempt, data, meta } with the same data fields as this endpoint; settled is false when the last re-check still could not say. An unverified answer is free and leaves nothing to repeat, so a re-check bills 1 credit when it confirms the mailbox and nothing while it still cannot say. One your balance cannot pay for is not run: the last callback then has settled false, data null and meta.stopped_reason "insufficient_credits".
Response fields · data.*
| field | type | description |
|---|---|---|
| string | The address as checked: local part preserved, domain lowercased and converted to punycode. Wrappers people paste — a display name (Mia K <mia@…>), a mailto: prefix, spreadsheet quotes, trailing punctuation, invisible characters — are stripped first. Input that still fails the syntax check is echoed back. | |
| normalized_email | string | null | The canonical inbox the mail actually reaches: lowercased, plus-tag stripped, and Gmail dots and googlemail.com folded away. Dedupe your list on this, not on the raw input. |
| is_alias | boolean | null | Whether the address carries a plus-tag (mia+promo@…) — the same inbox under a disposable label, a favorite of signup-bonus farmers. |
| is_gibberish | boolean | null | Whether the local part reads as keyboard mash (xkqzwv@…). Deliberately conservative: y counts as a vowel to protect names like krzysztof, and digit-only locals (normal on qq.com) are never flagged. |
| looks_generated | boolean | null | Whether the username looks machine-made or a placeholder rather than a person's choice: a hex string of 16 or more characters with at least one a–f, a dashed UUID, 20 or more alphanumerics mixing upper case, lower case and digits, one or two letters followed by four or more digits, any word (with at most one separator) followed by six or more digits, or a placeholder word — test, tester, testing, temp, fake, asdf, asdfasdf, qwerty, abc, abcd, aaa, xxx, sample, example, foo, foobar, null, none, noname, dummy, placeholder — with optional trailing digits. A heuristic about the string, never about the mailbox; +20 on the score. |
| local_part_pattern | string | null | The shape of the username, read with the plus-tag and up to four trailing digits removed: first.last, first_last, first-last, firstlast, f.last, flast, first.l, firstl, last.first, lastfirst, first or last (the name shapes, recognised with the bundled name lists), numeric (digits only), word (an alphabetic token the lists do not know), mixed (letters and digits forming no name shape) or other. The bundled first-name list is small, so a lone first name the list lacks reads as last. |
| first_name | string | null | The name token the bundled dictionary recognised as a first name, lowercased as typed, only for shapes that carry a full first name. Derived from the address you sent, never looked up anywhere, and never a promise of correctness: a pair like mia.kowalski reads positionally, while a lone name the first-name list lacks lands in last_name instead. |
| last_name | string | null | The name token the bundled dictionary recognised as a surname, only for shapes that carry a full surname. Derived from the address you sent; nothing is appended from outside, and the lists say nothing about gender. |
| deliverable | string | The verdict: deliverable (the exact mailbox was confirmed), risky (a burner, a typo-squat, a parked domain, mail served from a blocklisted network, a web-only domain, a no-reply or role inbox, a full mailbox, a mailbox that was dead and exists again, a catch-all, or a mailbox the handshake could not confirm), or undeliverable (bad syntax, an impossible username, no usable mail servers, or a missing or disabled mailbox). |
| reason | string | null | Why the verdict is not deliverable. Undeliverable: invalid_syntax, invalid_local_part, no_mx_records, null_mx, mx_unresolvable, mailbox_not_found, mailbox_disabled. Risky: likely_typo, parked_domain, disposable, mx_blocklisted, implicit_mx, mailbox_full, no_reply, role, recycled_mailbox, catch_all, unverified. |
| risk_score | number | A branchable 0–100 composite of the individual signals with published weights: disposable 80, parked domain 60, typo-squat 60, blocklisted mail network 60, web-only domain 30, role 30, catch-all 30, domain under 30 days old 30, recycled mailbox 30, full mailbox 20, gibberish 20, generated 20, no-reply 20 (on top of role), widely seen 20, catch-all behind a security gateway 15 (instead of 30), domain under 180 days 10, no SPF on a business domain 10, unverified mailbox 10, registration ending within 30 days 10, plus-tag alias 5, relay 5. Undeliverable is always 100. |
| risk_level | string | The score bucketed for easy branching — low, medium (30+), or high (60+) — using the same thresholds as the IP endpoint. |
| risk_signals | array | The signals that fired, each as { signal, weight, dataset } in the published order, so the score is the sum of the weights and every weight names the dataset it was read from. Empty when nothing fired, and empty on an undeliverable verdict, where the verdict is the answer and the score is fixed at 100. |
| syntax_valid | boolean | Whether the address parses under RFC 5322 grammar. |
| domain | string | null | The ASCII (punycode) domain actually used for the DNS and list checks. |
| domain_category | string | null | The class of the domain, first match wins: relay (a forwarding service such as Firefox Relay or SimpleLogin), disposable, webmail (a free mailbox provider), military, government or academic from their suffixes, or other. Never "business": from here a personal domain and a company domain look alike. |
| mx_found | boolean | null | Whether the domain has usable mail servers. An RFC 7505 null MX ("this domain accepts no mail") counts as false, and so do MX records that only point at loopback, private, or non-existent hosts. |
| mx_implicit | boolean | null | True when the domain publishes no MX record and mail would fall back to its own address (RFC 5321). Such web-only domains rarely accept mail, so the verdict is risky and no mailbox check is run. |
| mx_provider | string | null | Who runs the domain's mail, from its MX hostnames: google, microsoft, zoho, proofpoint, mimecast, and a dozen more, or "other" for self-hosted. Useful for routing and qualifying B2B signups. |
| mx_provider_type | string | null | What that provider is: mailbox_provider (Google, Microsoft, Zoho, Proton, Fastmail, Yahoo, iCloud, Yandex, GMX, Tutanota, HEY), security_gateway (Proofpoint, Mimecast, Barracuda — they accept everything at the edge and sort it out behind), transactional (Mailgun, Amazon SES), hosting (Rackspace, GoDaddy), self_hosted (every MX host under the domain itself), or other. Null when there is no provider. |
| mx_blocklisted | boolean | null | Whether any of the domain's mail hosts resolves into a network on the threat blocklists the IP product already installs (Spamhaus DROP and ASN-DROP, the abuse.ch botnet list). Mail served from a hijacked or criminal netblock is a burner-domain signal the disposable list has not caught yet: +60 and a risky verdict. false is a checked negative; null means no MX, unresolvable hosts, or no blocklist feed installed. |
| mx_network_type | string | null | The class of network the first resolvable mail host sits in: hosting when it is inside a datacenter range, otherwise the operator's type from the ASN database (education, government, hosting, isp). Null when unknown. Informational; it carries no weight. |
| has_spf | boolean | null | Whether the domain publishes an SPF policy. Real companies almost always do; a missing record suggests a parked or throwaway domain. |
| dmarc_policy | string | null | The domain's DMARC policy — none, quarantine, or reject — or null when it publishes none. A strict policy signals a domain someone actually operates. |
| domain_registered_at | string | null | The date the domain was registered, from public RDAP registry data. Null for free providers and burners (not consulted) and where the registry does not publish it. |
| domain_age_days | number | null | Days since registration. Fraud rings register domains hours before they use them; anything under 30 days adds 30 to the risk score, under 180 adds 10. |
| domain_expires_at | string | null | When the registration ends, from the same RDAP record as the registration date, consulted exactly when that is (business domains only). A registration ending within 30 days adds 10: most healthy domains renew at the last minute, so it is a nudge, not a verdict. |
| parked | boolean | null | Whether the domain's nameservers belong to a parking or for-sale service. A parked domain that still accepts mail is a harvesting catch-all rather than a mailbox provider: +60 and a risky verdict. Evaluated only when the domain has usable mail servers; null otherwise, or when the nameserver lookup failed. |
| disposable | boolean | null | Whether the domain — or a parent domain handing out subdomains — is a known burner-email provider, or routes its mail through one: a brand-new burner domain still points its MX at a listed operator. A local allow and deny list overrides the synced upstream, and a relay service is never disposable: for one, this is forced false. |
| is_relay | boolean | null | Whether the domain belongs to a permanent alias or forwarding service: Firefox Relay, SimpleLogin, Addy.io, DuckDuckGo Email Protection, Proton Pass, Sign in with Apple's private relay and the rest. A relay address is a real person's real mailbox reached through a name they control and often kept for years, which is why it is never reported as disposable; it adds 5, because the identity behind it is withheld. Ordinary iCloud Mail and Proton Mail mailboxes are not relays, and an iCloud+ Hide My Email alias on icloud.com cannot be told apart from one. |
| relay_service | string | null | The relay service's name when is_relay is true. |
| role | boolean | null | Whether the local part is a role account such as admin@, support@, or billing@ — over a thousand prefixes from an open list, checked with any plus-tag removed, plus any no-reply spelling the list lacks. |
| role_type | string | null | Which kind of role: no_reply (mail to it is discarded by design), postmaster (the RFC 2142 family — postmaster, abuse, hostmaster, webmaster, security), support, sales, info, admin, billing, marketing, careers, legal, or other for a listed username outside those families. Null when the address is not a role. |
| free_provider | boolean | null | Whether the domain is a known free mailbox provider like gmail.com, outlook.com, or seznam.cz — thousands of providers and country variants from an open list. |
| smtp_checked | boolean | Whether a definitive mailbox-level SMTP answer was obtained. Included in every lookup at the same single credit; when mail servers will not say (greylisting, timeouts), it reports false instead of guessing. |
| smtp_checked_at | string | null | When the mailbox probe actually ran, as an ISO 8601 timestamp. Confirmed answers are shared across customers for up to a week, so this can predate the lookup; null when no definitive answer was obtained. |
| mailbox_exists | boolean | null | Whether the exact inbox exists, from the SMTP-level check. Null when unconfirmed, or when a catch-all domain makes existence unknowable. |
| previously_invalid | boolean | null | Whether this mailbox exists now but answered "no such mailbox" to an earlier handshake. That is the shape of a recycled address, the spam-trap kind included, so the verdict is risky with reason recycled_mailbox and +30. false means it exists with no such history; null whenever the mailbox is not confirmed. |
| previously_invalid_at | string | null | The date of that earlier not-found answer. It is kept for a year under a keyed hash of the address, and erased with the address. |
| catch_all | boolean | null | Whether the domain accepts mail for every address, which turns the verdict risky: a mailbox there can never be individually confirmed. Behind a security gateway the weight is 15 rather than 30, because the gateway accepts everything by design and the domain behind it is a real, administered one. |
| smtp_reason | string | null | The detail behind the mailbox check: no_mailbox, mailbox_full, disabled, greylisted, timeout, no_connect, mx_unreachable, provider_unavailable when the verification provider itself could not be reached, or delivered_recently when your own account reported the address delivered within 90 days, which counts as a confirmed mailbox for your lookups. A full mailbox is risky and a disabled one undeliverable; the rest explain an unverified answer. |
| mailbox_confidence | number | null | For catch-all domains and unanswered checks only: a recomputable 0–100 estimate that the specific mailbox exists. Starts at 50; DMARC, SPF, an established mail provider, an old registration, and a name-shaped address add to it; gibberish, a fresh domain, and a plus-tag take away. Once five or more delivery outcomes have been reported for the domain, a bounce rate of 10% or less adds 15 and one of 50% or more removes 20, and a username that follows the naming convention seen in the domain's confirmed deliveries adds 15 while a name shape that breaks it takes 10 away. A relay alias stays at 50 and moves only on the evidence against it: a bounce rate of 50% or more, or a plus-tag. A published heuristic, not a probe. |
| widely_seen | boolean | null | Whether three or more other accounts looked this address up in the last 24 hours: an address hitting many signup forms in one day is being used by something automated. Counted from a keyed hash and account ids only, never the address, and +20 when it fires. Null where nobody could count: the guest demo, test addresses, invalid syntax. |
| did_you_mean | string | null | A suggested correction ("[email protected]" for [email protected]), offered after the mail-server check fails or when the domain is a known typo-squat of a major provider — those resolve on purpose, to harvest misdirected mail. Candidates are the big mailbox providers, the thousand most-linked domains and the domains you verify most; a domain outside the provider list is suggested only when its own mail servers accept mail. |
| ok_to_send | boolean | Your account's sending policy applied to this answer: false for every undeliverable verdict, and otherwise when a blocked signal fired, the score is over the policy's ceiling, or a catch-all or unverified mailbox sits under its confidence floor. The published defaults block disposable, likely_typo, parked_domain, mx_blocklisted and no_reply and any score over 60; GET, PUT and DELETE /api/v1/email/policy read, change and reset them. |
| blocked_by | string | null | The rule that made ok_to_send false, in the order they are applied: undeliverable, the name of the first blocked signal that fired, risk_score, or mailbox_confidence. Null when ok_to_send is true. |
Every response also carries a sources array naming the dataset and version behind each field.
Suppression list
An undeliverable verdict adds the address, in normalized form, to your account's suppression list. Batch, bulk, and monitor runs serve suppressed addresses from the stored verdict — free, marked meta.suppressed: true — instead of re-verifying them. A single lookup always re-verifies, and a verdict other than undeliverable removes only an entry an undeliverable verdict added. An import and a complaint stay until you remove them, and a bounce you reported leaves when you report a delivery to that address; a complaint about an address you imported is recorded on that entry, which stays an import. Imports and complaints never go stale, though an import serves a verdict it kept from before you imported it only for 90 days after the entry was last renewed; any other entry older than 90 days is re-verified instead of trusted.
Manage it directly: GET /api/v1/email/suppressions lists entries, POST /api/v1/email/suppressions imports up to 10,000 known-bad addresses per request — your provider's bounce export, honored from day one at no credit cost, answered with reason "suppressed" — and DELETE /api/v1/email/suppressions/{id} removes one.
Browser lookups with a publishable key
POST /api/v1/email/public takes { key, email } with a publishable key, checked against the browser's Origin header and the key's allowed domains. It powers the form helper, bills the key's owner under the normal rules, and returns { success, data } without a meta block, so page visitors never see your balance. POST /api/v1/phone/public does the same for phone fields with { key, phone, country }, never with the live carrier check. Server-side requests should use a secret key and the standard endpoints instead. Because the key sits in page source, give it a daily credit cap in the dashboard: once the cap is spent the endpoint answers 429 KEY_SPEND_CAP_REACHED until the next day, and your balance is safe from a scripted crowd. For forms open to the public, add a Cloudflare Turnstile pair to the key as well: the endpoint then requires a confirmed turnstile_token with every lookup and answers 403 TURNSTILE_FAILED without one, before any credit is spent. The form helper handles the token when the script tag carries data-turnstile-site-key.
Test addresses
Six fixed addresses on the reserved spaw.test domain always answer the same canonical result, cost no credits, touch no DNS or mail server, and never appear in your history or suppression list — the way payment providers publish test card numbers. Use them in integration tests and staging pipelines; a plus-tag ([email protected]) works too. They answer on every endpoint, including batch, bulk, and the browser endpoint.
| [email protected] | deliverable · mailbox confirmed, risk_score 0 |
| [email protected] | risky · reason unverified, smtp_checked false, smtp_reason greylisted, mailbox_confidence set |
| [email protected] | risky · reason catch_all, mailbox_exists null |
| [email protected] | risky · reason disposable, risk_score 90 |
| [email protected] | risky · reason role |
| [email protected] | undeliverable · reason mailbox_not_found, smtp_reason no_mailbox |
POST /api/v1/email/batch
Email batch
Up to 50 addresses in one synchronous call, each under the exact single-lookup billing rules. Their mailbox probes are sent to the verification provider concurrently, so a full batch takes about as long as a handful of single lookups. data.results[] mirrors the single response per address. Running out of credits mid-batch returns the paid partial results with meta.stopped_reason: "insufficient_credits" instead of discarding billed work; only a batch whose first lookup is refused returns the typed 402. A key's own daily credit cap stops a batch the same way, with meta.stopped_reason: "key_spend_cap"; if the account runs out of credits in the same batch, that stop is reported instead, because a spent balance is money and a cap is only your own policy. A batch that begins with the cap already spent still answers 429 KEY_SPEND_CAP_REACHED and runs nothing. Each item's meta carries credits_used, cache_hit, and suppressed.
| emails | string[] · required | 1 to 50 addresses; each item at most 254 characters. |
GET /api/v1/email/domain/{domain}
Domain intelligence
The domain-level signals of an email lookup — MX, provider, SPF and DMARC, the disposable and free-provider lists — answered once for a whole domain. One credit when the domain accepts mail; an invalid or dead domain is free, and repeats within 7 days are free.
$ curl https://spaw.co/api/v1/email/domain/acme.com \ -H "Authorization: Bearer sk_live_…"
Response fields · data.*
| domain | string | The domain as checked: lowercased, punycode form. |
| valid | boolean | Whether the input is a syntactically valid hostname. When false, every other field is null. |
| mx_found | boolean · null | Whether the domain can receive mail — MX records, or the RFC 5321 implicit fallback. |
| mx_implicit | boolean | null | True when the domain has no MX record and mail would fall back to its own address; such domains rarely accept mail. |
| catch_all | boolean | null | True once any address at the domain has answered catch-all within the last week; null when that is not known. A catch-all domain accepts every address, so individual mailboxes there cannot be confirmed. |
| domain_registered_at | string | null | The registration date from public RDAP registry data, or null where the registry does not publish it. |
| domain_age_days | number | null | Days since registration; fresh domains are the classic fraud signal. |
| mx_provider | string · null | The mail infrastructure behind the MX hosts: "google", "microsoft", "proofpoint", "other", … |
| has_spf | boolean · null | Whether the domain publishes an SPF record. |
| dmarc_policy | string · null | The published DMARC policy: "none", "quarantine", or "reject". |
| disposable | boolean · null | Whether the domain is on the open disposable-provider list. |
| free_provider | boolean · null | Whether it is a consumer mailbox provider (Gmail, Outlook, …) rather than a company domain. |
POST /api/v1/email/bulk
Bulk jobs & webhooks
Queue up to 100,000 addresses in one job. Creation answers 202 with the job's id and status; poll GET /api/v1/email/bulk/{id} for progress, then download the result CSV from GET /api/v1/email/bulk/{id}/results. Jobs and their files are kept for 30 days.
The result file starts with the input's own columns (for an API list, a single email column; for a dashboard upload, every column of the file) followed by verified_email and the verdict fields, one row per input row in the original order. Add ?variant=deliverable (or risky, undeliverable) to download only those rows. Rows repeating an address already in the list are verified once and counted in duplicate_count. Addresses at a domain already known to be catch-all, and addresses your account reported delivered recently, answer without a probe.
POST /api/v1/email/bulk/{id}/cancel stops a run: a queued job is cancelled on the spot, a running one at its next checkpoint. Rows already verified stay billed and downloadable — as do the rows a run that failed part-way had already written — and the webhook fires with status cancelled just as it does for completed and failed.
| emails | string[] · required | 1 to 100,000 addresses; each item at most 254 characters. |
| webhook_url | string · optional | An https URL to POST when the job finishes. The response to job creation includes the signing secret — shown at creation and on an idempotent replay of the same request, never otherwise. |
Verifying the webhook signature
When the job finishes we POST a JSON summary to your webhook. X-Spaw-Signature-V2 carries two elements, t={unix seconds},v2={hex}: the hex is the HMAC-SHA256 of v2:{t}:{raw body}, keyed with the secret from job creation. Check the timestamp as well as the digest — a signature over the body alone never goes stale, so a captured delivery could be posted back at you a year later and still verify.
$elements = []; foreach (explode(',', (string) $request->header('X-Spaw-Signature-V2')) as $element) { $pair = explode('=', trim($element), 2); if (count($pair) === 2) { $elements[$pair[0]] = $pair[1]; } } $timestamp = $elements['t'] ?? ''; abort_unless(ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 300, 401); $expected = hash_hmac('sha256', "v2:{$timestamp}:".$request->getContent(), $secret); abort_unless(hash_equals($expected, $elements['v2'] ?? ''), 401);
The original X-Spaw-Signature — the same HMAC over the body alone, with no timestamp — is still sent beside it until 1 March 2027, so a receiver written against it keeps working while you move over.
POST /api/v1/email/feedback
Bounce feedback
Tell us what actually happened to an address after you sent to it. Each outcome is stored against the verdict you had been given for that address in the previous 90 days, which turns verdicts into measured accuracy, and it keeps your suppression list current: a bounce or complaint adds the address, and a delivery removes an entry a verdict or a bounce added, never a complaint or an import. Free, up to 1,000 items per request; answers 202 with the counts recorded and skipped.
$ curl https://spaw.co/api/v1/email/feedback \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{"items": [{"email": "[email protected]", "outcome": "bounced", "reason": "550 5.1.1 no such user"}]}'
Request
| items[].email | string · required | The recipient. Normalized like a lookup; an address that does not parse is skipped and counted, not rejected. |
| items[].outcome | string · required | delivered, bounced (hard bounces only), or complained. |
| items[].reason | string | The provider's diagnostic, e.g. the SMTP reply. Stored as-is, at most 255 characters. |
| items[].occurred_at | string | When it happened, any parseable date. Defaults to now; a time more than five minutes ahead is refused, and one inside that margin is recorded as now. |
Webhooks from your email provider
Generate a feedback key on the API keys page and point your provider's bounce, delivery, and complaint webhooks at the matching URL below. The key is the only credential a webhook can carry, so rotating it invalidates the URLs at once. Payloads are read for their documented fields and nothing else; events that say nothing definitive about the address are ignored.
| postmark | /api/v1/email/feedback/postmark/{key} | Bounce (hard types) → bounced · Delivery → delivered · SpamComplaint → complained. Soft bounces are ignored. |
| ses | /api/v1/email/feedback/ses/{key} | SNS notifications: Bounce with bounceType Permanent → bounced · Delivery → delivered · Complaint → complained. Subscription confirmations are answered automatically. |
| mailgun | /api/v1/email/feedback/mailgun/{key} | delivered → delivered · failed with severity permanent → bounced · complained → complained. Temporary failures are ignored. |
| sendgrid | /api/v1/email/feedback/sendgrid/{key} | delivered → delivered · bounce and dropped → bounced · spamreport → complained. Deferred events are ignored. |
Measured accuracy
GET /api/v1/email/feedback/summary crosses the last 90 days of outcomes with the verdicts on file: by_verdict counts delivered, bounced, and complained per verdict, and measured.deliverable_delivery_rate and measured.undeliverable_bounce_rate are the two numbers that say how right we were for you. The same figures appear on the History tab of the dashboard. Outcomes are kept for 180 days. Across accounts, only per-domain delivered-versus-bounced counts are read, and only once a domain has at least five, to inform the mailbox confidence on catch-all domains.
Phone & IP
POST /api/v1/ip is live and has its own IP intelligence guide, with GET, self, batch, bulk and browser variants listed under Endpoints above. POST /api/v1/phone is live as well, with its own phone intelligence guide. Both use the same envelope and error codes; a phone answer bills one credit, and one credit covers five IP answers. The fields teams reach for first:
/v1/phone
| valid | boolean | true |
| e164 | string | null | "+12025550100" |
| line_type | string | null | "mobile" |
| carrier | string | null | "Spaw Test Wireless" |
| block_status | string | null | "allocated" |
| is_disposable | boolean | null | false |
| risk_score | number | null | 0 |
/v1/ip
| country | string | "US" |
| sanctions | string[] | null | [] |
| timezone_source | string | null | "region" |
| asn | number | 15169 |
| org | string | "Google LLC" |
| connection_type | string | null | "hosting" |
| is_datacenter | boolean | true |
| is_corporate_proxy | boolean | false |
| is_crawler | boolean | false |
| is_blocklisted | boolean | false |
| is_attack_source | boolean | false |
| is_relay | boolean | false |
| tor_last_seen | string | null | null |
| privacy_service | string | null | null |
| account_list | object | null | null |
| risk_score | number | 40 |
POST /api/v1/phone
Parses a number with libphonenumber metadata, then reads the block it sits in where a regulator publishes its allocations (NANPA for the United States and its territories, the CNAC for Canada, Ofcom for the United Kingdom and the Crown dependencies), checks the fictional ranges and the open list of numbers used by SMS-receiving websites, and sums a recomputable risk score. Nothing is guessed: a signal whose dataset is not installed answers null, and every block-level hint names its source next to it. 1 credit for a fresh answer with valid: true; invalid numbers, including an unassigned block, and 7-day repeats are free.
| parameter | type | description |
|---|---|---|
| phone | string · required | The number, in any common notation (spaces, dashes, brackets, a leading + or 00, tel: links, vanity letters). Include the country calling code or pass country. Up to 64 characters. |
| country | string · optional | An ISO 3166-1 alpha-2 region code (US, GB, PL) used to parse numbers written without a calling code, and to recognise that region's emergency numbers and short codes. |
| hlr | boolean · optional | Run the live carrier-network check. Reserves the premium credits up front and charges them only when the check answers. The base credit is shared with the plain lookup: a check after a plain lookup of the number within seven days costs only the premium. Once your account has paid for a check of the number, a repeat within seven days is free while the network's answer is held (a day); after that it asks again and is charged the premium. The live carrier-network check (hlr: true) is not enabled on the service: hlr_checked answers false and nothing extra is charged. |
| client_ip | string · optional | The IP address of the person behind the lookup (your visitor, not your server). Counted for client_velocity only; never looked up and never billed. |
| call_window | string · optional | Your calling window in the number’s local time, "HH:MM-HH:MM" (09:00-20:00; from the start up to, not including, the end; a window may run past midnight). Answers in_call_window. The zone is where the number was issued, not where the person is: arithmetic, not compliance advice. |
Phone response fields
All 49 fields, in the order the answer lists them. Fields that depend on a dataset answer null while that dataset is not installed, never a guess.
| field | type | description |
|---|---|---|
| valid | boolean | Whether the number is assignable: valid for its numbering plan and, where the regulator's table is installed, inside an allocated block. A number in a range reserved for fiction (is_fictional) answers valid even where the regulator lists its block as protected. |
| reason | string | null | Why it is not valid: missing_country, invalid_country_code, too_short, too_long, not_a_number, invalid_length, invalid_number, unassigned_block, emergency_number, or short_number. Only unassigned_block keeps the parsed number in the answer. |
| e164 | string | null | The canonical +12025550100 form to store and dedupe by. |
| national | string | null | The national display format, e.g. (415) 555-0142. |
| international | string | null | The international display format, e.g. +1 415-555-0142. |
| rfc3966 | string | null | The tel: URI form, e.g. tel:+1-415-555-0142. |
| country | string | null | ISO 3166-1 alpha-2; null for non-geographic numbers. |
| country_name | string | null | The English name of the country. |
| calling_code | number | null | The country calling code, e.g. 44. |
| continent | string | null | Two-letter continent code: AF, AN, AS, EU, NA, OC, SA. |
| is_eu | boolean | null | Whether the number's country is a member of the European Union. |
| currency | string | null | ISO 4217 code of the country's currency. |
| line_type | string | null | mobile, fixed_line, fixed_line_or_mobile, voip, toll_free, premium_rate, shared_cost, personal_number, pager, uan, voicemail, or unknown. A UK 084 or 087 number answers shared_cost, as Ofcom’s numbering plan classes it (in the plan, premium rate is the 09 range). 0871 to 0873 still fall under Ofcom’s premium-rate-services rules, so a sender that treats them as premium rate should block shared_cost too. |
| line_type_source | string | null | numbering_plan when the numbering plan decides the type: libphonenumber's metadata, or where the regulator's plan types a range differently (drama ranges the metadata leaves out, such as Ofcom's 07700 900xxx and ARCEP's +262 639 98; UK 084 and 087, which the plan classes as shared cost); block_allocation when a North American fixed_line_or_mobile number was settled by its block holder (a wireless carrier's block is mobile, a virtual-number wholesaler's is voip). |
| carrier | string | null | The block holder as the regulator lists it, or the carrier the metadata knows for the prefix. Reflects the original allocation, not a later port. |
| carrier_source | string | null | numbering_plan or block_allocation. |
| block_status | string | null | What the regulator's table says about the block: allocated to a holder, unassigned (free, protected, quarantined: no number in it can be in service), or reserved for special use. Null when no table covers the region or the table says nothing about this number. |
| region | string | null | A geographic description, e.g. "San Francisco, CA". |
| is_geographic | boolean | null | Whether the number is tied to a place (fixed lines and geographic mobiles) rather than a service such as toll-free. |
| timezones | string[] | Every IANA time zone the number can belong to: one for most numbers, several for country-wide ranges, empty for non-geographic numbers. |
| extension | string | null | The parsed extension ("123" from input like "ext 123"), kept out of the E.164 form. |
| is_fictional | boolean | null | The number sits in a range reserved for fiction and drama (NANP 555-01XX; Ofcom, ARCEP, ACMA and ComReg drama ranges and numbers). Still valid, never in service. |
| is_virtual | boolean | null | The block is held by a virtual-number wholesaler or VoIP provider. Null when no regulator table covers the number. |
| is_disposable | boolean | null | The number is published by an SMS-receiving website, so anyone can read a code sent to it. Null until the list is installed. |
| disposable_seen_at | string | null | When the SMS-receiving list last saw the number (YYYY-MM-DD), where the list says so. It scores disposable_stale instead of disposable once it is more than a year old for a number the lists still carry, or more than 90 days old for a number every list has dropped. |
| risk_score | number | null | The sum of the weights in risk_signals, capped at 100. Null for invalid numbers. |
| risk_level | string | null | high at 60 and above, medium at 30 and above, low below. |
| risk_signals | object[] | The signals behind the score, each with the weight applied and the dataset that produced it, so the sum is always risk_score. |
| hlr_checked | boolean | Whether a live carrier-network check answered; false whenever the lookup did not ask for one. The live carrier-network check (hlr: true) is not enabled on the service: hlr_checked answers false and nothing extra is charged. |
| reachable | boolean | null | Whether the handset was reachable at check time. |
| ported | boolean | null | Whether the number has been ported away from its original carrier. |
| current_carrier | string | null | The carrier serving the number today, per the live check. |
| roaming | boolean | null | Whether the handset was roaming at check time. |
| carrier_code | string | null | The regulator’s code for the block holder (the NANPA or CNAC operating company number, the ARCEP operator code, the UKE operator ID). Like carrier, it names the original allocation, not a later port; in a pooled US code it is the holder of the number’s own thousands-block where NANPA lists that block. Null when no table row names one, including when carrier comes from the plan’s prefix metadata. |
| carrier_brand | string | null | The network brand behind carrier ("AT&T" for NEW CINGULAR WIRELESS PCS, "Lumen" for QWEST CORPORATION), from a bundled, versioned map (carrier-brands in sources) matched by the holder’s OCN first and its name second, country by country. Null when the name is not mapped. |
| block_pooled | boolean | null | North American numbers only: the central-office code is pooled into thousands-blocks. In the United States carrier, carrier_code and line_type then describe the holder of the number’s own thousands-block, from NANPA’s block report; in Canada, and for a US block the report does not settle, they describe the code holder, not necessarily the block this number sits in. Null outside the NANPA and CNAC tables. |
| block_assigned_at | string | null | When the regulator assigned the code or range (YYYY-MM-DD), where its table dates it (NANPA, CNAC, ARCEP); in a pooled US code, when NANPA assigned the number’s own thousands-block. Null for a block its holder retained when the code was pooled, which NANPA leaves undated; for a pooled code the code holder answers for (in Canada, or a block NANPA’s report does not settle), because the code’s date says nothing about the number’s block; and for most US codes assigned before 2001, which NANPA leaves undated too: a null does not mean new. |
| region_level | string | null | How specific region is: locality (a North American rate centre, or a place named with its state), area (the area a code covers, such as "London" or "California") or country (only the country’s name, as for most mobiles). A heuristic over where the description came from and its shape. Null when region is null. |
| local_time | string | null | The local time at the number now (ISO 8601), only when every zone in timezones shares the current UTC offset. Worked out on every request, never stored. |
| utc_offsets | string[] | The distinct current UTC offsets across timezones ("-05:00"), sorted west to east. Empty when there are no zones. |
| in_call_window | boolean | null | Only when the request sent call_window ("HH:MM-HH:MM"): true when the local time in every zone in timezones is inside it, false when any is outside. Null without a window, without zones, and on an answer served from the suppression list. |
| did_you_mean | string | null | Invalid answers only, when a country was sent: the E.164 form of the one valid reading of the input, the digits with a missing + or with a foreign international prefix (011 outside North America) left in. Never applied; null when no reading or more than one is valid, when it lands in a block nobody holds (unless it sits in a range reserved for fiction), or when the input could be a national number with one digit too many or too few. |
| range_seen_by_you | object | null | {distinct_numbers, window_minutes}: how many distinct numbers in the same 1,000-number range your account looked up inside the window. Counted for single and browser lookups only; null where not counted and on publishable-key answers. |
| client_velocity | object | null | {distinct_numbers, window_minutes}: how many distinct numbers were looked up from the same client IP (the client_ip you sent, or the browser visitor’s address). Null without a client IP and on publishable-key answers. |
| destination_new_for_you | boolean | null | True when your account has not looked up a valid number in this country in the last 30 days. Null until a week of the account’s own lookups has been counted, wherever range_seen_by_you is not counted (batch, bulk and monitor rows, test numbers, invalid answers), and on publishable-key answers. Carries no weight. |
| complaints_90d | number | null | North American numbers only: consumer complaints naming this number as the caller in the FTC’s reported-calls data over the last 90 days; 0 when nobody complained. Null outside the NANP or before the first sync. Unverified reports about callers, often spoofed: context, not proof, and not a do-not-call scrub. |
| complaints_last_at | string | null | The date (YYYY-MM-DD) of the latest such complaint. |
| ok_to_send | boolean | Your account’s SMS policy applied to this answer (GET /api/v1/phone/policy). A fraud and deliverability decision, not consent and not a do-not-call scrub. |
| blocked_by | string | null | The first rule that decided a false, in this order: invalid, opted_out, country, line_type, a risk-signal name, risk_score, then unreachable or unconfirmed; suppressed on batch, bulk and monitored answers served from your suppression list. opted_out answers a single or browser lookup of a number your suppression list holds as opted out (the policy’s block_opted_out, on by default). Null when ok_to_send is true. |
Every response also carries a sources array naming the dataset and version behind the answer: libphonenumber always, the regulator table when it answered (nanpa-co-codes, cnac-co-codes, ofcom-numbering and the other tables listed on the phone guide), carrier-brands when carrier_brand names a brand, fictional-ranges and disposable-phone-numbers whenever they were evaluated, us-complaint-reports for a North American number (dated by the FTC’s newest file), delivery-feedback when your own outcome reports fired a signal, account-velocity (today’s date) when range_burst or numbers_per_client fired, and hlr-live-status when the live check ran.
Phone reasons
When valid is false, the reason is one of these; each has its own page under verdict reasons.
- missing_country
- invalid_country_code
- too_short
- too_long
- not_a_number
- invalid_length
- invalid_number
- unassigned_block
- emergency_number
- short_number
Phone risk weights
The risk_score is the flags below added up with published weights and capped at 100, so you can recompute it from risk_signals in the same answer. 60 and above reports risk_level: "high", 30 and above medium, below that low. Invalid numbers carry no score.
| fictional | range reserved for fiction (NANP 555-01XX; Ofcom, ARCEP, ACMA and ComReg drama ranges) | +80 |
| disposable | published by an SMS-receiving website | +70 |
| disposable_stale | listed by an SMS-receiving website, but not recently: last seen over a year ago, or over 90 days ago once every list dropped it | +40 |
| reported_abuse | you reported abuse for this number within 90 days | +60 |
| reported_undelivered | your latest report for this number within 90 days was a failed delivery | +50 |
| reported_abuse_widely | abuse reports from three or more accounts within 30 days; only the count of accounts is ever read | +40 |
| premium_rate | premium-rate number (in the UK, the 09 range) | +60 |
| reserved_block | block reserved for special use (555, test codes, designated ranges) | +40 |
| virtual | block held by a virtual-number wholesaler or VoIP provider | +40 |
| range_burst | your account looked up several numbers in this 1,000-number range within the window | +40 |
| voip | VoIP number under the numbering plan | +30 |
| pager | pager number | +30 |
| digit_pattern | one digit repeated or a straight run across the last seven digits | +30 |
| numbers_per_client | one client IP tried several different numbers within the window | +30 |
| disposable_range | not listed itself, in a 1,000-number range holding ten or more SMS-receiving numbers that still score disposable | +30 |
| international_network | international network or satellite number (+870, +881, +882, +883) | +30 |
| shared_cost | shared-cost number, including the UK 084 and 087 ranges | +20 |
| reported_unwanted_calls | consumers reported unwanted calls from this North American number three or more times, on at least two days, in the last 90 days | +20 |
SMS outcome feedback
POST /api/v1/phone/feedback tells us what happened after you sent an SMS: delivered, undelivered, abused, verified (the recipient entered the code) or opted_out. Your own reports feed your own later lookups of that number as risk signals (reported_abuse +60, reported_undelivered +50, cancelled by a later delivery or verification); no other account ever sees them. Free, up to 1,000 items per request; answers 202 with the counts recorded and skipped. Only failures about the number score: a failure your provider or you class as sender_side or not_sms_capable is kept and summarised, never counted, and one permanent failure suppresses the number at once. An abuse report suppresses it with reason abuse until you remove the entry. An opt-out never scores: it suppresses the number with reason opted_out, on an entry you imported too, until you remove the entry, and a single lookup says meta.opted_out: true and, under the default SMS policy, answers blocked_by: "opted_out" (an OTP sender may switch block_opted_out off). Honouring it stays your obligation; Spaw is not a consent record. GET /api/v1/phone/feedback/summary returns the last 90 days of outcomes, the failed deliveries by class, the distinct numbers they cover, verified_share (of the numbers reached, the share that verified: your OTP conversion), and the ten blocks with the most failed deliveries about the number, each with its own verified_share. Outcomes are kept for 180 days.
| items[].phone | string · required | The number, in any notation. Normalized like a lookup; a number that is not a valid subscriber number is skipped and counted, not rejected. |
| items[].outcome | string · required | delivered, undelivered (the carrier could not deliver), abused (the code was used by someone other than the recipient, or the number was involved in fraud), verified (the recipient entered the code; counts as a delivery), or opted_out (the recipient unsubscribed; never scored, and it suppresses the number until you remove the entry). |
| items[].failure_class | string | Undelivered only: permanent, transient, not_sms_capable or sender_side, from your provider's error code. Only permanent, transient and unclassified failures count against the number; one permanent failure suppresses it. The other two are kept and summarised, never scored. An opt-out is its own outcome, not a class. |
| items[].country | string | An ISO 3166-1 alpha-2 region for a number written without a calling code. Overrides the request-level country. |
| items[].occurred_at | string | When it happened, any parseable date. Defaults to now; a time more than five minutes ahead is refused, and one inside that margin is recorded as now. |
| country | string | The region applied to items that do not name their own. |
SMS providers can report for you: point their delivery status callbacks at POST https://spaw.co/api/v1/phone/feedback/{provider}/{key} with the feedback key from the API keys page (the same key as for email bounces). No bearer token, no credits; only definitive statuses become outcomes.
| twilio | /api/v1/phone/feedback/twilio/{key} | Message status callback: delivered → delivered · undelivered and failed → undelivered, classed by ErrorCode (30006 not_sms_capable, 30007 and the 10DLC and toll-free registration codes sender_side); 21610, a recipient who replied STOP, → opted_out. Queued, sending and sent are ignored; the callback carries no timestamp, so the outcome is dated on receipt. |
| vonage | /api/v1/phone/feedback/vonage/{key} | SMS API receipts and Messages API status webhooks: delivered → delivered · expired, failed, rejected and undeliverable → undelivered, classed by err-code or error.title (3 and 4 permanent, 14 not_sms_capable); 9 and 1240, the recipient asking not to be messaged, → opted_out. Accepted, buffered, submitted and read are ignored. |
| messagebird | /api/v1/phone/feedback/messagebird/{key} | Status reports, configured as POST: delivered → delivered · delivery_failed and expired → undelivered, classed by statusErrorCode or statusReason (1 permanent); 103 or the status reason opted out → opted_out. Scheduled, sent and buffered are ignored. |
| sinch | /api/v1/phone/feedback/sinch/{key} | Per-recipient and batch delivery reports: Delivered → delivered · Failed, Expired, Rejected and Aborted → undelivered, classed by code (150 and 151 permanent). Dispatched, queued and unknown are ignored. |
Batch, bulk and the live check
POST /api/v1/phone/batch takes up to 1,000 items and POST /api/v1/phone/bulk up to 100,000, each item a number or { phone, country }. Both accept hlr: true, which runs the live carrier-network check on every item: the balance is gated for the premium up front and it is charged only where the network answered; a bulk job stores the choice as live_check and its CSV carries the hlr_checked, reachable, ported, current_carrier and roaming columns after risk_signals. The batch also takes call_window ("09:00-20:00"), which answers in_call_window for every item; a bulk job takes no window. Numbers on your suppression list answer free in batch and bulk, marked meta.suppressed: true per item and in the CSV's trailing suppressed column. The two columns after it mark a row that repeats a number an earlier row held: is_duplicate and duplicate_of_row, the earlier row's 1-based place in the list. The results download takes variant valid, invalid, low, medium, high, deduped (valid, first row of each number), mobile, ok or blocked (by your policy's ok_to_send), and a settled job carries a summary of its rows.
Phone suppression list
A fresh unassigned_block answer adds the number to your account's phone suppression list, and so do your own SMS reports: two undelivered reports with no later delivery, one permanent failure, any abuse report, or an opt-out. Batch, bulk and monitor runs serve suppressed numbers free from the stored answer, or with reason "suppressed" where there is none (an import, your own failure or abuse reports, an opt-out); a single lookup always re-checks. A valid answer removes only an entry an unassigned block put there: one your failure reports added leaves when you report a delivery or a verification, and an abuse report, an opt-out or an import only when you remove it. An abuse report or opt-out about a number you imported is recorded on that entry, which stays an import, and an abuse or opt-out entry you removed comes back only with a new abuse report or opt-out, never from one you had already made. Imports, opt-outs and abuse entries never go stale, though an import serves a stored answer it kept from before you imported it only for 90 days after the entry was last renewed; any other entry older than 90 days is re-checked instead of trusted.
Manage it directly: GET /api/v1/phone/suppressions lists entries (100 per page), POST /api/v1/phone/suppressions imports up to 10,000 known-dead numbers per request as { items: [phone | { phone, country }], country } and answers the counts added, already_suppressed and invalid, and DELETE /api/v1/phone/suppressions/{id} removes one. Imports are free.
Monitored phone lists
POST /api/v1/phone/monitors saves up to 500 numbers for a weekly or monthly re-check (answers 201; the baseline run is queued right away). From the next run on, numbers that stop being valid, or stop being reachable when the live check is on, count as decayed and you get an email. Each run bills every number like a single lookup; suppressed numbers answer free. GET /api/v1/phone/monitors lists them with the last summary (total, valid, invalid, unassigned, unreachable, changed, decayed, newly_flagged, credits_used), GET /api/v1/phone/monitors/{id} adds results (number → valid, invalid, unassigned or unreachable) and flags (number → the change flags the last run raised: became_disposable, reported_abuse_widely, risk_high, line_type_changed, and with the live check ported and carrier_changed), POST /api/v1/phone/monitors/{id}/run re-checks now (202) and DELETE /api/v1/phone/monitors/{id} stops it.
| name | string · required | A label for the list, up to 100 characters. |
| numbers[] | string[] · required | Up to 500 numbers in any notation. Numbers written without a calling code need the country. |
| cadence | string · required | weekly or monthly. No daily: most tables behind a run refresh weekly, a weekly run already catches a change within days, and a daily live check would pay for the network seven times a week. |
| country | string | An ISO 3166-1 alpha-2 region for numbers written without a calling code. |
| live_check | boolean | Run the live carrier-network check on every run, where the service has it enabled; the premium credits apply per number that answers. |
Test numbers
Eight fixed numbers in the Washington 555-01XX fiction block always answer the same result, cost no credits and leave no repeat marker, so integration tests never spend credits or depend on the day's tables. They answer on every endpoint, including batch, bulk and the browser endpoint, and their sources name the test-numbers dataset. National notation works with country US; the neighbouring 555-01XX numbers are real lookups, flagged fictional and charged.
| +1 202 555 0100 | valid · line_type mobile from the block holder, carrier Spaw Test Wireless, risk_score 0 |
| +1 202 555 0101 | valid · line_type fixed_line, carrier Spaw Test Telecom, risk_score 0 |
| +1 202 555 0102 | valid · line_type voip, is_virtual true, risk_score 40 (virtual) |
| +1 202 555 0103 | valid · is_disposable true, disposable_seen_at today, risk_score 70 (disposable) |
| +1 202 555 0104 | invalid · reason unassigned_block, block_status unassigned, the parsed number kept |
| +1 202 555 0105 | valid · is_fictional true, risk_score 80 (fictional) |
| +1 202 555 0106 | invalid · reason invalid_number, every other field null |
| +1 202 555 0113 | valid · not listed, but in a 1,000-number range crowded with SMS-receiving numbers, risk_score 30 (disposable_range) |
Test postal addresses
Five fixed addresses on a street that does not exist, under a ZIP the postal service has never assigned, always answer the same result on POST /api/v1/address and every other address endpoint, cost no credits, leave no repeat marker and touch no suppression list; their sources name the test-postal-addresses dataset. Send them with country US; the published sandbox key answers them too. The full field-by-field contract is in the address verification guide.
| 1 Spaw Test Street, Springfield, IL 99999 | valid · exists true from the test register, located, risk_score 0 |
| 2 Spaw Test Street, Springfield, IL 99999 | valid · is_mail_drop true (operator_list, Spaw Test Mailboxes), companies_registered 120, risk_score 50 (mail_drop) |
| 3 Spaw Test Street, Springfield, IL 99999 | valid · exists false, match_level street, risk_score 20 (not_in_register) |
| 4 Spaw Test Street, Springfield, IL 99999 | valid · postcode_status terminated, risk_score 30 (terminated_postcode) |
| 5 Spaw Test Street PMB 5, Springfield, IL 99999 | valid · address_type private_mailbox, unit PMB 5, risk_score 60 (private_mailbox) |