Skip to main content

API reference

Deliverability API

Check a sending domain's DNS authentication and IP reputation, then retrieve checks owned by your account. Send your API key in the X-Api-Key header on every request.

Run a deliverability check

POST/v1/deliverability/check

Inspect MX, SPF, DKIM, DMARC, reverse DNS and supported DNS blocklists. A new check costs 10 credits.

Request body

ParameterTypeRequiredDescription
domainstringRequiredSending domain, such as example.com
sending_ipstringOptionalThe public IPv4 address your mail is sent from, for PTR and blocklist checks. If omitted, PTR is skipped and blocklists use an IP resolved from the domain's MX records.
refreshbooleanOptionalSet to true to bypass the 24-hour cache and buy a new 10-credit check.

These are the only accepted fields. Unknown fields and a non-boolean refresh return HTTP 400 before any history lookup, DNS work or credit movement. sending_ip accepts IPv4 only; null, blank, hostname and IPv6 values are rejected before charging. Omit the field to use MX discovery.

sending_ip must be the public address receiving mail servers actually see. Private (10/8, 172.16/12, 192.168/16), loopback, link-local, shared/CGNAT (100.64/10), documentation (192.0.2/24, 198.51.100/24, 203.0.113/24), the deprecated 6to4 relay anycast block (192.88.99/24), multicast and other reserved addresses also return HTTP 400 before charging: no public reverse-DNS or blocklist zone answers for them. A domain that is a special-use name the public DNS never answers for (such as .example, .test, .invalid, .localhost, .local, .onion, .internal or .home.arpa) is refused the same way. In the example below, replace YOUR_SENDING_IP with your own sending address, or leave the field out.

Request
curl -X POST https://api.kawaa.com/v1/deliverability/check \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "example.com",
    "sending_ip": "YOUR_SENDING_IP"
  }'
Response
{
  "success": true,
  "data": {
    "domain": "example.com",
    "score": 92,
    "grade": "A",
    "checked_at": "2026-09-14T12:00:00.000Z",
    "check_id": "38c271db-97c4-4c0f-88df-a975027240c4",
    "dns": {
      "mx_records": [{ "priority": 10, "host": "mail.example.com", "ip": null }],
      "has_valid_mx": true,
      "mx_score": 100
    },
    "authentication": {
      "spf": { "status": "pass", "record": "v=spf1 -all", "score": 100, "issues": [] },
      "dkim": { "status": "pass", "selectors_found": ["default"], "score": 100, "issues": [] },
      "dmarc": { "status": "pass", "record": "v=DMARC1; p=reject", "policy": "reject", "score": 100, "issues": [] }
    },
    "reputation": {
      "score": 100,
      "blacklists_checked": 5,
      "blacklists_inconclusive": 3,
      "blacklists_skipped": 0,
      "blacklists_total": 8,
      "blacklists_listed": 0,
      "listed_on": []
    },
    "server": {
      "accepts_mail": null,
      "response_time_ms": null,
      "supports_tls": null,
      "tls_version": null,
      "reverse_dns": "mail.example.com"
    },
    "spam_assessment": { "score": 8, "spam_likelihood": "low", "factors": [] },
    "recommendations": [],
    "credits_used": 10,
    "credits_remaining": 490
  }
}

Cache and charging: a matching domain and sending-IP result from the previous 24 hours returns with cached: true and costs 0 credits, even when the account has fewer than 10 credits. Set refresh: true only when you intend to purchase a new check.

Safe failures: if the result history cannot be read, the API returns 503 before charging. If every DNS lookup times out, or nothing at all could be scored, it attempts to refund the 10-credit debit and returns 503. Other failures after a debit also attempt a refund and return 500.

Lookups that fail: only an answer of "no such record" (NXDOMAIN or NODATA) reports a record as missing. When the SPF, DKIM, DMARC, MX or reverse-DNS lookup itself fails (SERVFAIL, REFUSED or a timeout), that part is named in not_measured with the reason, its authentication status is not_measured, and it is left out of the score rather than counted against the domain.

No grade without SPF, DKIM and DMARC: when any of those three could not be looked up, grade and spam_assessment.spam_likelihood are null and score is a partial figure over what did answer. 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, so read not_measured before treating a result as complete, and send refresh: true to measure again.

