The Python client is a thin wrapper over the REST API: one method per endpoint, the API's data and meta blocks returned exactly as documented, and a typed exception for every error code. It has no dependencies beyond the standard library, ships type hints, and works on Python 3.9 and newer.
The package is built and will be published on PyPI as spaw-sdk; the distribution name differs from the import name because spaw was already taken. Until the listing is live, the plain REST calls in the API reference do the same job with a few more lines.
Install and verify one address
- Create a secret key on the API keys page.
- Install the package once it is published:
pip install spaw-sdk
- Verify an address. The import name is
spaw:
from spaw import Client
client = Client("sk_live_…")
result = client.verify_email("[email protected]")
print(result.data["deliverable"]) # "deliverable", "risky" or "undeliverable"
print(result.data["reason"]) # e.g. "catch_all", or None when deliverable
print(result.data["risk_score"]) # 0–100, a sum of published weights
print(result.meta["credits_used"]) # 1 for a fresh answered lookup, 0 otherwise
Every reason and smtp_reason value is explained on the verdict reasons pages, and result.data carries all 46 documented fields, including did_you_mean, mx_provider and smtp_checked_at.
Everything the client does
| Method | Endpoint |
|---|---|
verify_email(email, callback_url=None, callback_secret=None) |
POST /email |
verify_emails(emails) |
POST /email/batch, up to 50 |
domain(domain) |
GET /email/domain/{domain} |
list_bulk_jobs(page=1, status=None, since=None, until=None), preview_bulk_job(emails=None, csv=None, webhook_url=None), start_bulk_job(draft_id, mapping=None, idempotency_key=None), create_bulk_job(emails, webhook_url=None), get_bulk_job(id), cancel_bulk_job(id), resume_bulk_job(id), redeliver_bulk_webhook(id), download_bulk_results(id, variant=None, format=None) |
the bulk endpoints, up to 100,000 addresses; a run that stopped on an empty balance is resumed from the top of its stored list |
list_suppressions(page=1, value=None, source=None, reason=None, since=None, until=None, per_page=None), add_suppressions(emails), remove_suppression(id), remove_suppressions(values) |
the suppression list, by id or by the address itself (up to 1,000 per call) |
report_feedback(items), feedback_summary() |
delivery outcomes and measured accuracy |
account(), ledger(since=None, until=None, reason=None, via=None, api_token_id=None, page=None, per_page=None), request_log(request_id), data_status() |
the live credit balance, the credit ledger, and one request read back by the id it answered with |
send_test_webhook(url, event) |
POST /webhooks/test, a real signed delivery to a URL you name, free, so a signature check can be written before a paid run depends on it |
verify_webhook(raw_body, header, secret, tolerance=…) |
checks a delivery you received: the digest AND the timestamp in X-Spaw-Signature-V2, compared in constant time. A module-level function, not an API call |
validate_phone(phone, country=None, hlr=None, client_ip=None, call_window=None), validate_phones(phones, country=None, hlr=None, call_window=None) |
POST /phone and POST /phone/batch, up to 1,000 numbers |
phone_block_report(country, prefix) |
GET /phone/block/{country}/{prefix}, the regulator's own allocation table for one range |
list_phone_bulk_jobs(page=1, …), preview_phone_bulk_job(phones=None, csv=None, country=None, hlr=None), start_phone_bulk_job(draft_id, mapping=None), create_phone_bulk_job(phones, country=None, webhook_url=None), get_phone_bulk_job(id), cancel_phone_bulk_job(id), resume_phone_bulk_job(id), redeliver_phone_bulk_webhook(id), download_phone_bulk_results(id, variant=None, format=None) |
the bulk phone endpoints, up to 100,000 numbers |
list_phone_suppressions(page=1, value=None, country=None, …), add_phone_suppressions(items, country=None), remove_phone_suppression(id), remove_phone_suppressions(values, country=None) |
the phone suppression list; a value is read to E.164 before it is matched |
get_phone_policy(), update_phone_policy(max_risk_score=…, block_signals=…, allowed_countries=…, blocked_countries=…, blocked_line_types=…, require_reachable=…, block_opted_out=…), reset_phone_policy() |
the account's SMS policy behind ok_to_send and blocked_by on every phone answer (GET, PUT, DELETE /phone/policy); a fraud and deliverability decision, not consent and not a do-not-call scrub |
lookup_ip(ip, privacy=None, abuse_contact=None, network_details=None, hostname=None, browser_timezone=None, fields=None), lookup_my_ip(), lookup_ips(ips) |
POST /ip, GET /ip/me, POST /ip/batch, up to 1,000 addresses; browser_timezone fills timezone_matches, fields keeps only the named keys |
lookup_asn(asn), lookup_ip_range(prefix) |
GET /ip/asn/{asn} (every prefix of one autonomous system) and GET /ip/range?prefix=… (what the feeds say about a whole prefix) |
list_ip_lists(), create_ip_list(name, kind, entries), get_ip_list(id), update_ip_list(id, …), delete_ip_list(id) |
your own allow and deny lists, account-wide, applied to every lookup: an allow-list hit zeroes the score, a deny-list hit tops it |
list_ip_bulk_jobs(page=1, …), preview_ip_bulk_job(ips=None, csv=None, privacy=None), start_ip_bulk_job(draft_id, mapping=None), create_ip_bulk_job(ips, webhook_url=None, privacy=None), get_ip_bulk_job(id), cancel_ip_bulk_job(id), resume_ip_bulk_job(id), redeliver_ip_bulk_webhook(id), download_ip_bulk_results(id, variant=None, format=None) |
the bulk IP endpoints, up to 100,000 addresses |
report_ip_feedback(items), ip_feedback_summary() |
IP outcomes (abuse, fraud, bot, chargeback), free |
get_email_policy(), update_email_policy(max_risk_score=…, block_signals=…, min_mailbox_confidence=…), reset_email_policy() |
the account's sending policy behind ok_to_send and blocked_by on every email answer (GET, PUT, DELETE /email/policy) |
list_domain_monitors(), create_domain_monitor(name, domains, cadence, webhook_url=None, webhook_events=None), get_domain_monitor(id), update_domain_monitor(id, …), run_domain_monitor(id), delete_domain_monitor(id) |
monitored domain lists: up to 500 domains re-checked daily, weekly or monthly for mail-setup drift (/email/domain-monitors) |
list_ip_monitors(), create_ip_monitor(name, ips, cadence, webhook_url=None, webhook_events=None), get_ip_monitor(id), update_ip_monitor(id, …), run_ip_monitor(id), delete_ip_monitor(id) |
monitored IP lists with blocklist alerts, changeable in place and postable to a webhook |
verify_address(address, country=None, deliverability=None, fields=None), verify_addresses(addresses, country=None) |
POST /address and POST /address/batch, up to 100 addresses; the separated fields include building, fields keeps only the named keys, and every answer carries the 61 fields incl. unit_check, looks_placeholder, seen_by_you, ok_to_ship and blocked_by |
list_address_bulk_jobs(page=1, …), preview_address_bulk_job(addresses=None, csv=None, country=None, deliverability=None), start_address_bulk_job(draft_id, mapping=None), create_address_bulk_job(addresses, country=None, deliverability=None, webhook_url=None, idempotency_key=None), get_address_bulk_job(id), cancel_address_bulk_job(id), resume_address_bulk_job(id), redeliver_address_bulk_webhook(id), download_address_bulk_results(id, variant=None, format=None) |
the bulk address endpoints, up to 100,000 addresses |
address_streets(country, postal_code) |
GET /address/streets, every street a national register lists inside a postcode, 1 credit |
addresses_at_postcode(postcode, country=None) |
GET /address/postcode/{postcode}: the same streets from an open register, or a licensed partner's delivery points where one is configured |
get_address_policy(), update_address_policy(max_risk_score=…, block_signals=…, require_exists=…), reset_address_policy() |
the account's shipping policy behind ok_to_ship and blocked_by on every address answer (GET, PUT, DELETE /address/policy) |
list_address_monitors(), create_address_monitor(name, addresses, cadence, country=None, deliverability=None, webhook_url=None, webhook_events=None), get_address_monitor(id), update_address_monitor(id, …), run_address_monitor(id), delete_address_monitor(id) |
monitored address lists, alerting on newly flagged addresses |
report_address_feedback(items, country=None), address_feedback_summary() |
postal outcomes (delivered, returned, moved, refused), free and never shared between accounts |
list_address_suppressions(page=1, value=None, country=None, …), add_address_suppressions(items, country=None), remove_address_suppression(id), remove_address_suppressions(values, country=None) |
the address suppression list, up to 10,000 per import; a value is folded against its country before it is matched |
verify_entity(identifier, type, country=None), verify_entities(identifiers) |
POST /entity and POST /entity/batch, up to 500 identifiers read against locally synced registers |
list_entity_monitors(), create_entity_monitor(name, identifiers, cadence, webhook_url=None, webhook_events=None), get_entity_monitor(id), update_entity_monitor(id, …), run_entity_monitor(id), delete_entity_monitor(id) |
monitored identifier lists, alerting when an entity is dissolved or its registration lapses |
list_monitors(), create_monitor(name, emails, cadence, webhook_url=None, webhook_events=None), get_monitor(id), update_monitor(id, …), run_monitor(id), delete_monitor(id), and the phone equivalents |
monitored email and phone lists; an update sends only the fields you name, and webhook_url=None removes the webhook |
check_consistency(email=None, phone=None, country=None, ip=None, address=None) |
POST /consistency, any subset of the four signals compared against each other |
list_consistency_bulk_jobs(), create_consistency_bulk_job(items, country=None, webhook_url=None), preview_consistency_bulk_job(items=None, csv=None, …), start_consistency_bulk_job(draft_id, mapping=None), get_consistency_bulk_job(id), cancel_consistency_bulk_job(id), resume_consistency_bulk_job(id), redeliver_consistency_bulk_webhook(id), download_consistency_bulk_results(id, variant=None) |
the same check over a whole list, queued: a row costs the sum of the legs it ran and the comparison is free |
A batch keeps the API's shape: data["results"] holds one {index, input, data, meta} per address in input order — index is its place in the list you sent and input the value as you sent it, because the answer carries the normalized form, and meta["stopped_reason"] is set instead of an exception when the balance runs out part-way, so paid results are never lost.
A bulk job or a monitor returns its webhook_secret once; keep it, because it signs the deliveries. spaw.verify_webhook(raw_body, header, secret) is the other half: pass the raw body and the X-Spaw-Signature-V2 header and it answers whether the delivery is ours, unmodified and recent. It checks the timestamp as well as the digest, which is the point of the header — 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. The original X-Spaw-Signature, with no timestamp in it, is sent beside it until 1 March 2027 and is deliberately not accepted by this call.
A delivery that failed is not lost: redeliver_bulk_webhook(job_id) (and its phone, IP, address and cross-signal twins) posts that run's completion webhook to your URL again, for the day your endpoint was down for the half-minute the three attempts cover. The body is byte-for-byte the one the first delivery carried, rebuilt from the run's own frozen row, so the two reconcile — but the signature is stamped afresh, and has to be, because verify_webhook refuses anything whose timestamp is more than five minutes old. Verify a redelivery with the same call you verify everything else with. Nothing deduplicates it for you: a receiver that gets both sees the event twice, and job.id is the key to deduplicate on. A run created without a webhook_url raises ConflictError with code WEBHOOK_NOT_CONFIGURED and one that has not settled JOB_NOT_FINISHED; a run whose last delivery succeeded is not refused, because a receiver can lose a message it acknowledged. The delivery is queued rather than made while you wait — one attempt takes half a minute against an endpoint that is down — so the call answers 202 and the run it hands back reads webhook_status: "pending" until a worker has an answer; read the outcome with get_bulk_job(job_id). It is free and capped at ten a minute per signed-in user, neither per key nor per account.
Addresses
verify_address takes the address as one written line or as a mapping of the separated fields (address_line1, locality, postal_code and the rest), always with a country: that is what decides which country's format it is read under, and it is never guessed. The answer splits the address into components, formats it the way that country writes addresses, and says what open data can establish — the postcode's status and centroid, whether the town matches it, what kind of delivery point it is, how close a national address register comes to it, and how many companies are registered there.
address = client.verify_address("10 Downing Street, London SW1A 2AA", country="GB")
address.data["formatted"] # the address as that country writes it
address.data["postcode_status"] # "live", "terminated", or None where no directory covers the country
address.data["address_type"] # street, po_box, private_mailbox, general_delivery, …
address.data["risk_signals"] # the weights behind risk_score
client.verify_addresses(addresses, country="GB") # up to 100 per call; an item's own country wins
For a whole list, queue a bulk run instead of looping: up to 100,000 rows in the background, billed per row on exactly the single-lookup rules, with in-list repeats free and counted in duplicate_count.
Read the list before you start it: the preview_bulk_job() call (and its phone, IP and address twins) answers what we made of it — which column filled which field and whether a heading named it or we guessed, the first rows as parsed, the repeats, and credits_upper_bound — and queues nothing. That figure is a CEILING, never a quote: a row can be free for reasons no preview can know in advance, so a run usually costs less and never more. Start it with the start_bulk_job() call, correcting a column we read wrongly with mapping.
job = client.create_address_bulk_job(
addresses, country="GB", idempotency_key="addresses-2026-09-05"
).data["job"]
client.get_address_bulk_job(job["id"]) # status, counts, credits_used
client.download_address_bulk_results(job["id"]) # or variant="invalid" / "high"
A field answers None when its dataset does not cover the country, which means "not evaluated" and never "no". exists is True only on a premise match and False only under a register whose coverage is complete: a register whose coverage is partial, because it covers part of the country or cannot vouch for every address, cannot make an address's absence mean anything, so it answers None. is_mail_drop is a heuristic built from mailbox operators' own published branch lists and from how many companies are registered at an address, never a postal service's own agency flag; mail_drop_source says which of the two fired.
One answer is licensed and not enabled. deliverability=True would ask a partner whether post actually arrives and whether the delivery point is a mail-receiving agency, vacant or residential; until one is configured deliverability_checked comes back False, the four fields stay None and the premium credits are charged only where a partner answered. The streets inside a postcode are an open answer wherever a national register with a street listing is installed: address_streets(country, postal_code) lists them for 1 credit, addresses_at_postcode answers the same streets as one entry each (a licensed partner's delivery points, 2 credits, where one is configured), and a country neither covers (Italy, for one), and a postcode the listing cannot speak for — a Northern Irish BT, forces BFPO or non-geographic BX code, or a German code outside Berlin — raises NotEnabledError (ADDRESS_LIST_UNAVAILABLE), free; an ordinary British postcode answers its district's streets from Ordnance Survey's Open Names, never its buildings. A postcode the register lists no street at raises NotFoundError (ADDRESS_STREETS_NOT_FOUND), free.
A saved list of up to 500 addresses is re-read on a weekly or monthly cadence — there is no daily one, because the registers and postcode directories an address is measured against are republished weekly at best. Every run answers each address ok, one of the flagged invalid, not_in_register, terminated_postcode, mail_drop and high_risk, or unverifiable, which means the address stood as written but no dataset installed on this server covers it well enough to check — a limit of the data, never a property of the address, and never an alert. An email goes out only for an address that was already known and unflagged and became flagged; a first run and a newly added address only set the baseline, and one that stays flagged never alerts twice. Runs bill per address exactly like a single lookup.
monitor = client.create_address_monitor("Supplier sites", addresses, "monthly", country="GB").data["monitor"]
client.get_address_monitor(monitor["id"]).data["monitor"]["results"] # {address: {status, flags, changed, …}}
client.run_address_monitor(monitor["id"]) # re-check now
Tell the service what happened to the post you sent and it keeps your own suppression list. Outcomes are delivered, returned, moved and refused. A moved or refused report suppresses the address at once; a second returned not followed by a later delivered suppresses it; a delivered report clears an entry the feedback put there, and never touches one you imported. Batch, bulk and monitor runs answer a suppressed address free with meta["suppressed"] true, a single lookup never consults the list, and drops an entry your reports added once they no longer stand behind it (a delivery since, or reports aged out); an import leaves only when you remove it; and an entry your reports added is re-verified rather than served once it is older than 90 days, while an import never goes stale.
client.report_address_feedback([ # free, and never logged as a lookup
{"address": "10 Downing Street, London SW1A 2AA", "outcome": "returned"},
{"address_line1": "Postfach 10 01 01", "country": "DE", "outcome": "moved"},
], country="GB")
client.address_feedback_summary().data["outcomes"] # your own counts, by outcome
client.add_address_suppressions(dead_addresses, country="GB") # up to 10,000, free
client.list_address_suppressions().data["suppressions"]
client.remove_address_suppression(suppression_id) # read fresh next time
Unlike the IP and phone products, address feedback is never shared between accounts. An IP is a network endpoint and a phone number is a handset, but a postal address is where somebody lives, and a shared "post fails here" database would be a database about residences. Your reports raise the risk of your own later lookups of an address and nobody else's, through reported_returned (+50, dataset address-feedback), which is scored while your most recent report for the address is returned, moved or refused rather than delivered.
Businesses
Business verification reads one identifier — a Legal Entity Identifier or a UK company number — against the registers this server holds, which is the check you want before signing with a counterparty or when a supplier list needs going over again. Both registers are bulk files synced locally, so no third party sits in the request path and nothing about a lookup leaves the server; the LEI reference data is published under a CC0 public-domain dedication, and Spaw is not affiliated with its publisher. A company the national register records as dissolved is the strongest signal the product carries: a dissolved company no longer exists as a legal person and cannot enter a contract. A monitor that stops being run is deleted and the identifiers it stored go with it — prunes_after_days on every monitor payload says how long that takes — because an identifier can belong to a sole trader as easily as to a company.
Cross-signal consistency
check_consistency runs any subset of the four signals in one call and reports where their answers agree and where they do not: it is built for signup, onboarding and marketplace vetting, the moment before there is an order or a card and the contact details are all you have. Each signal bills exactly as its own single lookup, and the analysis on top is free, because every comparison is arithmetic over fields the four pipelines already computed. There is no score and no verdict — you get named flags and both sides of every comparison quoted, because a difference between two fields is a fact about the fields and not a judgement about a person.
check_consistency is one record at a time. preview_consistency_bulk_job and start_consistency_bulk_job run the same check over a list of up to 100,000 rows, so scoring a signups export is one upload rather than four and a spreadsheet join afterwards. One header row is read as an email address, a phone number, an IP address and the address fields at once, and a row that resolves only two of the four is a perfectly good consistency check of those two.
The preview prices the run against the SIGNALS the columns feed rather than one credit a row — a file reaching only an email address and a phone number answers credits_per_row 2, not 4 — and it is still a ceiling and never a quote.
draft = client.preview_consistency_bulk_job(csv=open("signups.csv").read(), country="GB")
draft.data["draft"]["mapping"] # {"1": "email", "2": "phone", "3": "ip", "4": "address_line1", …}
draft.data["draft"]["credits_upper_bound"] # the most it can cost, never what it will cost
job = client.start_consistency_bulk_job(draft.data["draft"]["id"])
rows = client.download_consistency_bulk_results(job.data["job"]["id"], variant="flagged")
A result row is the comparison, not four lookups pasted together: signals_requested, signals_answered, country_agreement, flags, one cell per comparison, distance_km, and the few per-product fields those comparisons read. A comparison cell reads agree, differ, not_compared, missing_value, low_confidence, or nothing at all — an empty cell is a comparison never attempted, because the row did not carry both of its signals.
report = client.check_consistency(
email="[email protected]",
phone="+442079460018",
country="GB", # the region hint for the phone number only
ip="81.2.69.142",
address={"address": "10 Downing Street, London SW1A 2AA", "country": "GB"},
)
report.data["consistency"]["flags"] # e.g. ["ip_country_differs_from_address_country"]
report.data["consistency"]["country_agreement"] # "agree", "differ", or None when none could be made
report.data["signals"]["ip"]["data"] # each product's own result, unchanged
report.meta["credits_used"] # the sum of the per-signal costs
Handle errors
Every failure raises a subclass of SpawError carrying code, message, http_status and request_id; no credits are spent on a failed request.
from spaw import InsufficientCreditsError, RateLimitedError, SpawError
try:
result = client.verify_email("[email protected]")
except InsufficientCreditsError:
... # nothing ran and nothing was billed
except RateLimitedError as error:
... # error.retry_after seconds, from the Retry-After header
except SpawError as error:
print(error.code, error.http_status, error.request_id)
AuthenticationError maps UNAUTHENTICATED, ValidationError maps VALIDATION_FAILED with errors keyed by field, NotFoundError covers a bulk job or suppression entry that is not on the account, and SpawConnectionError means the API could not be reached. Pass max_retries=2 to the client to retry 429 and 5xx answers automatically, honouring Retry-After.
What to do with the verdict
Branch on deliverable. Keep deliverable addresses, drop undeliverable ones, and route risky ones by reason: disposable and likely_typo are usually refused, role depends on your use case, and catch_all is often a fine address whose mailbox simply cannot be confirmed, in which case mailbox_confidence gives you a number to threshold. Offer did_you_mean back to the user when it is set.
Cost
One credit per fresh deliverable or risky verdict, and one per five IP lookups. Unverified answers, invalid input, repeats within seven days and the spaw.test test addresses cost nothing, undeliverable verdicts cost nothing within a fair-use allowance, and every account gets 100 free credits a month. Use [email protected] and its siblings in your test suite: they answer fixed results, cost nothing and never appear in your history.