Spaw's risk_score is not a model. It is the sum of fixed weights over the boolean flags already in the response, capped at 100, and an undeliverable verdict is always exactly 100. You can recompute it from the fields next to it, branch on the flags instead, or change the weights in your own code if your policy differs. This page lists what the code does, weight by weight.
Which flags carry weight?
| Signal | Field it comes from | Points |
|---|---|---|
Disposable domain (disposable) |
disposable: true |
+80 |
Parked or for-sale domain that still accepts mail (parked_domain) |
parked: true |
+60 |
Known typo-squat domain (likely_typo) |
reason: "likely_typo" |
+60 |
Mail served from a blocklisted network (mx_blocklisted) |
mx_blocklisted: true |
+60 |
Domain has no MX record, only the address-record fallback (implicit_mx) |
mx_implicit: true |
+30 |
Role address (role) |
role: true |
+30 |
Catch-all server (catch_all) |
catch_all: true, not behind a security gateway |
+30 |
Domain registered less than 30 days ago (young_domain) |
domain_age_days under 30 |
+30 |
Mailbox that was dead and exists again (recycled_mailbox) |
previously_invalid: true |
+30 |
Mailbox full (mailbox_full) |
smtp_reason: "mailbox_full" |
+20 |
Gibberish username (gibberish) |
is_gibberish: true |
+20 |
Machine-made or placeholder username (generated) |
looks_generated: true |
+20 |
No-reply address, on top of the role row (no_reply) |
role_type: "no_reply" |
+20 |
Looked up by three or more other accounts in a day (widely_seen) |
widely_seen: true |
+20 |
Catch-all behind a security gateway, instead of the catch-all row (catch_all_gateway) |
catch_all: true and mx_provider_type: "security_gateway" |
+15 |
Domain registered less than 180 days ago (new_domain) |
domain_age_days under 180 |
+10 |
No SPF record on a business domain (no_spf) |
has_spf: false and free_provider: false |
+10 |
Mailbox not SMTP-verified (unverified) |
smtp_checked: false |
+10 |
Registration ending within 30 days (expiring_domain) |
domain_expires_at within 30 days |
+10 |
Plus-tag alias (alias) |
is_alias: true |
+5 |
Relay or forwarding service (relay) |
is_relay: true |
+5 |
The age rows are exclusive: a domain under 30 days gets 30, one between 30 and 179 days gets 10, older domains and unknown ages get nothing. The two catch-all rows are exclusive too: behind Proofpoint, Mimecast or Barracuda a catch-all is the gateway's design rather than a squat, so it weighs 15 instead of 30. The no-reply row is added on top of the role row, because a no-reply address is a role address: 50 in all. A relay address (Firefox Relay, SimpleLogin, Addy.io, DuckDuckGo Email Protection) is never disposable — it is a real person's real mailbox reached through a name they control — so it carries 5 for the withheld identity and never the 80. The SPF row only applies to business domains, because a Gmail user's risk has nothing to do with Google's SPF record. The sum is capped at 100, and risk_signals lists exactly the rows that fired, each with its weight and the dataset it was read from; an undeliverable verdict lists nothing and answers 100.
What are the levels?
risk_score |
risk_level |
|---|---|
| 60 and above | high |
| 30 to 59 | medium |
| 0 to 29 | low |
An undeliverable verdict skips the sum entirely and reports 100 and high.
Worked examples
| Address and situation | Flags | Score |
|---|---|---|
[email protected], mailbox confirmed, old domain with SPF |
none | 0, low |
[email protected], mailbox confirmed |
role 30 | 30, medium |
[email protected], catch-all server |
catch-all 30, alias 5 | 35, medium |
[email protected], registered 20 days ago, no SPF, greylisted |
young domain 30, no SPF 10, unverified 10 | 50, medium |
[email protected] |
typo-squat 60 | 60, high |
[email protected] |
disposable 80, unverified 10 | 90, high |
[email protected], catch-all server, no SPF |
gibberish 20, catch-all 30, no SPF 10 | 60, high |
[email protected], mailbox confirmed |
role 30, no-reply 20 | 50, medium |
[email protected], catch-all behind Mimecast, old domain with SPF |
catch-all behind a gateway 15 | 15, low |
[email protected], parked domain that still accepts mail |
parked domain 60 | 60, high |
[email protected], catch-all server |
catch-all 30, relay 5 | 35, medium |
[email protected], server says no such user |
undeliverable | 100, high |
Disposable domains are never probed, so the unverified weight always joins the disposable one; that is why the disposable test address answers 90 rather than 80.
What counts as gibberish?
The check is deliberately conservative, because a false positive here insults a real person. A username is only considered when it has six or more letters. Anything the name dictionary recognises, a first name, a surname, first.last, an initial plus a surname, or the two run together, is never gibberish. Beyond that, a username is flagged if it contains no vowel at all, with y counted as a vowel to protect names like Krzysztof, or if it contains a run of six consonants. Digit-only usernames are never flagged; numeric IDs at large Chinese providers are ordinary.
How does the verdict interact with the score?
The verdict comes first and is decided by precedence, not by the score. Undeliverable reasons win over everything: null MX, unresolvable MX, no mail server, an impossible username, a disabled mailbox, a missing mailbox, in that order. Among risky reasons the order is typo-squat, parked domain, disposable, blocklisted mail network, implicit MX, mailbox full, no-reply, role, recycled mailbox, catch-all, unverified. The score then adds up every flag that applies, so an address reported as role on a catch-all server carries both weights even though reason names only one.
curl https://spaw.co/api/v1/email \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]"}'
{
"success": true,
"data": {
"email": "[email protected]",
"deliverable": "deliverable",
"reason": null,
"risk_score": 0,
"risk_level": "low",
"is_alias": false,
"is_gibberish": false,
"disposable": false,
"role": false,
"catch_all": false,
"has_spf": true,
"free_provider": false,
"domain_age_days": 10402,
"smtp_checked": true,
"risk_signals": [],
"// 31 more fields": "see the endpoint reference"
},
"meta": { "credits_used": 1, "credits_remaining": 9, "cache_hit": false }
}
Every flag the score reads is in that response, which is what makes it recomputable.
How is mailbox_confidence different?
The risk score answers "how risky is sending here". mailbox_confidence answers a narrower question, "does this specific mailbox exist", and only when the handshake could not say: catch-all domains and unanswered probes. It is null for confirmed mailboxes and for undeliverable verdicts.
It starts at 50 and moves by fixed amounts.
| Signal | Adjustment |
|---|---|
| Delivery outcomes every account reported for the domain, five or more: bounce rate 50% or higher | −20 |
| Delivery outcomes every account reported for the domain, five or more: bounce rate 10% or lower | +15 |
| DMARC policy reject or quarantine | +15 |
| DMARC policy none | +5 |
| SPF record present | +10 |
| SPF record absent | −10 |
| Mail hosted at Google, Microsoft, Proofpoint, Mimecast, Barracuda, Zoho or Fastmail | +10 |
| Username recognised as a name | +15 |
| Username flagged as gibberish | −25 |
| Role address | +5 |
| Domain registered under 90 days ago | −15 |
| Domain registered two years ago or more | +10 |
| Plus-tag alias | −10 |
| Username follows the naming convention seen in the domain's confirmed deliveries (first.last, flast, …) | +15 |
| Username is name-shaped but breaks that convention | −10 |
The result is clamped to 0 to 100. The bounce-rate rows and the naming-convention rows read delivery outcomes reported through the API and the provider webhooks, pooled across accounts as counts per domain — never an address, and never a report typed into a dashboard.
A relay address (is_relay: true) is the exception. It stays at 50 and moves only on the evidence against it: the bounce rate of 50% or more (−20) and the plus-tag (−10). The relay's records describe the operator rather than the alias, and an alias's name, whether the relay generated it or its owner chose it, says nothing about whether it exists, so the other rows do not apply.
The score is not the decision
ok_to_send on every answer is. It comes from your account's sending policy, three optional rules applied after the verdict: the signals that block whenever they fire, a ceiling on the score, and a floor for a catch-all or unverified mailbox's confidence. An undeliverable verdict never passes, and blocked_by names the rule that stopped the rest: undeliverable, the blocking signal's own name, risk_score or mailbox_confidence. Until you set a policy the defaults apply — the disposable, typo-squat, parked-domain, blocklisted-network and no-reply signals block, so does any score over 60 — and GET, PUT and DELETE /api/v1/email/policy, or the Sending policy tab on the verification page, change them. Branch on ok_to_send when one decision is what you need, and keep reading the flags when it is not.
Every signal has a page
Each weight in the table is a signal with its own reference page — what it reads, the dataset behind it, the verdict reason it comes with and what to do about it — at /docs/signals/{signal}, and the index at /docs/signals lists all 21 in the published order: disposable, parked_domain, likely_typo, mx_blocklisted, implicit_mx, role, catch_all, young_domain, recycled_mailbox, mailbox_full, gibberish, generated, no_reply, widely_seen, catch_all_gateway, new_domain, no_spf, unverified, expiring_domain, alias and relay.
This page is about email. A phone answer adds up its own risk_score from a separate set of weights, and a name can weigh differently there: disposable is +80 on an email answer and +70 on a phone one. The phone weights are listed under /docs/signals#phone, each with its own page at /docs/signals/phone/{signal}, such as reported_undelivered, which fires when your own latest delivery report for the number was a failure.
How should you use the score?
Branch on the flags when you have a policy, and on the score when you need one threshold. A typical signup form rejects undeliverable, asks for another address on disposable, and accepts the rest. A typical campaign sends to scores under 30, holds 30 to 59 for a warm-up batch, and drops 60 and above. If your bounce data says the weights are wrong for your audience, recompute the score with your own weights from the same flags; nothing about the API assumes you use its number.
What to do next
- Read the verdict reasons to see which flag each reason sets and what it costs.
- Wire your bounce webhook into the feedback endpoint so the confidence score learns from your own sends.
- See how the catch-all case plays out in what a catch-all address is.
- Check the authentication rows against your own domain with SPF, DKIM and DMARC for developers.