# Email verification

How Spaw email verification works: verdicts and the risk score, list validation, bulk runs with webhooks, monitors, the suppression list, and the form widget.

Every lookup runs RFC syntax parsing, live DNS and MX checks, SPF and DMARC records, open disposable and role lists, and a mailbox-level SMTP handshake — without ever sending mail. This guide covers how to read the answer and every way to run verifications at scale.

## Verdicts and the risk score

Each answer leads with one of three verdicts in `deliverable`, and a machine-readable `reason` when it is not a clean pass:

| Verdict | Meaning |
| --- | --- |
| deliverable | The mailbox accepts mail. Nothing in the checks argues against sending. |
| risky | Mail may arrive, but something argues for caution: a typo-squat of a major provider, a parked domain that still accepts mail, a disposable domain, mail served from a blocklisted network, a web-only domain with no MX record, a full mailbox, a no-reply or role inbox, a mailbox that was dead and exists again, a catch-all server that accepts everything, or a mailbox the handshake could not confirm (reason unverified). |
| undeliverable | Do not send. The syntax is invalid, the username breaks its own provider's rules, the domain has no usable mail server, or the exact mailbox was confirmed missing or disabled. |

Every reason value is explained at https://spaw.co/docs/reasons.

The `risk_score` is not a black box: it is the documented flags added up with published weights, so you can recompute it — or ignore it and branch on the explicit booleans behind it.

| Signal | Fires when | Points |
| --- | --- | --- |
| disposable | disposable domain | +80 |
| parked_domain | parked or for-sale domain that still accepts mail | +60 |
| likely_typo | known typo-squat domain (gamil.com) | +60 |
| mx_blocklisted | mail served from a blocklisted network | +60 |
| implicit_mx | web-only domain, no MX record | +30 |
| role | role address (info@, support@, …) | +30 |
| catch_all | catch-all domain | +30 |
| young_domain | domain registered under 30 days ago | +30 |
| recycled_mailbox | mailbox that was dead and exists again | +30 |
| mailbox_full | mailbox full | +20 |
| gibberish | gibberish local part | +20 |
| generated | machine-made or placeholder local part | +20 |
| no_reply | no-reply address, on top of role | +20 |
| widely_seen | looked up by three or more other accounts in a day | +20 |
| catch_all_gateway | catch-all behind a security gateway (instead of catch-all) | +15 |
| new_domain | domain registered under 180 days ago | +10 |
| no_spf | no SPF record on a business domain | +10 |
| unverified | mailbox not SMTP-verified | +10 |
| expiring_domain | registration ending within 30 days | +10 |
| alias | plus-tag alias | +5 |
| relay | relay or forwarding service (never a burner) | +5 |