How SPF, DMARC and MX are read: the way receiving mail servers read them. Two SPF records, or an SPF term receivers cannot read, make SPF invalid, because receivers then let no mail pass SPF. Includes and redirects are followed and every DNS lookup through them is counted: more than 10, a loop, or an include with no SPF record makes SPF weak, because receivers stop there, so servers listed past that point fail SPF and the record's all is never applied. An include that cannot be read leaves SPF in not_measured, and an include whose record ends with +all lets any server send. The SPF qualifier comes from the all term, or from the record a redirect= hands the policy to: a bare all means +all and lets any server send, and a record with no all gives unlisted servers a neutral result. A subdomain with no DMARC record of its own is graded on the record receivers fall back to (its Organizational Domain's, found as RFC 9989 describes), whose sp= applies, and authentication.dmarc.inherited_from names that domain. Two DMARC records, a tag written twice, or an invalid p= value make DMARC invalid, because receivers then apply no DMARC policy (unless rua= holds a valid address: then they read it as p=none, and so does the check). A record with no p= at all is read as p=none, as RFC 9989 says; other parts of a record that are not valid tags are ignored, with a warning; and pct= below 100 lowers the DMARC score. A null MX (MX 0 .) says the domain accepts no mail, so has_valid_mx is false. DKIM is looked for at common selectors only, so a key published under another selector is not found.

Raw scoring detail: the response also carries the unprojected result the grade was computed from: checks (per-mechanism score out of maxScore), a severity-sorted issues list covering every mechanism, and sendingIp, checkedAt and durationMs in their original camelCase spelling. checks.*.score is raw points, while authentication.*.score is the same measurement as a percentage of maxScore: DKIM at 11 of 15 in checks reads 73 in authentication. Prefer checked_at over checkedAt. An older stored check may have no checks at all.

Measured scope: the API does not probe an SMTP server today. The server acceptance, response-time and TLS fields are null because they are not measured, not because those checks failed. A reputation score of null likewise means fewer than half of the blocklists returned a verdict and none listed the domain, so reputation was not measured. Blocklists that were never asked count against it: without a sending_ip or an address for the domain's mail server, only the domain blocklists can answer.

Safe retries with the Idempotency-Key header

A new check is charged before its DNS work starts, so a request that times out or fails with a 5xx on the way back may already have been charged. To retry it safely, send an optional Idempotency-Key header: any opaque token of 1–255 printable ASCII characters with no spaces or commas (a UUID is fine), unique per logical request, and send the same key again when you retry.

cURL
# Requires Python 3. Run once per check; on retry, run only curl again.
KEY=$(python3 -c 'import uuid; print(uuid.uuid4())') || exit 1
test -n "$KEY" || exit 1

curl -X POST https://api.kawaa.com/v1/deliverability/check \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Idempotency-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"domain": "example.com", "refresh": true}'
  • A repeat after the check was stored answers 200 with the original check_id and result, "idempotent_replay": true, "credits_used": 0 and the credits_remaining recorded right after the original charge (left out if that record cannot be read). If the first attempt was paid but has not stored its result yet (it is still running, or its connection dropped), the repeat runs that paid check again without charging and returns the result, whatever your balance.
  • The debit and the key's claim commit together, so two requests racing with the same key are charged once. A refund and the key's release also commit together: once an attempt is refunded (every DNS lookup timed out, or the result could not be stored), the same key buys a new 10-credit check on its next use, and a refunded attempt is never refunded twice.
  • Reusing a key with a different domain, sending_ip or refresh value returns 409 IDEMPOTENCY_KEY_CONFLICT before anything is charged, and a malformed key returns 400 INVALID_IDEMPOTENCY_KEY. Keys are scoped to your account and to this endpoint, and are remembered for 30 days.
  • The 24-hour cache still applies first: without refresh, a keyed request for a domain you checked recently gets the free cached result, and that key keeps replaying the same result (with cached: true) for its 30 days even after the cache window passes. Without the header the endpoint behaves exactly as before.

List check history

GET/v1/deliverability/checks?limit=20

The rows considered by this request are sorted newest first. limit defaults to 20 and is capped at 100. The service reads at most 20 history pages. When truncated is false, all of your history was considered; when it is true, rows outside that bounded read were not considered and may be newer or older than the rows returned.

Response
{
  "success": true,
  "data": {
    "checks": [
      {
        "check_id": "38c271db-97c4-4c0f-88df-a975027240c4",
        "domain": "example.com",
        "sending_ip": "YOUR_SENDING_IP",
        "score": 92,
        "grade": "A",
        "checked_at": "2026-09-14T12:00:00.000Z"
      }
    ],
    "count": 1,
    "total": 1,
    "truncated": false
  }
}

Get a check

GET/v1/deliverability/checks/{id}

Return one check with the same detailed result sections shown above. A missing ID, or an ID owned by another account, returns HTTP 404 without revealing whether another account has that record.