Skip to main content

API reference

Verification API

Verify a single email address in real-time and get detailed results including deliverability status, risk flags, and enrichment data.

POST/v1/verify

Request body

ParameterTypeRequiredDescription
emailstringRequiredEmail address to verify
include_activitybooleanOptionalInclude the engagement your own account's tracking has recorded for the address (default: false)
options.deep_verifybooleanOptionalRequest extended checks (default: true). Kawaa attempts SMTP mailbox probing when deep verification is requested and the target mail server supports the check; otherwise Kawaa falls back to syntax, domain, MX, and AI risk analysis. Every option is a JSON boolean: a string such as "false" is rejected with 400 INVALID_OPTION.
options.include_aibooleanOptionalRun the AI-assisted checks (default: true). Turning it off does more than remove AI analysis: the check also skips the SPF, DKIM and DMARC lookup, so domain_info usually lacks auth_score, auth_grade, has_spf, spf_policy, has_dmarc, dmarc_policy and has_dkim. They then appear only when a recent check of the same domain already measured them. Typo suggestions and catch-all confidence still come from the built-in rules.
options.skip_cachebooleanOptionalForce a fresh check instead of serving a recent cached or in-flight result (default: false). A cached result costs 0.5 credits and sets from_cache: true; a fresh one costs 1. An inconclusive result is never reused: catch_all, risky and unknown statuses, and temporary outcomes such as greylisted or temporary_failure, are checked again at the full 1 credit, because a stored non-answer is not a result. On an accept-all domain that is most of a list, though not all of it: an address ruled disposable or spam_trap is settled before the catch-all probe and is reused normally. Every fresh check replaces or invalidates the worker's option-scoped cache entry, so a later request cannot resurrect older evidence. Concurrent work that started earlier cannot overwrite a forced result if it finishes later.
options.enrichbooleanOptionalReturn the enrichment block shown in the example response (default: false). Without it the response has no enrichment key. Requires the Business plan or higher. On a lower plan the whole request is rejected with 403 PLAN_UPGRADE_REQUIRED, so omit it unless your plan includes it. That code means no API key will work whatever its scopes; the fix is a plan change.
options.timeout_msintegerOptionalHow long, in milliseconds, this request waits for a fresh (cache-miss) check to finish before answering with status: "pending" (default: 24,000). Values are clamped to 5,000–25,000, not refused: a positive value below 5,000 is raised to 5,000 and anything above 25,000 is capped at 25,000, so asking for 60,000 still answers after about 25 seconds. Zero or a negative number falls back to the default. The wait can end sooner on a request that has already spent time on authentication and credits, because the answer must arrive inside the API gateway's 29-second limit. A non-number or a fraction (for example 7500.5) is rejected with 400. It only bounds the wait on this endpoint; the check itself continues and GET /v1/jobs/{job_id} returns the result. Not accepted on the batch or file routes.

Any other top-level field is rejected with 400 BAD_REQUEST, and any other field inside options with 400 INVALID_OPTION; both name the supported fields, so a misspelled option can never be silently ignored and charged for.

Safe retries: the Idempotency-Key header

This endpoint charges before it answers, 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. Repeating the same request with the same key returns the job the first attempt created and charges nothing more.

