Skip to content

API reference/email

Verify an email address

POST/api/v1/email

authentication
Secret API key as a bearer token
billing
1 credit for a fresh deliverable or risky verdict whose mailbox check was definite; an unverified answer (the mailbox check could not say, including a provider outage) is free, and so are invalid input, 7-day repeats and test addresses. An undeliverable verdict is free within your email fair use and bills 1 credit past it, with `meta.fair_use_billed` true.

Runs the full pipeline on one address and answers with a verdict, a machine-readable reason, an auditable 0–100 risk score with the signals behind it, and 46 named fields. The checks run in order: address extraction (display names, mailto: prefixes, spreadsheet quotes and invisible characters are stripped), RFC 5322 syntax, the shape of the username (naming pattern, name tokens, machine-made or placeholder look), live MX resolution including the null-MX and implicit-MX rules, the relay, disposable, role, free-provider and typo-squat lists, provider-specific username rules, the MX hosts against the threat blocklists and the parking nameservers, SPF and DMARC records, the domain's registration and expiry from RDAP, and finally a mailbox-level SMTP handshake that ends before any message is transmitted, read against the address's own not-found history and how many other accounts checked it today.

The mailbox probe runs only when the domain accepts mail and no free signal already settled the answer, so a disposable domain or a typo-squat never reaches the mail server. Confirmed mailbox answers are shared across customers for up to a week, which is why smtp_checked_at can predate the lookup. When mail servers refuse to say whether an inbox exists (greylisting, timeouts), the answer degrades honestly to a risky verdict with reason unverified, smtp_checked: false and mailbox_exists: null, plus a recomputable mailbox_confidence estimate; an unverified answer whose smtp_reason is provider_unavailable is free.

A single lookup never consults your suppression list, and its verdict clears only an entry a verdict added: an import, or an entry your own bounce and complaint reports added, stays until you remove it (a reported bounce also leaves when a delivery to that address is reported; a complaint only when you remove it). The batch, bulk and monitor paths do honor it.

Asking for a fresh answer

Repeats inside the seven-day window are free and served from the earlier answer. Pass refresh: true to discard that answer and the shared mailbox answer, run a real handshake again, and bill the lookup as fresh when it is answered; or pass 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 for the call. An uncertain (unverified) mailbox answer under 15 minutes old is the one exception: it is free, so neither control sets it aside, and the call answers it again. timeout caps how long the mailbox handshake may wait for this call; a handshake that runs out of time answers unverified with smtp_reason: timeout instead of holding the request.

Settling an unverified answer later

Pass callback_url and callback_secret and an unverified answer is re-checked after 5 and 20 minutes. The settled verdict is POSTed to the callback as { "event": "email.settled", "request_id", "settled", "attempt", "data", "meta" }, signed with callback_secret the way every webhook here is: X-Spaw-Signature-V2 carries t={unix seconds},v2={lowercase hex}, the HMAC-SHA256 of v2:{t}:{raw body}, with the older X-Spaw-Signature over the body alone beside it until 1 March 2027. The response to this request then carries meta.settling: true. An unverified answer is free and leaves nothing to repeat, so each re-check is billed like a fresh lookup: 1 credit when it confirms the mailbox, nothing while it still cannot say (settled is false when the last re-check still could not say), and an undeliverable answer counts against your email fair use. A re-check your balance cannot pay for is not run: the last callback then has settled false, data null, meta.credits_used 0 and meta.stopped_reason: "insufficient_credits".

Request body

field type description
emailrequiredstringThe address to verify. Wrappers people paste (a display name, a `mailto:` prefix, quotes, trailing punctuation) are stripped before checking. At most 254 characters.
callback_urlstring | nullAn 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. At most 2,048 characters.
callback_secretstring | nullRequired with `callback_url`. Signs the callback body exactly as the bulk webhook is signed — `X-Spaw-Signature-V2` carries `t={unix seconds},v2={lowercase hex}`, the HMAC-SHA256 of `v2:{t}:{raw body}`. 16 to 128 characters.
refreshboolean | nullDiscard the answer from the seven-day repeat window and the shared mailbox answer, run the handshake again, and bill as a fresh lookup when answered.
max_ageinteger | nullSeconds. Refresh only when the earlier answer is older than this; a younger answer is served free as usual.
timeoutinteger | nullSeconds the mailbox handshake may wait for this call. When it runs out the answer is `unverified` with `smtp_reason` `timeout`.
fieldsstring | nullA comma-separated list of the answer's own field names to return, in the answer's order. Unknown names are ignored, a blank list means everything, and `sources` comes back only when named. The meta block is never narrowed. At most 1,000 characters.

