---
title: "Check a domain's email authentication"
method: POST
path: "/v1/deliverability/check"
tags: ["Deliverability"]
---

# Check a domain's email authentication

`POST /v1/deliverability/check`

## Request

- Base URL: `https://api.kawaa.com`
- URL: `https://api.kawaa.com/v1/deliverability/check`
- Auth: one of:
  - API key in header `X-Api-Key`
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.kawaa.com/oauth/authorize`, token URL `https://api.kawaa.com/oauth/token`, scopes: `deliverability:write`)

Ten credits for a fresh check; a check of the same domain within 24 hours is served from cache and costs nothing.

**Send `Idempotency-Key`.** The DNS work can run for up to 120 seconds while the API stops waiting at about 29, so a client that times out cannot tell whether it paid. With a key, the identical request replays the original result for 30 days and charges nothing; without one, the retry is a new 10-credit check. A key already in flight answers 409, and a receipt that cannot be read answers 503. In both cases retry the identical request rather than starting a fresh one.

**A lookup that fails is not a missing record.** Only NXDOMAIN or NODATA counts as "no record". An SPF, DKIM, DMARC, MX or PTR lookup that fails (SERVFAIL, REFUSED, a timeout) is named in `not_measured`, shown as `not_measured` in `authentication`, and left out of `score` rather than scored as missing.

**No grade without SPF, DKIM and DMARC.** When any of those three is not measured, `grade` and `spam_assessment.spam_likelihood` are null and `score` is a partial figure over what did answer: a letter over the rest would grade records nobody read. A check where only MX or reverse DNS failed is still graded, over the rest. Either way the check is charged, because it measured something; it is refunded (503) only when every lookup timed out or nothing at all could be scored.

## Headers

- `Idempotency-Key` string

## Request body

- object: Closed: only `domain`, `sending_ip` and `refresh` are allowed, and anything else is refused with 400 before the cache lookup, the DNS work or any credit movement, so a misspelling like `refesh` costs nothing but also achieves nothing.
  - `domain` string, required: A bare registrable domain name. **Normalized before it is validated**, which is why no `format: hostname` or `pattern` is declared here: the API trims, lower-cases, punycodes an internationalized domain and then strips ONE leading `www.`, in that order, because IDNA maps the Unicode label separators (`。`, `．`, `｡`) to `.`, so `www。münchen.de` only grows its ASCII dot during the fold. Only then is the format checked. So `" WWW.Example.COM "` and `münchen.de` are both accepted; the latter is checked as `xn--mnchen-3ya.de`, the same fold `/v1/verify` applies to the domain half of an address. A scheme, a path or a port is NOT stripped and is a `400 Invalid domain format`; send the host on its own. At least two labels are required, each label 1–63 characters, and the TLD either letters only and at least two (`com`, `de`) or a decodable `xn--` A-label of an internationalized one (`пример.рф` is checked as `xn--e1afmkfd.xn--p1ai`). The A-label must decode AND be the canonical spelling of what it decodes to, not merely carry the prefix: `example.xn--abc` decodes to nothing, and `example.xn---7a` decodes to `¡` whose one canonical form is `xn--7a`. Neither can name a TLD any root zone holds, so both are refused rather than charged for. A special-use name the public DNS never answers for, such as `.example`, `.test`, `.invalid`, `.localhost`, `.local`, `.onion`, `.alt`, `.internal`, `.corp`, `.home`, `.mail`, or `home.arpa` and the other special-use `.arpa` names, is also a `400` before any charge: every lookup could only fail.
  - `sending_ip` string: The IPv4 address mail is actually sent from, when that is not the domain's own mail host. Leading and trailing whitespace is trimmed before the IPv4 rule is applied, so the raw request string deliberately has no `format` or `pattern` constraint. **IPv4 only**: after trimming, an IPv6 literal or a hostname is a `400` before the check runs, not a fallback. **Only the blocklist lookup falls back to an MX-derived address.** Reverse DNS is looked up for the normalized `sending_ip` alone and reports `status: "skipped"` when there is none, so without one reverse DNS is not measured at all and `server.reverse_dns` is null. **It must be a public address: the one receiving mail servers actually see.** Private (`10/8`, `172.16/12`, `192.168/16`), loopback (`127/8`), link-local (`169.254/16`), shared/CGNAT (`100.64/10`), documentation (`192.0.2/24`, `198.51.100/24`, `203.0.113/24`), `0/8`, benchmarking (`198.18/15`), the deprecated 6to4 relay anycast block (`192.88.99/24`), multicast (`224/4`) and reserved (`240/4`) addresses are a `400` before the cache lookup or any charge: no public reverse-DNS or blocklist zone answers for them, and the resolver this check runs behind answers reverse DNS for private space itself, which once graded a private network name as a PTR pass. Documentation ranges included: an address copied from an example is refused, not checked.
  - `refresh` boolean: Ignore the 24-hour cache and measure again. Costs the full 10 credits, like any other fresh check. Without it a customer who has just fixed their SPF or DMARC record gets the identical old grade back and reasonably concludes the fix did not work. There is no other way to ask for current evidence.

