# How Spaw computes the risk score

The risk score is a sum of published weights over documented flags, capped at 100, with undeliverable fixed at 100. Every weight, thresholds, examples.

Date: 2026-09-03

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: true` | +80 |
| Known typo-squat domain | `reason: "likely_typo"` | +60 |
| Domain has no MX record, only the address-record fallback | `mx_implicit: true` | +30 |
| Role address | `role: true` | +30 |
| Catch-all server | `catch_all: true` | +30 |
| Domain registered less than 30 days ago | `domain_age_days` under 30 | +30 |
| Mailbox full | `smtp_reason: "mailbox_full"` | +20 |
| Gibberish username | `is_gibberish: true` | +20 |
| Domain registered less than 180 days ago | `domain_age_days` under 180 | +10 |
| No SPF record on a business domain | `has_spf: false` and `free_provider: false` | +10 |
| Mailbox not SMTP-verified | `smtp_checked: false` | +10 |
| Plus-tag alias | `is_alias: 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 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.

## 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 |
| --- | --- | --- |
| `mia@acme.com`, mailbox confirmed, old domain with SPF | none | 0, low |
| `support@acme.com`, mailbox confirmed | role 30 | 30, medium |
| `mia+news@acme.com`, catch-all server | catch-all 30, alias 5 | 35, medium |
| `mia@newstartup.example`, registered 20 days ago, no SPF, greylisted | young domain 30, no SPF 10, unverified 10 | 50, medium |
| `mia@gmail.con` | typo-squat 60 | 60, high |
| `x9@mailinator.com` | disposable 80, unverified 10 | 90, high |
| `qzxvbn@acme.com`, catch-all server, no SPF | gibberish 20, catch-all 30, no SPF 10 | 60, high |
| `nobody@acme.com`, 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, disposable, implicit MX, mailbox full, role, catch-all. 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.

```bash
curl https://spaw.co/api/v1/email \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"email": "mia@acme.com"}'
```

```json
{
  "success": true,
  "data": {
    "email": "mia@acme.com",
    "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,
    "// 13 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 |
| --- | --- |
| Your own delivery outcomes for the domain, five or more reported: bounce rate 50% or higher | −20 |
| Your own delivery outcomes for the domain, five or more reported: 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 |

The result is clamped to 0 to 100. Only your own account's delivery reports are used; nobody else's outcomes influence your answers.

## 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](/docs/reasons) to see which flag each reason sets and what it costs.
- Wire your bounce webhook into the [feedback endpoint](/docs/api/report-delivery-feedback) so the confidence score learns from your own sends.
- See how the catch-all case plays out in [what a catch-all address is](/guides/catch-all-email-addresses).
- Check the authentication rows against your own domain with [SPF, DKIM and DMARC for developers](/guides/spf-dkim-dmarc-for-developers).

Reference: https://spaw.co/guides/how-spaw-computes-the-risk-score