cURL
# Requires Python 3. Run once per verification; 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/verify \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Idempotency-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'
  • After dispatch, a repeat answers 200 with "idempotent_replay": true, the receipt's current job_id, "credits_used": 0 and the "status": "pending" shape. Read the result or refund for that attempt from GET /v1/jobs/{job_id}. The request claims its uncharged job first; the debit, credit-ledger entry and funded job state then commit together. If a caller stops between that commit and queueing, a repeat safely finishes the interrupted enqueue and returns the normal acceptance or result shape without a second charge. Once a failed attempt is fully refunded, the same key starts a newly charged attempt under a fresh job_id; the old failed job remains terminal (until the refund completes, a repeat returns that failed job uncharged).
  • A key identifies one request on this endpoint. Reusing it with a different address or options is refused with 409 IDEMPOTENCY_KEY_CONFLICT before anything is charged. A malformed key is refused with 400 INVALID_IDEMPOTENCY_KEY.
  • A repeat that arrives while another request under the same key is still queueing it answers 409 CONFLICT with retryable: true and a Retry-After header. Nothing more is charged; repeat it with the same key after that delay and it returns the job.
  • Keys are scoped to your account and to this endpoint, and a repeat is answered with the receipt's current job for as long as that job is kept (the data retention of the plan it was submitted under); only the fully refunded rotation described above changes that current job. The receipt itself lasts at least 30 days and never less than the job, so a repeat that arrives after the job's retention period has ended (after 7 days on the Free plan) is refused with 410 IDEMPOTENT_JOB_EXPIRED and charges nothing, rather than buying the same work twice; a new key runs it again. Two requests racing with the same key produce one charge and one job.
  • Without the header, an identical request (same address, same options other than timeout_ms, same include_activity) that arrives while an earlier one from your account is still being verified joins it instead of paying again: it gets that verification's result, or the same pending shape and job_id, with "credits_used": 0 and "idempotent_replay": true. A verification counts as in flight until it has its answer, and for at most 15 minutes after its charge. Once it has its answer, an identical request is charged as usual (0.5 credits from cache, 1 for a fresh check); only a key makes a later retry free. The Node and Python SDKs send a key automatically on every verify and batch call from 0.2.0 and retry only requests that carry one; earlier releases and the other SDKs do not.

Example request

cURL
curl -X POST https://api.kawaa.com/v1/verify \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "john@example.com",
    "include_activity": true,
    "options": {
      "deep_verify": true,
      "include_ai": true
    }
  }'

This request runs on every plan. To also receive the enrichment block, add "enrich": true inside options. That option requires the Business plan or higher, and sending it without one returns 403 instead of a verification result.

Response

An address on a domain that accepts all mail, shown with every optional block populated. A catch_all result has its own status (it is never folded into risky), and its quality_score is capped at 60. enrichment appears only when you send options.enrich on a Business-or-higher plan, activity only with include_activity, and ai only when AI analysis returns something to report. A cached result ("from_cache": true) is answered on the spot and its response has no job_id. From October 7, 2026 it is still recorded as a single-verification job, like a fresh one: it is listed by GET /v1/jobs and counts as one verification and one API call in your usage and analytics. Cached answers from before October 7, 2026 appear in Credit history but not in verification counts.

200 OK
{
  "success": true,
  "data": {
    "job_id": "5e809a8a-1d1a-4c92-86f8-2b7395e5d803",
    "email": "john@example.com",
    "status": "catch_all",
    "sub_status": "catch_all",
    "quality_score": 60,
    "verification": {
      "syntax_valid": true,
      "domain_exists": true,
      "mx_found": true,
      "mx_records": ["mx1.example.com", "mx2.example.com"],
      "smtp_check": true,
      "catch_all": true,
      "greylisted": false,
      "smtp_code": 250,
      "smtp_message": "250 2.1.5 Recipient OK"
    },
    "flags": {
      "disposable": false,
      "role_account": false,
      "free_provider": false,
      "spam_trap": false,
      "abuse_email": false,
      "honeypot": false,
      "complainer": false,
      "bot_generated": false
    },
    "domain_info": {
      "domain": "example.com",
      "provider_type": "corporate",
      "auth_score": 84,
      "auth_grade": "B",
      "has_spf": true,
      "spf_policy": "SoftFail",
      "has_dmarc": true,
      "dmarc_policy": "Quarantine",
      "has_dkim": true
    },
    "enrichment": {
      "first_name": "John",
      "full_name": "John",
      "gender": "male",
      "country": "United States",
      "country_code": "US"
    },
    "ai": {
      "catch_all_confidence": 35
    },
    "activity": {
      "level": "active",
      "score": 78,
      "last_activity_at": "2026-01-15T10:30:00Z",
      "engagement": { "open_count": 12, "click_count": 5, "reply_count": 1 },
      "deliverability": { "bounce_count": null, "complaint_count": null },
      "confidence": 80,
      "data_points": 18
    },
    "credits_used": 1,
    "credits_remaining": 4998,
    "from_cache": false,
    "processing_time_ms": 1345,
    "verified_at": "2026-02-03T14:22:15.123Z",
    "classifier_version": 7,
    "request_id": "2a4104cd-ef74-4412-89e2-a4859a72bd11"
  }
}