## Response `200`

The check. `cached` and `idempotent_replay` say whether it was measured now or reused; `credits_used` is what was actually charged.

- object
  - `success` unknown, required
  - `data` DeliverabilityCheck, required
    - `domain` string, required
    - `sendingIp` string, nullable: The `sending_ip` this check ran against, or null when none was supplied. Part of the raw scoring result, published alongside the snake_case projection in its original camelCase spelling; kept because existing integrations read it.
    - `score` integer, required: 0-100, over the mechanisms that were measured. One whose lookup failed is left out rather than scored as missing, so read `not_measured` before treating this as the whole picture. When `grade` is null, SPF, DKIM or DMARC was not measured and this is a partial score over what did answer: report it as partial, not as the domain's result.
    - `grade` string, nullable, required: `score` as a letter: A (90-100), B (80-89), C (70-79), D (60-69) or F. **null when SPF, DKIM or DMARC could not be looked up** (named in `not_measured`): a letter over the rest would grade records nobody read, and it used to, so a domain whose DKIM and DMARC lookups both failed could read A. A stored check from before this rule that carries such a letter is answered with null too, from its own `not_measured`. A failed MX or reverse-DNS lookup still leaves a grade, over the rest; see `not_measured`.
    - `checkedAt` string, date-time: When the check ran, as recorded in the raw scoring result. Prefer `checked_at`: on a cached or replayed result the stored row's `checked_at` is authoritative, and an older stored result may carry no `checkedAt` at all.
    - `durationMs` integer: How long the DNS work took, in milliseconds, for the run that produced this result. A cached or replayed answer repeats the original run's figure; it is not the latency of this request.
    - `checks` object: The raw per-mechanism scoring detail. One entry per mechanism REPORTED, and they do NOT share a shape: `dkim` carries the selectors it found, `mx` the records, `blacklists` the zones and the listings, and `mx` and `ptr` carry no `issues` array at all. Read the named property, not a common map value. **Optional, and absent on an older stored result.** A stored check without it is served as it is, with nothing made up in its place, so a check stored before the current shape answers 200 with the projected fields and no `checks`. Read `authentication`, `dns`, `reputation` and `server`, which the projection always produces, and treat this as the raw detail when it happens to be there. **BIMI and MTA-STS are checked, and they are unscored.** Both initialise `maxScore: 0`, and the score counts a mechanism only when `maxScore > 0`, so neither can move `score` or `grade` in either direction. They also have no entry in this object. A missing MTA-STS raises nothing at all; a missing BIMI produces a recommendation only when DMARC is already `reject`.
      - `spf` object: SPF: whether a record exists, parses, and is not over the lookup limit.
        - `status` 'pass' | 'weak' | 'missing' | 'invalid' | 'error' | 'found' | 'listed' | 'skipped', required: `missing` is an answer: the name has no such record (NXDOMAIN or NODATA). `error` means the lookup itself failed (SERVFAIL, REFUSED, a timeout), so nothing is known about the record: the mechanism is not measured, has `maxScore` 0, is named in `not_measured` and is left out of the score; when it is SPF, DKIM or DMARC the check has no grade. `found`, `listed` and `skipped` are used by some mechanisms only.
        - `score` number, required: What this mechanism contributed to the total, in raw points out of `maxScore`. The projected sections restate the same measurement as a percentage: `authentication.<mechanism>.score`, `dns.mx_score` and `reputation.score` are `round(score / maxScore × 100)`, so `checks.dkim` at 11 of 15 appears as `authentication.dkim.score: 73`. Two scales, one measurement.
        - `maxScore` number, required: The most it could have contributed. A mechanism whose lookup failed (status `error`, named in `not_measured`) has `maxScore` 0 and is left out of the grade rather than scored as missing, the way an unanswered blocklist section is. For SPF, DKIM or DMARC there is then no grade at all (`grade: null`). Until 2026-10-01 a failed SPF, DKIM, DMARC, MX or PTR lookup scored 0 against its full weight, so a DKIM SERVFAIL took a perfect domain from 100 (A) to 81 (B).
        - `record` string, nullable: The SPF record as published, or null when there is none.
        - `issues` object[]
          - `severity` 'error' | 'warning' | 'info': How serious the finding is. `error` is what a missing SPF, DMARC, MX or PTR record produces, the common case on a paid check. A lookup that failed is a `warning` saying the mechanism was not measured, never an `error`.
          - `message` string
      - `dkim` object: DKIM: which selectors published a key.
        - `status` 'pass' | 'weak' | 'missing' | 'invalid' | 'error' | 'found' | 'listed' | 'skipped', required: `missing` is an answer: the name has no such record (NXDOMAIN or NODATA). `error` means the lookup itself failed (SERVFAIL, REFUSED, a timeout), so nothing is known about the record: the mechanism is not measured, has `maxScore` 0, is named in `not_measured` and is left out of the score; when it is SPF, DKIM or DMARC the check has no grade. `found`, `listed` and `skipped` are used by some mechanisms only.
        - `score` number, required: What this mechanism contributed to the total, in raw points out of `maxScore`. The projected sections restate the same measurement as a percentage: `authentication.<mechanism>.score`, `dns.mx_score` and `reputation.score` are `round(score / maxScore × 100)`, so `checks.dkim` at 11 of 15 appears as `authentication.dkim.score: 73`. Two scales, one measurement.
        - `maxScore` number, required: The most it could have contributed. A mechanism whose lookup failed (status `error`, named in `not_measured`) has `maxScore` 0 and is left out of the grade rather than scored as missing, the way an unanswered blocklist section is. For SPF, DKIM or DMARC there is then no grade at all (`grade: null`). Until 2026-10-01 a failed SPF, DKIM, DMARC, MX or PTR lookup scored 0 against its full weight, so a DKIM SERVFAIL took a perfect domain from 100 (A) to 81 (B).
        - `selectors` string[]: The selectors a key was found under. Empty means none of the selectors tried resolved, not that DKIM is unused, because Kawaa can only probe the common ones. With `status: "error"` no key was found and some selectors could not be looked up, so DKIM was not measured.
        - `issues` object[]
          - `severity` 'error' | 'warning' | 'info': How serious the finding is. `error` is what a missing SPF, DMARC, MX or PTR record produces, the common case on a paid check. A lookup that failed is a `warning` saying the mechanism was not measured, never an `error`.
          - `message` string
      - `dmarc` object: DMARC: the published policy and record.
        - `status` 'pass' | 'weak' | 'missing' | 'invalid' | 'error' | 'found' | 'listed' | 'skipped', required: `missing` is an answer: the name has no such record (NXDOMAIN or NODATA). `error` means the lookup itself failed (SERVFAIL, REFUSED, a timeout), so nothing is known about the record: the mechanism is not measured, has `maxScore` 0, is named in `not_measured` and is left out of the score; when it is SPF, DKIM or DMARC the check has no grade. `found`, `listed` and `skipped` are used by some mechanisms only.
        - `score` number, required: What this mechanism contributed to the total, in raw points out of `maxScore`. The projected sections restate the same measurement as a percentage: `authentication.<mechanism>.score`, `dns.mx_score` and `reputation.score` are `round(score / maxScore × 100)`, so `checks.dkim` at 11 of 15 appears as `authentication.dkim.score: 73`. Two scales, one measurement.
        - `maxScore` number, required: The most it could have contributed. A mechanism whose lookup failed (status `error`, named in `not_measured`) has `maxScore` 0 and is left out of the grade rather than scored as missing, the way an unanswered blocklist section is. For SPF, DKIM or DMARC there is then no grade at all (`grade: null`). Until 2026-10-01 a failed SPF, DKIM, DMARC, MX or PTR lookup scored 0 against its full weight, so a DKIM SERVFAIL took a perfect domain from 100 (A) to 81 (B).
        - `record` string, nullable
        - `policy` string, nullable: The `p=` value: none, quarantine or reject.
        - `issues` object[]
          - `severity` 'error' | 'warning' | 'info': How serious the finding is. `error` is what a missing SPF, DMARC, MX or PTR record produces, the common case on a paid check. A lookup that failed is a `warning` saying the mechanism was not measured, never an `error`.
          - `message` string
      - `mx` object: MX: whether the domain can receive mail at all. No `issues` array on this one.
        - `status` 'pass' | 'weak' | 'missing' | 'invalid' | 'error' | 'found' | 'listed' | 'skipped', required: `missing` is an answer: the name has no such record (NXDOMAIN or NODATA). `error` means the lookup itself failed (SERVFAIL, REFUSED, a timeout), so nothing is known about the record: the mechanism is not measured, has `maxScore` 0, is named in `not_measured` and is left out of the score; when it is SPF, DKIM or DMARC the check has no grade. `found`, `listed` and `skipped` are used by some mechanisms only.
        - `score` number, required: What this mechanism contributed to the total, in raw points out of `maxScore`. The projected sections restate the same measurement as a percentage: `authentication.<mechanism>.score`, `dns.mx_score` and `reputation.score` are `round(score / maxScore × 100)`, so `checks.dkim` at 11 of 15 appears as `authentication.dkim.score: 73`. Two scales, one measurement.
        - `maxScore` number, required: The most it could have contributed. A mechanism whose lookup failed (status `error`, named in `not_measured`) has `maxScore` 0 and is left out of the grade rather than scored as missing, the way an unanswered blocklist section is. For SPF, DKIM or DMARC there is then no grade at all (`grade: null`). Until 2026-10-01 a failed SPF, DKIM, DMARC, MX or PTR lookup scored 0 against its full weight, so a DKIM SERVFAIL took a perfect domain from 100 (A) to 81 (B).
        - `records` object[]: The MX hosts, in the order DNS returned them.
          - `priority` integer, required
          - `host` string, required
      - `ptr` object: PTR: reverse DNS for `sending_ip`. Only meaningful when `sending_ip` was sent. No `issues` array on this one.
        - `status` 'pass' | 'weak' | 'missing' | 'invalid' | 'error' | 'found' | 'listed' | 'skipped', required: `missing` is an answer: the name has no such record (NXDOMAIN or NODATA). `error` means the lookup itself failed (SERVFAIL, REFUSED, a timeout), so nothing is known about the record: the mechanism is not measured, has `maxScore` 0, is named in `not_measured` and is left out of the score; when it is SPF, DKIM or DMARC the check has no grade. `found`, `listed` and `skipped` are used by some mechanisms only.
        - `score` number, required: What this mechanism contributed to the total, in raw points out of `maxScore`. The projected sections restate the same measurement as a percentage: `authentication.<mechanism>.score`, `dns.mx_score` and `reputation.score` are `round(score / maxScore × 100)`, so `checks.dkim` at 11 of 15 appears as `authentication.dkim.score: 73`. Two scales, one measurement.
        - `maxScore` number, required: The most it could have contributed. A mechanism whose lookup failed (status `error`, named in `not_measured`) has `maxScore` 0 and is left out of the grade rather than scored as missing, the way an unanswered blocklist section is. For SPF, DKIM or DMARC there is then no grade at all (`grade: null`). Until 2026-10-01 a failed SPF, DKIM, DMARC, MX or PTR lookup scored 0 against its full weight, so a DKIM SERVFAIL took a perfect domain from 100 (A) to 81 (B).
        - `record` string, nullable: The reverse-DNS name for the sending IP, when one was given. Null when no `sending_ip` was supplied or the lookup found nothing.
      - `blacklists` object: Public blocklists. Read `checked` and `inconclusive` before treating an empty `listed` as a clean bill of health.
        - `status` 'pass' | 'weak' | 'missing' | 'invalid' | 'error' | 'found' | 'listed' | 'skipped', required
        - `score` number, required: What this mechanism contributed to the total, in raw points out of `maxScore`. The projected sections restate the same measurement as a percentage: `authentication.<mechanism>.score`, `dns.mx_score` and `reputation.score` are `round(score / maxScore × 100)`, so `checks.dkim` at 11 of 15 appears as `authentication.dkim.score: 73`. Two scales, one measurement.
        - `maxScore` number, required: The most it could have contributed. A mechanism whose lookup failed (status `error`, named in `not_measured`) has `maxScore` 0 and is left out of the grade rather than scored as missing, the way an unanswered blocklist section is. For SPF, DKIM or DMARC there is then no grade at all (`grade: null`). Until 2026-10-01 a failed SPF, DKIM, DMARC, MX or PTR lookup scored 0 against its full weight, so a DKIM SERVFAIL took a perfect domain from 100 (A) to 81 (B).
        - `listed` object[]: The zones that answered "listed". Empty is the good case.
          - `name` string, required
          - `zone` string, required
          - `severity` 'critical' | 'high' | 'medium' | 'low'
        - `checked` integer: Zones that gave a definitive answer either way.
        - `inconclusive` integer: Zones that were asked and answered nothing usable: a timeout, a refusal, a SERVFAIL. NOT a pass: an unanswered zone is not evidence of a clean record, and it contributes neither a pass nor its share of `maxScore`.
        - `skipped` integer: Zones that were never asked, e.g. IP zones when no `sending_ip` was given.
        - `total` integer: Zones in the list. `checked + inconclusive + skipped`.
        - `issues` object[]
          - `severity` 'error' | 'warning' | 'info': How serious the finding is. `error` is what a missing SPF, DMARC, MX or PTR record produces, the common case on a paid check. A lookup that failed is a `warning` saying the mechanism was not measured, never an `error`.
          - `message` string
    - `check_id` string: The saved check. Fetch it again with `GET /v1/deliverability/checks/{id}`: free, and the only way back to a result whose response was lost.
    - `credits_used` number: What this call actually charged: 10 for a fresh check, 0 for a cached one or an idempotent replay. Read this rather than the price list.
    - `credits_remaining` number: Balance after the charge. Omitted when it could not be read; its absence is not a zero balance.
    - `cached` boolean: Present and true when this reused a check from the last 24 hours, which costs nothing. Send `refresh: true` to measure again.
    - `idempotent_replay` boolean: Present and true when this returned an earlier call's result under the same `Idempotency-Key`, and charged nothing. Distinct from `cached`: a replay can return a FRESH check that was already paid for.
    - `issues` object[]: Every finding, across every mechanism, severity-sorted. This is the list to render: the per-mechanism `issues` arrays do not carry MX, PTR or blocklist findings at all, so rebuilding this from `checks` loses exactly the problems that stop mail being delivered.
      - `check` string: Which mechanism produced it.
      - `severity` 'error' | 'warning' | 'info', required
      - `message` string, required
    - `recommendations` object[], required: What to change, in the order worth doing it. The remediation half of the 10 credits: a score with no recommendations is a grade without an answer. Empty on a domain with nothing to fix.
      - `priority` 'high' | 'medium' | 'low', required: Do the `high` ones first: they are the missing records that cause mail to be rejected outright, rather than the weak ones that cost reputation slowly.
      - `category` string, required: Which part of the setup this is about, e.g. `Authentication`, `Reputation`, `Infrastructure`.
      - `issue` string, required: What is wrong, in one line.
      - `fix` string, required: What to change, concretely: usually the record to publish and roughly what to put in it.
    - `not_measured` object: The scored mechanisms whose DNS lookup failed (SERVFAIL, REFUSED, a timeout), keyed by `spf`, `dkim`, `dmarc`, `mx` or `ptr`, each with a sentence saying why. Present only when at least one did; absent when every lookup answered, and on checks stored before 2026-10-01. A mechanism named here is unknown, not missing: its raw `checks.<mechanism>.status` is `error` with `maxScore` 0, its projected status is `not_measured`, it is left out of `score`, and it produces no recommendation. When it is `spf`, `dkim` or `dmarc`, `grade` and `spam_assessment.spam_likelihood` are null. The check is still charged, like any check that got real answers, with or without a grade; it is refunded (503) only when every lookup timed out or nothing at all could be scored. The 24-hour cache serves the stored result, so send `refresh: true` to measure again.
    - `checked_at` string, date-time, required: When the check ran. Falls back to the Unix epoch on an older cached row that stored none. A timestamp of `1970-01-01` means "not recorded", not "checked in 1970".
    - `dns` object, required: The mail exchangers, as the projection reports them.
      - `mx_records` object[], required
        - `priority` integer, required
        - `host` string, required
        - `ip` string, nullable, required: Null unless the host was resolved during the check; usually null.
      - `has_valid_mx` boolean, required: True only when the MX check passed AND at least one record came back. Also false when the MX lookup failed; `not_measured.mx` then says it was not measured rather than absent.
      - `mx_score` number, required: The MX contribution, normalized to 100. 0 when the MX lookup failed, which `not_measured.mx` names; read it as unknown, not as no MX.
    - `authentication` object, required: SPF, DKIM and DMARC as three comparable results. This is the half of the answer a sender acts on; `recommendations` says what to do about it.
      - `spf` object, required
        - `status` 'pass' | 'fail' | 'missing' | 'not_measured', required: The projection's status: `pass`, `fail`, `missing` or `not_measured`. `missing` means no record was found and is not the same as `fail`, which means one was found and did not hold up. `not_measured` means the lookup failed (SERVFAIL, REFUSED, a timeout), so nothing is known about the record; it is named in `not_measured` with the reason, left out of `score`, and the check has no `grade`. Added 2026-10-01: before then such a lookup read `missing`. A stored check from before then never carries it.
        - `record` string, nullable, required: The published record, or null when none was found.
        - `score` number, required: This mechanism's contribution, already normalized to 100: `round(checks.spf.score / checks.spf.maxScore × 100)`, 0 when nothing could be scored, including when it was `not_measured`. `checks.spf.score` is the same measurement in raw points (for example 11 of 15 there is 73 here); the two are not meant to agree numerically.
        - `issues` string[], required: What is wrong with this record, in sentences. Empty when nothing is.
      - `dkim` object, required
        - `status` 'pass' | 'fail' | 'missing' | 'not_measured', required: The projection's status: `pass`, `fail`, `missing` or `not_measured`. `missing` means no record was found and is not the same as `fail`, which means one was found and did not hold up. `not_measured` means the lookup failed (SERVFAIL, REFUSED, a timeout), so nothing is known about the record; it is named in `not_measured` with the reason, left out of `score`, and the check has no `grade`. Added 2026-10-01: before then such a lookup read `missing`. A stored check from before then never carries it.
        - `selectors_found` string[], required: Selectors that answered. Empty is the ordinary shape for a domain with no DKIM: the check probes a fixed list and cannot enumerate selectors.
        - `score` number, required: This mechanism's contribution, already normalized to 100: `round(checks.dkim.score / checks.dkim.maxScore × 100)`, 0 when nothing could be scored, including when it was `not_measured`. `checks.dkim.score` is the same measurement in raw points (for example 11 of 15 there is 73 here); the two are not meant to agree numerically.
        - `issues` string[], required: What is wrong with this record, in sentences. Empty when nothing is.
      - `dmarc` object, required
        - `status` 'pass' | 'fail' | 'missing' | 'not_measured', required: The projection's status: `pass`, `fail`, `missing` or `not_measured`. `missing` means no record was found and is not the same as `fail`, which means one was found and did not hold up. `not_measured` means the lookup failed (SERVFAIL, REFUSED, a timeout), so nothing is known about the record; it is named in `not_measured` with the reason, left out of `score`, and the check has no `grade`. Added 2026-10-01: before then such a lookup read `missing`. A stored check from before then never carries it.
        - `record` string, nullable, required
        - `policy` string, nullable, required: The `p=` value: `none`, `quarantine` or `reject`. `none` is published-but-not-enforcing, which scores worse than `quarantine` and better than nothing at all.
        - `score` number, required: This mechanism's contribution, already normalized to 100: `round(checks.dmarc.score / checks.dmarc.maxScore × 100)`, 0 when nothing could be scored, including when it was `not_measured`. `checks.dmarc.score` is the same measurement in raw points (for example 11 of 15 there is 73 here); the two are not meant to agree numerically.
        - `issues` string[], required: What is wrong with this record, in sentences. Empty when nothing is.
    - `reputation` object, required: Blacklist standing. The counts are separate on purpose: a zone that did not answer is not a zone that cleared you.
      - `score` number, nullable, required: **Null when fewer than half of `blacklists_total` gave a verdict and none listed the domain** (none at all, or, say, only the domain zones because no sending IP is known): there is no verdict to score, a 100 from the few that answered would read as a clean record, and a zero would read as "listed everywhere". A listing is scored whatever the coverage.
      - `blacklists_checked` integer, required: Zones that returned a usable answer.
      - `blacklists_inconclusive` integer, required: Zones that answered with something that could not be read as listed or not listed. **They are removed from the grade rather than scored against it**: both the section score and its maximum are scaled by the proportion of zones consulted, so an unreadable zone neither penalises the domain nor silently counts as clean; the reputation section simply carries less weight. That is why `score` here can be null when nothing answered at all.
      - `blacklists_skipped` integer, required: Zones not queried on this run.
      - `blacklists_total` integer, required: Zones in the list. `checked + inconclusive + skipped` should account for it.
      - `blacklists_listed` integer, required: Zones that reported the domain or its IP as listed.
      - `listed_on` object[], required: Which ones, and how much each matters. Empty when `blacklists_listed` is 0.
        - `name` string, required
        - `severity` 'critical' | 'high' | 'medium' | 'low', required: How much a listing there costs, worst first.
    - `server` object, required: What a probe of the receiving server would have found. **Four of these five are always null**: `accepts_mail`, `response_time_ms`, `supports_tls` and `tls_version` are hard-coded null in the projection: no SMTP banner, TLS or acceptance probe runs on this route, with or without a `sending_ip`. Only `reverse_dns` is ever populated, from the PTR check, and only when a `sending_ip` was supplied. Null here means "not measured", never "no". The fields exist because the shape is shared with a probe that may be added later; today they are placeholders and must not be reported as evidence about the server.
      - `accepts_mail` boolean, nullable, required: Always null today: no acceptance probe runs. Not "the server refuses mail".
      - `response_time_ms` number, nullable, required: Always null today: nothing is timed.
      - `supports_tls` boolean, nullable, required: Always null today. Not "no TLS": nothing connected to find out.
      - `tls_version` string, nullable, required: Always null today, for the same reason as `supports_tls`.
      - `reverse_dns` string, nullable, required: The PTR record for the `sending_ip`, and the only field here that is ever populated. Null when no `sending_ip` was supplied, because reverse DNS is looked up for that address alone and is skipped without one; it never resolves an address from the MX records.
    - `spam_assessment` object, required: The same evidence again, expressed as how likely mail from this domain is to be filtered.
      - `score` number, required: How likely mail from this domain is to be filtered, 0-100, where **higher is worse**. It is the INVERSE of the top-level `score`: `100 - score`, clamped. A domain scoring 90 for deliverability carries a spam score of 10. Do not compare it against the top-level number or apply the same thresholds to it.
      - `spam_likelihood` 'low' | 'medium' | 'high' | 'null', nullable, required: A band, not a prediction about any one message. **Derived from the DELIVERABILITY score, not from `score` beside it**: `low` at 80 and above, `medium` from 60, `high` below. Because that sibling is the inverse, applying these numbers to it reads every domain backwards: a spam score of 10 is the best case, not the worst. Read `spam_likelihood` and leave the arithmetic alone. **null when `grade` is null**: the band is read off the same partial score, so it is withheld with the grade.
      - `factors` object[], required: What pushed the score each way, including the things that helped.
        - `factor` string, required: What was looked at, e.g. `SPF`, `DKIM`, `DMARC`, `MX`.
        - `impact` 'positive' | 'negative' | 'neutral', required: Which way it pushed. `neutral` means it changed nothing: it was assessed with no effect, skipped (reverse DNS without a `sending_ip`, or too few blocklists answered to give a verdict), or not measured because its lookup failed. The `description` says which.
        - `description` string, required: Why, in one sentence.

