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; undeliverable verdicts, invalid input, 7-day repeats and test addresses are free.

Runs the full pipeline on one address and answers with a verdict, a machine-readable reason, an auditable 0–100 risk score, and 27 named fields. The checks run in order: address extraction (display names, mailto: prefixes, spreadsheet quotes and invisible characters are stripped), RFC 5322 syntax, live MX resolution including the null-MX and implicit-MX rules, the disposable, role, free-provider and typo-squat lists, provider-specific username rules, SPF and DMARC records, the domain's registration age from RDAP, and finally a mailbox-level SMTP handshake that ends before any message is transmitted.

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 smtp_checked: false and mailbox_exists: null, plus a recomputable mailbox_confidence estimate.

A single lookup never consults your suppression list: an explicit re-verify is the only way an address earns its way off it. The batch, bulk and monitor paths do honor it.

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 the HMAC-SHA256 of the exact raw body in the X-Spaw-Signature header. The response to this request then carries meta.settling: true. Re-checks are repeats of a charged lookup, so they cost nothing; settled is false when the last re-check still could not say.

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 — `X-Spaw-Signature` is the HMAC-SHA256 of the exact raw body, like the bulk webhook. 16 to 128 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);

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,
        "deliverable": "deliverable",
        "reason": null,
        "risk_score": 0,
        "risk_level": "low",
        "syntax_valid": true,
        "domain": "acme.com",
        "mx_found": true,
        "mx_implicit": false,
        "mx_provider": "google",
        "has_spf": true,
        "dmarc_policy": "reject",
        "domain_registered_at": "1998-03-12",
        "domain_age_days": 10402,
        "disposable": false,
        "role": false,
        "free_provider": false,
        "smtp_checked": true,
        "smtp_checked_at": "2026-09-03T10:12:44+00:00",
        "mailbox_exists": true,
        "catch_all": false,
        "smtp_reason": null,
        "mailbox_confidence": null,
        "did_you_mean": 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,
        "suppressed": 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