Response fields

Every field a completed result can carry. A field marked as omitted is absent from the JSON rather than null, so read it with a default. Fields are added without a version change; unknown ones are informational.

Top-level fields

FieldTypeDescription
job_idstringIdentifier of this check. GET /v1/jobs/{job_id} returns the same result together with its billing status.
emailstringThe address that was verified.
statusstringThe verdict. See Status values below.
sub_statusstringThe specific reason behind status. See Sub-status below. Omitted when there is nothing more specific to say.
quality_scoreinteger 0–100Overall quality. See Quality score below.
verificationobjectThe individual checks that produced the verdict. Fields below.
flagsobjectRisk signals about the address. Fields below.
domain_infoobjectThe domain’s provider type and email-authentication posture. Fields below. Omitted when the domain could not be looked up. The SPF, DKIM and DMARC fields come from a lookup that runs only when options.include_ai is on (the default). With it off, they are present only when a recent check of the same domain already measured them, so expect them to be missing.
aiobjectTypo and catch-all signals: typo_suggestion, typo_confidence and typo_method (how the typo was found, for example DirectMapping) when the address looks like a typo of a known domain, and catch_all_confidence on a catch-all domain. Present whenever there is one of these to report, with or without options.include_ai: without it the built-in rules produce them; with it, AI analysis is tried first. Omitted otherwise.
suggestionstringThe corrected address when the domain looks like a misspelling of a well-known one, for example jane@gmail.com for jane@gmial.com. The same value as ai.typo_suggestion. It is advice only: status and every check describe the address you sent. The typo check runs whether or not options.include_ai is set; with it, AI analysis can also catch misspellings the built-in list does not. Omitted when there is no correction.
enrichmentobjectfirst_name, last_name, full_name, gender, country and country_code where they can be inferred. Present only when options.enrich was sent on a plan that includes it.
activityobjectEngagement your own account's tracking has recorded for the address (never another account's). Present only when the request set include_activity; see the Activity API. When your tracking has recorded nothing it is still an object, with level unknown, score 0 and data_points 0. Omitted when the activity could not be read.
domain_reputationobjectReserved for a stored numeric domain reputation score when include_activity is true. Kawaa does not compute one today, so current responses omit this field even when domain activity exists.
credits_usednumberWhat this request cost: 1 for a fresh check, 0.5 for a cached result.
credits_remainingnumberYour credit balance after this request.
from_cachebooleantrue when the result was served from a recent check of the same address rather than a fresh one. Send options.skip_cache to force a fresh check.
cache_age_secondsintegerHow old the cached result is. Present only when from_cache is true.
processing_time_msintegerTime the verification worker spent on the check, in milliseconds.
verified_atstring (ISO 8601)When evidence collection began. For a cached result this is the start time of the original check, not of this request.
classifier_versionintegerVersion of the classifier that produced the verdict. When the classifier changes, a cached result from an older version is not reused if the new classifier could reach a different verdict for it; the address is verified afresh.
request_idstringIdentifier of this API request. Quote it when contacting support.

verification