## Other responses

- `400`: The request is malformed, or names a field this endpoint does not accept. Retrying it unchanged will fail identically.
- `401`: No credential, or one that is not accepted. `code` distinguishes `API_KEY_SUSPENDED` (subscription ended; restartable) from `API_KEY_REVOKED`, `SESSION_EXPIRED` and `OAUTH_TOKEN_EXPIRED` (a connected app's one-hour access token lapsed: refresh it and retry, or sign in again if no refresh token was issued or the refresh is refused).
- `402`: The account cannot pay for this request. The message carries the numbers and where to top up. Nothing was charged.
- `403`: Refused, and **the code says which of three problems it is**, and they need different remedies: - `INSUFFICIENT_SCOPE`: the credential is valid but not scoped for this route. With an API key, issue one that carries the scope named in the message. With an OAuth access token, connect the app again asking for that scope as well as the ones it has, unless the message says a connected app can never be given it; then use an API key with every scope the message names, or have the account owner do it in the dashboard. - `PLAN_UPGRADE_REQUIRED`: the account's plan does not include the feature. **No key of any scope reaches it**, so issuing another is wasted work; the remedy is a plan change. Returned by the verification routes for `options.enrich`, and by the finder, the monitors, the audit log, auto-clean and the white-label routes. - `PLAN_LIMIT_EXCEEDED`: the plan includes the feature and the account is at its ceiling (webhook endpoints, team seats, integrations, auto-clean lists). Delete one or upgrade. - `FORBIDDEN`: refused for a reason none of the above covers. `ErrorCode` is an open set, so branch on the code with a default rather than exhaustively.
- `409`: Another request is already using this `Idempotency-Key`. Nothing was charged. Retry the identical request.
- `429`: Too many requests. When the server knows how long to wait, `Retry-After` says so and `error.retry_after_seconds` carries the same number; the `X-RateLimit-*` headers say where you stand.
- `500`: The check failed AFTER the 10 credits were debited (storing the completed result, for instance). A refund is attempted, and **it can itself fail**: the API records that the credits were lost and still answers 500. A keyed check also answers 500 when it cannot confirm whether its debit committed (the debit timed out or lost its connection, and could still land a moment later): the message says the charge could not be confirmed, `retryable` is `true`, and a repeat with the SAME `Idempotency-Key` runs the check without charging again if the debit landed, and charges it once if it did not. This is not the same as the 503 beside it, but do not read that one as free either: most of its paths fail BEFORE the debit, and the all-DNS-timeout path fails after it. Read the 503's own description rather than assuming. Check `GET /v1/usage` before buying the check again; retrying with the SAME `Idempotency-Key` is safe and is the cheaper thing to try first.
- `503`: The check could not be completed. Several paths answer this, and **they differ on money**: an unreadable idempotency receipt, an unavailable cache/history read and transient credit contention all fail BEFORE the debit, so nothing was charged. But a check whose DNS lookups all time out fails AFTER it, and so does one in which nothing could be scored (every scored lookup failed and too few blocklists answered to score): the API attempts a refund, and its message says `No credits were charged` only when that refund succeeded. Read the message rather than assuming, and check the balance if it does not say so. Retry with the SAME `Idempotency-Key` either way; a fresh one can buy the check again.
- `default`: Any status this operation does not list. Kawaa's own errors carry the error envelope. The gateway in front of the API can answer any operation with its own bare body (`GatewayError`): a `503` after about 29 seconds, when the request may still have run and been charged, a `413` for a body over about 6 MB, or a `400` for a path with malformed percent-encoding.

---

[API](https://skmtc.dev/kawaa/apis/kawaa-api.md) · [All operations](https://skmtc.dev/kawaa/apis/kawaa-api/llms.txt) · [OpenAPI document](https://skmtc.dev/kawaa/apis/kawaa-api/revisions/d27c9c33f6ba?raw)
