API reference
Verification API
Verify a single email address in real-time and get detailed results including deliverability status, risk flags, and enrichment data.
/v1/verifyRequest body
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | Required | Email address to verify |
include_activity | boolean | Optional | Include the engagement your own account's tracking has recorded for the address (default: false) |
options.deep_verify | boolean | Optional | Request 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_ai | boolean | Optional | Run 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_cache | boolean | Optional | Force 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.enrich | boolean | Optional | Return 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_ms | integer | Optional | How 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.
# 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
200with"idempotent_replay": true, the receipt's currentjob_id,"credits_used": 0and the"status": "pending"shape. Read the result or refund for that attempt fromGET /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 freshjob_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_CONFLICTbefore anything is charged. A malformed key is refused with400 INVALID_IDEMPOTENCY_KEY. - A repeat that arrives while another request under the same key is still queueing it answers
409 CONFLICTwithretryable: trueand aRetry-Afterheader. 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_EXPIREDand 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, sameinclude_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 andjob_id, with"credits_used": 0and"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 everyverifyand batch call from 0.2.0 and retry only requests that carry one; earlier releases and the other SDKs do not.
Example request
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.
{
"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
| Field | Type | Description |
|---|---|---|
job_id | string | Identifier of this check. GET /v1/jobs/{job_id} returns the same result together with its billing status. |
email | string | The address that was verified. |
status | string | The verdict. See Status values below. |
sub_status | string | The specific reason behind status. See Sub-status below. Omitted when there is nothing more specific to say. |
quality_score | integer 0–100 | Overall quality. See Quality score below. |
verification | object | The individual checks that produced the verdict. Fields below. |
flags | object | Risk signals about the address. Fields below. |
domain_info | object | The 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. |
ai | object | Typo 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. |
suggestion | string | The 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. |
enrichment | object | first_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. |
activity | object | Engagement 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_reputation | object | Reserved 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_used | number | What this request cost: 1 for a fresh check, 0.5 for a cached result. |
credits_remaining | number | Your credit balance after this request. |
from_cache | boolean | true 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_seconds | integer | How old the cached result is. Present only when from_cache is true. |
processing_time_ms | integer | Time the verification worker spent on the check, in milliseconds. |
verified_at | string (ISO 8601) | When evidence collection began. For a cached result this is the start time of the original check, not of this request. |
classifier_version | integer | Version 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_id | string | Identifier of this API request. Quote it when contacting support. |
verification
| Field | Type | Description |
|---|---|---|
syntax_valid | boolean | The address is syntactically valid. |
domain_exists | boolean | true: 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_found | boolean | true: 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_records | string[] | The MX hostnames found. Omitted when there are none. |
smtp_check | boolean | Whether the SMTP mailbox probe succeeded. Omitted when no probe was attempted (for example a domain with no MX records). |
mailbox_exists | boolean | true: 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_all | boolean | The 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_all | boolean | The provider accepts every address at the provider level, which makes the mailbox probe inconclusive. Present only when true. |
greylisted | boolean | The mail server deferred the probe with a temporary rejection. |
smtp_code | integer | The mail server’s last SMTP reply code. Omitted when no probe ran. |
smtp_message | string | The mail server’s last SMTP reply. Omitted when no probe ran. |
flags
| Field | Type | Description |
|---|---|---|
disposable | boolean | The 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_account | boolean | A role address such as info@ or support@ rather than a person’s. |
free_provider | boolean | A free consumer provider such as Gmail or Yahoo. |
spam_trap | boolean | Reserved. 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_email | boolean | The 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. |
honeypot | boolean | No 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. |
complainer | boolean | A 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_generated | boolean | The 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
| Field | Type | Description |
|---|---|---|
domain | string | The domain part of the address. |
provider_type | string | One of free, corporate, educational, government or unknown. |
auth_score | integer 0–100 | How 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_grade | string | auth_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_spf | boolean | The domain publishes an SPF record. Omitted when the lookup failed or did not run. |
spf_policy | string | What 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_dmarc | boolean | The domain publishes a DMARC record. Omitted when the lookup failed or did not run. |
dmarc_policy | string | The DMARC policy: None, Quarantine or Reject. Omitted when there is no DMARC record or the lookup did not run. |
has_dkim | boolean | A 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
| Status | Description |
|---|---|
| valid | SMTP accepted the address and no catch-all/provider-limited caveat was detected during this run |
| invalid | No valid mail server or SMTP rejected the mailbox |
| risky | May receive mail, but risk signals or incomplete proof remain |
| unknown | Could not determine deliverability |
| catch_all | Domain accepts all emails - individual mailbox unverifiable |
| disposable | Temporary email address |
| role | Role-based email (info@, support@) |
| spam_trap | At a domain on the reviewed list of trap-only domains - do not send. The list is empty today, so no check returns this yet |
| pending | No 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:
{
"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,riskyandcatch_allresults 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