FieldTypeDescription
syntax_validbooleanThe address is syntactically valid.
domain_existsbooleantrue: DNS shows the domain exists. false is not always a DNS answer: it is also the value when DNS was not read (a syntax error) or did not answer (sub_status temporary_failure). Only status invalid with sub_status domain_not_found says DNS found no such domain.
mx_foundbooleantrue: the domain publishes MX records. false: it publishes none, it publishes a null MX (MX 0 ., RFC 7505) and takes no mail, or DNS was not read or did not answer (a syntax error, sub_status temporary_failure). A domain with no MX record at all can still take mail at its address record, so read false with status: when the mail server was checked (smtp_check present), status says what it answered.
mx_recordsstring[]The MX hostnames found. Omitted when there are none.
smtp_checkbooleanWhether the SMTP mailbox probe succeeded. Omitted when no probe was attempted (for example a domain with no MX records).
mailbox_existsbooleantrue: the mail server accepted the mailbox at RCPT TO, on a domain that does not accept every address. false: the server answered RCPT TO without accepting it. That is usually a refusal of the mailbox (status invalid), but it is also false when the answer was temporary (status unknown, sub_status greylisted or temporary_failure) or the server refused the sender rather than the mailbox (sub_status policy_block), so read it with status and sub_status. Omitted when no server answered RCPT TO (no probe, a connection failure, or a refusal earlier in the conversation, such as at the greeting), when an acceptance proves nothing (catch_all, provider_accept_all or a risky result), and when the server said the mailbox is full.
catch_allbooleanThe domain accepts mail for any address, so an accepted mailbox proves nothing on its own. Omitted when the catch-all probe did not measure it: the check ended before any probe (a disposable or spam-trap verdict, a domain with no usable mail server), the mail server refused or never answered for the mailbox so the probe never ran, or the probe could not settle it (that result is risky). Also true beside provider_accept_all, where the mail provider accepts addresses it does not have.
provider_accept_allbooleanThe provider accepts every address at the provider level, which makes the mailbox probe inconclusive. Present only when true.
greylistedbooleanThe mail server deferred the probe with a temporary rejection.
smtp_codeintegerThe mail server’s last SMTP reply code. Omitted when no probe ran.
smtp_messagestringThe mail server’s last SMTP reply. Omitted when no probe ran.

flags

FieldTypeDescription
disposablebooleanThe domain is on the list of disposable or temporary mail providers. With AI analysis on, it is also set when the domain name looks like one of them, unless DNS shows the domain does not exist or cannot receive mail. Only a listed domain gets the disposable status, so an address flagged by its name alone still gets the mail server check.
role_accountbooleanA role address such as info@ or support@ rather than a person’s.
free_providerbooleanA free consumer provider such as Gmail or Yahoo.
spam_trapbooleanReserved. Kawaa’s reviewed trap-domain list is empty, so no check since September 22, 2026 sets this flag. A stored result from before then can still have it: verify the address again.
abuse_emailbooleanThe local part matched an abuse, complaint-handling, feedback-loop, legal or blocklist-removal name or pattern, at any domain, such as abuse@, postmaster@, feedback@, legal@, spam-alerts@ or abuse1@. Every address with complainer set has it too. With AI analysis on, the model can also raise it for a local part containing abuse, spam or complaint, at a domain that can receive mail. A match on the address itself, not a finding about what the mailbox is used for or anything its owner has done.
honeypotbooleanNo check since September 22, 2026 sets this flag: Kawaa does not guess that an address is a honeypot from how it looks. A stored result from before then can still have it: verify the address again.
complainerbooleanA narrower abuse_email: the address is a complaint-handling mailbox, such as abuse@, postmaster@, fbl@ or feedback@, at a reviewed anti-spam, feedback-loop, sender-reputation or consumer-protection organization, such as spamhaus.org, spamcop.net or ftc.gov. The same local part at any other domain, and any other mailbox at those organizations, does not raise it. A match on the address itself, not complaint history: verification does not derive it from any record of who has marked mail as spam.
bot_generatedbooleanThe address looks machine-generated. With AI analysis on, the model judges a local part the rules leave open, at a domain that can receive mail.

domain_info

FieldTypeDescription
domainstringThe domain part of the address.
provider_typestringOne of free, corporate, educational, government or unknown.
auth_scoreinteger 0–100How completely the domain has set up SPF, DKIM and DMARC. Omitted when the lookup failed or did not run. It runs only with options.include_ai on; with it off, this and the six fields below appear only when a recent check of the same domain already measured them.
auth_gradestringauth_score as a letter: A (90–100), B (80–89), C (70–79), D (50–69) or F. Omitted when the lookup failed or did not run.
has_spfbooleanThe domain publishes an SPF record. Omitted when the lookup failed or did not run.
spf_policystringWhat the SPF record says about senders it does not list: Pass (+all), Fail (-all), SoftFail (~all) or Neutral (?all, or no all term). A record that hands its policy to another domain with redirect= gets that domain’s. PermError means receivers stop with a permanent error before any policy applies: for example the domain publishes two SPF records or one with a term receivers cannot read, its redirect= names a domain with no SPF record, or it needs more than 10 DNS lookups. Omitted when there is no SPF record or the lookup did not run.
has_dmarcbooleanThe domain publishes a DMARC record. Omitted when the lookup failed or did not run.
dmarc_policystringThe DMARC policy: None, Quarantine or Reject. Omitted when there is no DMARC record or the lookup did not run.
has_dkimbooleanA DKIM key was found at a common selector. Omitted when none was found and a lookup failed, or when the lookup did not run.