Example request

curl -X POST https://spaw.co/api/v1/email \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "[email protected]"
}'
const response = await fetch('https://spaw.co/api/v1/email', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer sk_live_…',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "email": "[email protected]"
  }),
});
const result = await response.json();
import requests

response = requests.post(
    'https://spaw.co/api/v1/email',
    headers={'Authorization': 'Bearer sk_live_…'},
    json={
        'email': '[email protected]'
    },
)
result = response.json()
$ch = curl_init('https://spaw.co/api/v1/email');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode([
        'email' => '[email protected]'
    ]),
]);
$result = json_decode(curl_exec($ch), true);
from spaw import Client

client = Client('sk_live_…')
result = client.verify_email('[email protected]')
import Spaw from 'spaw';

const spaw = new Spaw({ apiKey: 'sk_live_…' });
const result = await spaw.verifyEmail('[email protected]');
use Spaw\Client;

$spaw = new Client('sk_live_…');
$result = $spaw->verifyEmail('[email protected]');

Run it

free demo · no key needed

Running a request from this page needs JavaScript. The command above makes the same call from a terminal.

Responses

200The verdict and every field, whether or not the address is deliverable.

{
    "success": true,
    "data": {
        "email": "[email protected]",
        "normalized_email": "[email protected]",
        "is_alias": false,
        "is_gibberish": false,
        "looks_generated": false,
        "local_part_pattern": "first",
        "first_name": "mia",
        "last_name": null,
        "deliverable": "deliverable",
        "reason": null,
        "risk_score": 0,
        "risk_level": "low",
        "risk_signals": [],
        "syntax_valid": true,
        "domain": "acme.com",
        "domain_category": "other",
        "mx_found": true,
        "mx_implicit": false,
        "mx_provider": "google",
        "mx_provider_type": "mailbox_provider",
        "mx_blocklisted": false,
        "mx_network_type": "hosting",
        "has_spf": true,
        "dmarc_policy": "reject",
        "domain_registered_at": "1998-03-12",
        "domain_age_days": 10402,
        "domain_expires_at": "2027-03-12",
        "parked": false,
        "disposable": false,
        "is_relay": false,
        "relay_service": null,
        "role": false,
        "role_type": null,
        "free_provider": false,
        "smtp_checked": true,
        "smtp_checked_at": "2026-09-03T10:12:44+00:00",
        "mailbox_exists": true,
        "previously_invalid": false,
        "previously_invalid_at": null,
        "catch_all": false,
        "smtp_reason": null,
        "mailbox_confidence": null,
        "widely_seen": false,
        "did_you_mean": null,
        "ok_to_send": true,
        "blocked_by": null,
        "sources": [
            {
                "dataset": "rfc-5322-syntax",
                "version": "2026-09-03"
            },
            {
                "dataset": "live-dns",
                "version": "2026-09-03"
            },
            {
                "dataset": "disposable-domains",
                "version": "2026-09-01"
            },
            {
                "dataset": "mx-provider-patterns",
                "version": "2026-08-30"
            },
            {
                "dataset": "mailbox-smtp",
                "version": "2026-09-03"
            }
        ]
    },
    "meta": {
        "credits_used": 1,
        "credits_remaining": 9,
        "cache_hit": false,
        "refreshed": false,
        "suppressed": false,
        "fair_use_billed": false,
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}

401The key is missing, malformed, or revoked.

{
    "success": false,
    "error": {
        "code": "UNAUTHENTICATED",
        "message": "Provide a valid API key as a bearer token.",
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}

402The balance is empty. The lookup did not run.

{
    "success": false,
    "error": {
        "code": "INSUFFICIENT_CREDITS",
        "message": "Your credit balance is empty.",
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}

422The request body could not be validated; `error.errors` lists the fields.

{
    "success": false,
    "error": {
        "code": "VALIDATION_FAILED",
        "message": "The email field is required.",
        "errors": {
            "email": [
                "The email field is required."
            ]
        },
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}

429Over 5 requests per second for the key. Retry after the limit resets.

{
    "success": false,
    "error": {
        "code": "RATE_LIMITED",
        "message": "Too many requests. Retry after the limit resets.",
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}

Error codes

Failures answer { success: false, error: { code, message, request_id } }. Each code has its own page.

markdown version·openapi.json