An undeliverable verdict is always 100. Scores of 60 and above report `risk_level: "high"`, 30 and above medium, below that low. When mail servers refuse to say whether an inbox exists, the answer degrades honestly — `smtp_checked: false`, null, and a risky verdict with reason `unverified` — never to a guess. An unverified answer is free whatever the cause and leaves no seven-day repeat marker, so a later lookup is a fresh one: the uncertain result is reused for up to 15 minutes, then the mailbox is asked again, and the lookup bills only once the mailbox is confirmed; an undeliverable verdict that needed a mailbox check is free within a fair-use allowance (https://spaw.co/pricing). Where the mailbox could not be settled, catch-all domains above all, `mailbox_confidence` adds a recomputable 0–100 estimate from the signals already in the answer: DMARC, SPF, an established mail provider, an old registration, and a name-shaped address raise it; gibberish, a fresh domain, and a plus-tag lower it. Reported delivery outcomes for the domain outweigh all of that once five or more have been sent in: a bounce rate of 10% or less adds 15, one of 50% or more takes 20 away, 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 is the exception: it stays at 50, and only a bounce rate of 50% or more or a plus-tag lowers it, because the domain's records describe the relay operator, and an alias's name, whether the relay generated it or its owner chose it, says nothing about whether it exists.

Three rules keep the sum honest. The two registration-age rows are exclusive, and so are `catch_all` and `catch_all_gateway`: behind Proofpoint, Mimecast or Barracuda a catch-all is the gateway's design rather than a squat, so it weighs 15 instead of 30. A no-reply address is a role address and carries both rows, 50 in all. And a relay address — Firefox Relay, SimpleLogin, Addy.io, DuckDuckGo — is a real person's real mailbox, so it is never disposable: it carries 5 for the withheld identity and nothing else. `risk_signals` lists exactly the rows that fired, each with its weight and the dataset it was read from, so the sum is always in the answer; an undeliverable verdict lists nothing and answers 100.

Beyond the verdict the answer says what the username and the domain look like, without a network call for the first and from data already in hand for the second. `local_part_pattern` names the shape of the username (first.last, flast, a lone first name, digits, a plain word) with the name tokens the bundled dictionary recognised in `first_name` and `last_name`, and `looks_generated` marks a hex string, a UUID, a placeholder word or a name glued to a run of digits. On the domain side `domain_category` sorts relay, disposable, webmail, academic, government and military domains from the rest, `mx_provider_type` says whether the provider hosts mailboxes, is a security gateway, a transactional relay, a web host or the domain's own servers, `mx_blocklisted` and `mx_network_type` run the mail hosts through the threat blocklists and network feeds the IP product already installs, and `domain_expires_at` and `parked` read the registration's end and the parking nameservers. Two signals grow from traffic rather than lists: `previously_invalid` remembers a "no such mailbox" answer for a year so a mailbox that exists again is reported as recycled, and `widely_seen` says whether three or more other accounts checked the address inside a day. Both are counts and dates under a keyed hash, never the address, and both are erased with it. The full 46-field contract, with every enum, is in the endpoint reference: https://spaw.co/docs/api/verify-email.

Repeats inside the seven-day window are free and served from the earlier answer. To ask for a fresh one, pass `refresh: true` (the earlier answer and the shared mailbox answer are set aside, a real handshake runs, and the lookup bills as fresh when answered) or `max_age` in seconds to do so only when the earlier answer is older than that; `meta.refreshed` says whether an earlier answer was discarded. `timeout` (2 to 60 seconds) caps how long the handshake may wait for one call; when it runs out the answer is unverified with `smtp_reason: timeout` instead of holding the request. All three work on the single and batch endpoints.

Every answer ends with the decision your own sending policy makes about it: `ok_to_send`, and `blocked_by`, the rule that blocked it. An undeliverable verdict never passes; the rest is yours to set (see Sending policy below).

Pass `fields`, a comma-separated list of the keys you want, to get only those in `data`, in the answer's own order: unknown names are ignored, a blank list means everything, `sources` is kept only when named, and `meta` is never narrowed. It works on the single and the browser endpoint, not on batch or bulk. `did_you_mean` now draws on the thousand most-linked domains and the domains you verify most, not only the big mailbox providers, and a domain outside that list is suggested only when its own mail servers accept mail.

## Sending policy

The score is arithmetic; the decision is yours. A sending policy turns an answer into one boolean, `ok_to_send`, so a form or a campaign branches once instead of re-implementing the verdict table. It has three rules, all optional: `block_signals`, the weights that block whenever they fire; `max_risk_score`, a ceiling on the score; and `min_mailbox_confidence`, a floor for a catch-all or unverified mailbox. The rules are applied in that order after the verdict, and `blocked_by` names the first one that fired: `undeliverable`, the blocking signal's own name, `risk_score` or `mailbox_confidence`. An undeliverable verdict never passes, whatever the policy says.

Until you set one, the published defaults apply: the `disposable`, `likely_typo`, `parked_domain`, `mx_blocklisted` and `no_reply` signals block, so does any score over 60, and there is no confidence floor. The defaults are what the guest demo and the sandbox key answer with, because neither has an account to keep a policy. The test addresses answer under your account's stored policy, like every other lookup your key makes, so they are a way to try a policy before a customer meets it.

The policy belongs to the account and applies to every channel the account is billed for: the single and batch endpoints, bulk runs (the result file carries `ok_to_send` and `blocked_by` as columns and the summary counts the blocked rows), monitors, the form widget and the dashboard. Read it with `GET /api/v1/email/policy` (https://spaw.co/docs/api/get-email-policy), change it with `PUT /api/v1/email/policy` (https://spaw.co/docs/api/update-email-policy) — send only the keys you are changing, a null clears the ceiling or the floor, a body naming none of the three answers `422` — and restore the defaults with `DELETE /api/v1/email/policy` (https://spaw.co/docs/api/reset-email-policy). All three are free and answer the policy with `is_default` and `updated_at`. The Sending policy tab on the verification page edits the same three rules.

## From the dashboard

Everything the API does is also a tab on the verification page (https://spaw.co/verify/email), under the same billing rules:

| Tab | What it does |
| --- | --- |
| Single address | One lookup with the full field breakdown and raw response — the same call the API serves. |
| Validate a list | Paste or drop up to 500 addresses; results stream in with verdict filters, CSV export, and a "Monitor list" save. |
| Bulk runs | Upload a CSV with any columns or paste up to 100,000 addresses; runs process in the background, keep your columns in the results, count repeated addresses, and can be cancelled mid-run. |
| Monitors | Saved lists re-verified weekly or monthly, with an email alert when previously deliverable addresses go bad. |
| Domains | Saved domain lists re-checked daily, weekly or monthly, with an email alert when a domain goes dead, gets parked, lands on a blocklist, loses a mail policy or changes provider. |
| Suppressions | Your known-bad list: view, import, export, and remove entries. |
| History | Your last verifications with a 30-day quality breakdown, re-verify shortcuts, and CSV export. |
| Sending policy | Which signals block, how high the score may go and how sure a catch-all mailbox must be: the rules behind `ok_to_send` on every answer. |

## Bulk runs & webhooks

A bulk run queues up to 100,000 addresses and processes them in the background — upload or paste from the Bulk runs tab, or automate with `POST /api/v1/email/bulk` (https://spaw.co/docs/api/create-bulk-job). Poll the job for progress, then download the result CSV with every response field per row. The file leads with your own columns, then this verdict block in this order:

```
verified_email, deliverable, reason, risk_score, risk_level, risk_signals, normalized_email, is_alias, is_gibberish, looks_generated, local_part_pattern, first_name, last_name, disposable, is_relay, relay_service, role, role_type, free_provider, domain_category, mx_found, mx_implicit, mx_provider, mx_provider_type, mx_blocklisted, mx_network_type, has_spf, dmarc_policy, domain_registered_at, domain_age_days, domain_expires_at, parked, smtp_checked, smtp_checked_at, mailbox_exists, previously_invalid, previously_invalid_at, catch_all, smtp_reason, mailbox_confidence, widely_seen, did_you_mean, ok_to_send, blocked_by, credits_used, cache_hit, suppressed
```

`risk_signals` is flattened rather than dropped, as `signal:weight` pairs joined with semicolons. A finished run also carries a `summary` built from the same rows the file holds, so the two can never disagree: rows and duplicates, the verdict split, every reason seen with its row count, the risk split, the ten most frequent providers and domains, the domain categories, and how many rows carried each of the disposable, relay, role, catch-all, parked, blocklisted, previously invalid, widely seen and generated signals. The webhook body is unchanged.

Cancelling stops a queued job on the spot and a running one at its next checkpoint; rows already verified stay billed and downloadable. A run that failed part-way keeps the rows it had already written, and they stay downloadable too. If a webhook URL was set at creation, the finish — completed, failed, or cancelled — is POSTed to it, signed with the secret creation returned once: `X-Spaw-Signature-V2` carries `t={unix seconds},v2={hex}`, the HMAC-SHA256 of `v2:{t}:{raw body}`, and a receiver refuses anything whose `t` is more than five minutes from its own clock so a captured delivery cannot be replayed later. The original `X-Spaw-Signature`, over the body alone, is sent beside it until 1 March 2027. Transient delivery failures are retried. Verify it, and the exact request shape, in the API reference: https://spaw.co/docs/api/create-bulk-job.

A run that stopped because the credit balance ran out is resumed rather than uploaded again: top up, then press Resume on the run in the dashboard or call `POST /api/v1/email/bulk/{jobId}/resume` (https://spaw.co/docs/api/resume-bulk-job). The whole list runs again from the top, and the rows it already verified are repeats inside the seven-day window, so only the rows it never reached are charged. The job keeps its id, its webhook and its cumulative `credits_used`, while `processed` and the verdict counts restart at zero and are rebuilt as the rows come back. A run that stopped for any other reason, or that is already going again, answers `409 JOB_NOT_RESUMABLE`; one whose list has passed out of the 30-day retention window answers `409 JOB_INPUT_EXPIRED`, and an empty balance answers `402 INSUFFICIENT_CREDITS` rather than queueing a run that would stop again at once.

If a delivery fails — your endpoint was down for the half-minute the three attempts cover — ask for it again rather than falling back to polling: press **Send webhook again** on the run in the dashboard, or call `POST /api/v1/email/bulk/{jobId}/webhook/redeliver` (https://spaw.co/docs/api/redeliver-bulk-webhook). The body is byte-for-byte the body the first delivery carried, rebuilt from the run's own frozen row, so the two reconcile. The signature is new, and has to be: a receiver refuses anything whose `t` is more than five minutes from its own clock, so verify a redelivery exactly as you verify any other delivery. Nothing deduplicates it for you — a receiver that gets both the original and the redelivery sees `bulk_email_job.finished` twice, and `job.id` is the key to deduplicate on. A run created without a `webhook_url` answers `409 WEBHOOK_NOT_CONFIGURED` and one that has not settled answers `409 JOB_NOT_FINISHED`; a run whose last delivery succeeded is not refused, because a receiver can lose a message it has already acknowledged. The delivery is queued rather than made while you wait — one attempt takes half a minute against an endpoint that is down, which is the endpoint you are asking about — so the call answers `202` and the run reads `webhook_status: "pending"` until a worker has an answer, then `delivered` or `failed` with what your endpoint said in `webhook_detail`. The dashboard shows the same three states beside the run. It is free and capped at ten a minute counted per signed-in user: neither per key nor per account, so two keys held by one person share the one budget while two teammates have one each.

Creation is safe to retry: send an `Idempotency-Key` header and a repeat with the same key replays the job (status 200, header `Idempotent-Replayed: true`, the same webhook secret), while the same key with a different request answers `409 IDEMPOTENCY_KEY_REUSED`.

## Monitors

Lists decay: mailboxes are abandoned, domains lapse. Saving a finished list run with "Monitor list" re-verifies it weekly or monthly. The first run only sets the baseline; after that, any address that was deliverable and no longer is triggers an email naming the decayed addresses. Repeats of unchanged answers ride the 7-day cache and the suppression list, so a scheduled run costs far less than the first one. "Run now" re-verifies on demand.

Monitors are also available over the API: `GET`, `POST` and `DELETE /api/v1/email/monitors`, `GET /api/v1/email/monitors/{id}` for the last run's verdict per address, and `POST /api/v1/email/monitors/{id}/run` to re-verify now (https://spaw.co/docs/api/list-monitors). Same 500-address limit, weekly or monthly cadence and decay alerts as the dashboard.

The History tab's rows are available as `GET /api/v1/email/history` (https://spaw.co/docs/api/list-history), newest first, 100 per page, with filters for verdict, address, date range (`since`/`until`) and channel (`via`), for the retention window.


A monitor can post its runs as well as email about them. Set `webhook_url` (https) when you create it, or later with a `PATCH`, and the signing secret comes back once in that response and is never readable again. `webhook_events` decides which runs are posted: `changes`, the default, posts only when the run found something new — an address that has decayed — or stopped early, and `every_run` posts after every run, which also tells you the monitor is still running. The body is `{ "event": "email_monitor.run", "monitor": {…}, "summary": {…}, "changes": [ … ] }`, where `changes` names the addresses the alert email names, and the delivery is signed with `X-Spaw-Signature-V2`: `t={unix seconds},v2={hex}`, where the hex is the HMAC-SHA256 of `v2:{t}:{raw body}` keyed with the secret. Refuse anything whose `t` is more than five minutes from your own clock — that window is what stops a captured delivery being replayed at you later. The original `X-Spaw-Signature`, the HMAC of the body alone with no timestamp, is sent beside it until 1 March 2027 and still verifies; both recipes are at https://spaw.co/integrations/webhooks. Connection errors and 5xx answers are retried twice, three attempts in all; `webhook_status` and `webhook_detail` on the monitor say how the last delivery went. A URL that resolves onto a private network is refused at delivery, every time, because DNS can be repointed after a URL is saved.

A monitor is changed rather than replaced: `PATCH /api/v1/email/monitors/{id}` (https://spaw.co/docs/api/update-monitor) takes `name`, `emails`, `cadence` and the webhook fields, and applies only the ones you send. That matters because what the last run recorded per entry is the baseline every alert is compared against: deleting a monitor and creating it again throws that away, and the first run of the new one only sets a baseline — a full cycle of silence, which is the one thing a monitor is bought to prevent. Replacing the list keeps what was recorded about the addresses that stayed, deletes what was recorded about the ones you removed, and gives a new entry no baseline. Changing the cadence neither skips a run nor starts a second one: a run already scheduled sooner than the new interval still happens, and a longer interval applies from the one after it. A body naming no field it could change answers `422`.

A monitor that stops being run is deleted, and the identifiers and results it stored go with it. `prunes_after_days` on every monitor payload says how long that takes, and `meta.retention_days` on the list endpoint says the same. A monitor in active use is never pruned: each run advances `last_run_at`, so the window only closes on a list that has genuinely stopped being re-checked — at which point holding the details somebody gave you serves no purpose. Deleting a monitor yourself removes everything it held at once.

## Monitored domains

A domain's mail setup drifts too: a provider is switched, a DMARC policy is loosened, a registration lapses into a parking page, a mail host lands on a blocklist. A monitored domain list re-checks up to 500 domains daily, weekly or monthly through the same domain lookup the API sells (https://spaw.co/docs/api/domain-intelligence), and bills it the same way: one credit when the domain accepts mail, nothing when it is dead or invalid, and repeats inside the seven-day window free — which is why a daily cadence is allowed here and not on address lists. It is DNS and a registry record, no handshake, and it costs at most one credit per domain a week.

Every domain answers one of four statuses: `ok`, `flagged` (a flag fired), `dead` (no usable mail servers) or `invalid` (not a hostname). The flags, every one that fires: `parked`, `blocklisted`, `expiring` (the registration ends within 30 days), `no_spf`, `no_dmarc`, and five that compare with the previous run — `provider_changed`, `spf_changed`, `dmarc_changed`, `dkim_lost` and `mta_sts_lost`. Beside the flags each entry keeps the facts they were read from: the provider and its type, SPF and its all-qualifier, the DMARC policy, the DKIM selectors found, MTA-STS, parked, blocklisted and the expiry date.

The first run only sets the baseline. After that, an alert email goes out for a domain that was ok and no longer is, or that gained a flag it did not have, and the run's summary counts `total`, `ok`, `flagged`, `dead`, `invalid`, `changed`, `newly_flagged` and `credits_used`, with a `stopped_reason` when the run could not finish.

Over the API: `GET` and `POST /api/v1/email/domain-monitors` (https://spaw.co/docs/api/list-domain-monitors), `GET`, `PATCH` and `DELETE /api/v1/email/domain-monitors/{id}`, and `POST /api/v1/email/domain-monitors/{id}/run` to re-check now. The detail endpoint carries `results`, the last run per domain. Creation takes `name`, `domains`, `cadence` and the webhook fields; a webhook posts `{ "event": "domain_monitor.run", "monitor": {…}, "summary": {…}, "changes": [ … ] }` under the same `webhook_events` rule, signature and retry contract as the address monitors above, and the `PATCH` keeps the baseline of every domain that stays, exactly as it does for addresses. An unused list prunes itself after `prunes_after_days`. The Domains tab on the verification page manages the same lists.

## Suppression list

Every undeliverable verdict adds the address — in normalized form, so tagged and dotted variants count as one mailbox — to your account's suppression list. List, bulk, and monitor runs answer suppressed addresses from the stored verdict, free and marked `meta.suppressed: true`, instead of re-checking them.

Two escape hatches keep the list honest: a single-address re-verify always runs fresh, and a recovered verdict removes an entry a verdict added; and an entry a verdict or a bounce report added is re-verified instead of trusted once it is untouched for 90 days; an import or a complaint never goes stale. You can also import your email provider's bounce export — up to 10,000 addresses per request, from the Suppressions tab or `POST /api/v1/email/suppressions` (https://spaw.co/docs/api/add-suppressions) — so your known-bads are honored from day one without spending a credit. What you import, and what your own bounce and complaint reports add, is your decision rather than a verdict: no lookup clears it, not even one typed into your public form, so it leaves the list only when you remove it or, for a bounce your reports added, when you later report a delivery to that address. A complaint is the recipient's objection rather than a fact about the mailbox: no delivery, later bounce or passing of time takes it off. A complaint about an address you imported is recorded on your import, which stays an import. An address you import that a verdict already suppressed keeps that verdict and becomes yours the same way; the verdict is served for 90 days after the entry was last renewed, then the import answers `suppressed`, because nothing verifies an import again.

Finding one address again does not mean paging the list. The Suppressions tab has a search box, and `GET /api/v1/email/suppressions` (https://spaw.co/docs/api/list-suppressions) takes `value`, `source`, `reason`, `since`, `until` and `per_page`. A searched address is normalized before it is matched, so any spelling of a suppressed mailbox finds it. To drop addresses without looking up their ids, `DELETE /api/v1/email/suppressions` (https://spaw.co/docs/api/remove-suppressions) takes up to 1,000 of them in `values` and answers which were on the list and which were not.

## Form widget

The form helper verifies addresses as visitors type them into your signup form. Create a publishable key on the API keys page — name it and list the domains it may run from — then include the script and mark your input:

```html
<script
    src="https://spaw.co/spaw-form.js"
    data-key="pk_live_…" defer></script>
<input type="email" name="email" data-spaw-email>
```

Phone fields work the same way. Mark the input `data-spaw-phone` and tell the helper the country, either fixed with `data-spaw-country="GB"` or read from a country select on the same form with `data-spaw-country-field`; without either, the number must carry its calling code:

```html
<input type="tel" name="phone" data-spaw-phone
    data-spaw-country-field="country">
```

On blur it renders "Did you mean…?" corrections and can't-receive-mail notices for emails, and for phones the international format of a valid number, a note when the number is fictional, shared on SMS-receiving sites or virtual, and the reason when it is not valid (an unassigned block reads "not in service"). It never blocks a submit on its own. Every verdict also fires a `spaw:result` event whose detail is the full response plus `type` (email, phone or address) and the checked `input`, so your own policy is one listener away:

```js
addEventListener('spaw:result', (event) => {
    if (event.detail.disposable || event.detail.is_disposable) {
        // e.g. disable the submit button
    }
});
```

Lookups bill your account and responses carry no meta block, so visitors never see your balance. Prefer to keep even the publishable key off the page? Point `data-endpoint` at a small server-side proxy that forwards to the API with your secret key instead; phone inputs use `data-phone-endpoint` on the script tag, and that proxy receives `{ phone, country }` to forward to the phone endpoint.

Bots that script your form can spend your credits. To keep them out, create a Cloudflare Turnstile widget, store its site key and secret on the publishable key when you create it, and add the site key to the script tag:

```html
<script
    src="https://spaw.co/spaw-form.js"
    data-key="pk_live_…"
    data-turnstile-site-key="0x4AAA…" defer></script>
```

The helper loads Turnstile once, renders it next to the field only when Cloudflare asks for an interaction, and obtains a fresh token before every lookup. The browser endpoint refuses lookups without a confirmed token with `403 TURNSTILE_FAILED` before any credit is spent. If Turnstile cannot load, the helper skips the lookup rather than blocking the form. The attribute also works on the input itself, and a proxy endpoint simply receives the token as `turnstile_token`.

## Related

- API reference: https://spaw.co/docs/api — every endpoint, field, and error code
- Verify an email address: https://spaw.co/docs/api/verify-email
- Browser endpoint for publishable keys: https://spaw.co/docs/api/verify-email-public
- Browser endpoint for phone numbers: https://spaw.co/docs/api/validate-phone-public
- Verdict reasons: https://spaw.co/docs/reasons · Error codes: https://spaw.co/docs/errors
- Getting started: https://spaw.co/docs

Reference: https://spaw.co/docs/email-verification