Status values

StatusDescription
validSMTP accepted the address and no catch-all/provider-limited caveat was detected during this run
invalidNo valid mail server or SMTP rejected the mailbox
riskyMay receive mail, but risk signals or incomplete proof remain
unknownCould not determine deliverability
catch_allDomain accepts all emails - individual mailbox unverifiable
disposableTemporary email address
roleRole-based email (info@, support@)
spam_trapAt a domain on the reviewed list of trap-only domains - do not send. The list is empty today, so no check returns this yet
pendingNo result yet. The check is still running, so the response carries job_id and credits_held instead of a result. Poll GET /v1/jobs/{job_id}.

Sub-status

sub_status is the specific reason behind status: one of mailbox_exists, mailbox_reject, catch_all, provider_accept_all, policy_block, greylisted, temporary_failure, connection_failure, dns_only, no_mx, domain_not_found or syntax_error. Branch on status for the decision and read sub_status for the explanation; new sub-status values may be added without a version change, so treat unknown ones as informational.

Pending responses

A cache-miss verification that has not finished within the request window returns 200 with status: "pending" rather than holding the connection open. There is no quality_score or result block on this response. Treat it as "ask again shortly", not as a verdict:

200 OK · pending
{
  "success": true,
  "data": {
    "job_id": "8f14e45f-ceea-467a-9a1f-2b0c8f9c4a11",
    "email": "user@example.com",
    "status": "pending",
    "message": "Verification is still in progress. 1 credit is held while processing; if the worker fails before producing a result, job reconciliation refunds it. Use GET /v1/jobs/{job_id} to check status.",
    "credits_held": 1,
    "credits_used": 1,
    "credits_remaining": 4998,
    "request_id": "6b0f3c55-27d4-4a5e-9f43-0c1d2e3f4a5b"
  }
}

credits_used is what this request debited, since the charge comes before the verdict, and credits_remaining is your balance after it. credits_held says that credit is held until the check finishes. request_id identifies the request, as on a completed result. With include_activity, the activity block is attached here too.

Poll GET /v1/jobs/{job_id} until it reports a terminal status. The held credit is refunded automatically if the check never produces a result, so a pending response is not a charge for nothing.

Rarely, a check is charged but cannot be confirmed as queued before the request has to answer. The answer is then this same pending shape with its job_id, and its message says so. The held credit is refunded automatically if no result is produced. While the job has no result, repeating the same request a few seconds later and within 15 minutes of sending it (with the same Idempotency-Key, or unchanged if you sent none) queues it if needed and is not charged again. If nothing could be queued in time, the answer is instead a 503 whose message says the request was charged and its check could not be queued yet; the same repeat takes the check over and is not charged again, and a check that is never queued is refunded. Without a key, that 503 also carries retry_may_charge_again: true: a repeat sent later, or after the check has a result, is a new request and may be charged.

Quality score

The quality_score is a 0-100 score indicating overall email quality:

  • 90-100: Excellent - lowest observed risk
  • 70-89: Good - Generally safe
  • 50-69: Fair - Review recommended
  • 0-49: Poor - High risk of issues

For catch-all domains, policy blocks, and provider-limited responses, see Understanding results & provider limits.

Credits

  • 1 credit per verification (cache miss)
  • 0.5 credits for cached results (within 24 hours)
  • The verdict does not change the price: unknown, risky and catch_all results cost 1 credit, here, over MCP and in bulk jobs. They are never served from the cache, so the 0.5 rate never applies to them
  • Async cache-miss checks hold 1 credit while processing
  • Held credits are refunded automatically if a check ends without a result
  • Activity data included at no extra cost when available