---
title: "Verify one address"
method: POST
path: "/v1/verify"
tags: ["Verification"]
---

# Verify one address

`POST /v1/verify`

## Request

- Base URL: `https://api.kawaa.com`
- URL: `https://api.kawaa.com/v1/verify`
- 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: `verification:write`)

Verifies a single address and answers with the verdict. Charges 1 credit, or 0.5 when the result is served from cache. The verdict does not change the price: an `unknown`, `risky` or `catch_all` result costs 1 credit, and is never served from cache.

If verification does not finish inside the request window the response is still `200`, with `status: "pending"` and a `job_id` instead of a result. Poll `GET /v1/jobs/{id}` for the result. Calling this again while that check is still running joins it rather than charging again (below); once it has its answer, a repeat is a new request at the usual price.

**Without a key, an identical request that arrives while the first is still in flight is not charged again.** The same account asking for the same normalized address with the same `deep_verify`, `include_ai`, `enrich`, `skip_cache` and `include_activity` (`timeout_ms` does not count) joins the earlier request's verification: it is answered with that verification's result when it is ready inside this request's wait, or with the pending shape carrying the same `job_id`, and either way with `credits_used: 0` and `idempotent_replay: true`. If that attempt ends without an answer while this request waits (it was refunded), this request gets a retryable `503` with `nothing_charged: true` instead. A concurrent pair is charged once. A verification counts as in flight until its answer exists, and for at most 15 minutes after its charge (the age at which job reconciliation refunds an unanswered one). A request that arrives after the answer existed is priced exactly as before: 0.5 credits from cache, or 1 for a fresh check. Another account's verification is never joined.

Send `Idempotency-Key` to make a retry safe at any time.

## Headers

- `Idempotency-Key` string

## Request body

- VerifyRequest
  - `email` string, required: The address to verify. **Normalized before it is validated**, so this is deliberately not constrained with `format: email`: the API trims surrounding whitespace, lower-cases the address and punycodes an internationalized domain, and only then checks its syntax. So `" User@Example.com "` and `user@münchen.de` are both accepted and are charged like any other address, while a strict email-format validator would refuse the raw strings. The response echoes the NORMALIZED form, so compare against that, not against what you sent.
  - `options` VerifyOptions: Only these option names are accepted. Anything else is a 400 naming the supported ones, rather than being ignored while the response reads as applied. Omit `options` to use the defaults. A present `null` is a wrong-type value and is refused with `400 INVALID_OPTION` before anything is verified or charged.
    - `deep_verify` boolean: Attempt an SMTP conversation with the receiving server.
    - `include_ai` boolean: Run the AI-assisted checks (default `true`). Turning it off does more than remove AI analysis. The built-in rules alone then answer the typo and catch-all checks, so `ai` and `suggestion` still appear when they find something. The worker also skips the SPF, DKIM and DMARC lookup, which runs only with this option on: `domain_info` then carries `auth_score`, `auth_grade`, `has_spf`, `spf_policy`, `has_dmarc`, `dmarc_policy` and `has_dkim` only when a recent check of the same domain already measured them, so expect them to be absent.
    - `enrich` boolean: Include name, gender and country hints where available. Needs the Business plan or higher: on a lower plan the whole request is refused with `403 PLAN_UPGRADE_REQUIRED` and nothing is charged.
    - `skip_cache` boolean: Ignore a cached result and verify fresh. Charges the full rate.
    - `timeout_ms` integer: How long `POST /v1/verify` waits (whole milliseconds) for a verdict before answering `status: "pending"`. **Normalized, not honoured verbatim**, and normalized rather than refused: the default is 24,000 ms, a positive value below 5,000 is raised to 5,000, and anything above 25,000 is capped at 25,000: asking for 60,000 gets `pending` after about 25 seconds. Zero and negative numbers fall back to the default. The cap can fall further on a request that has already spent time on auth and credits, because polling must finish inside the API's 29-second request time limit. No `minimum` or `maximum` is declared here on purpose: they would make a generated client refuse locally what the API accepts and clamps. A present value that is not a whole number is the one case that IS refused, with `400 INVALID_OPTION`: nothing is verified and nothing is charged. That includes a fraction such as `7500.5`: the verification worker reads this field as an unsigned integer, and a fractional value used to be charged for and then dropped as an unreadable job. A pending answer is not a failure: poll `GET /v1/jobs/{job_id}`.
  - `include_activity` boolean: Attach your own account's engagement history for the address as `activity`. `domain_reputation` is reserved and not sent today, because Kawaa stores no domain reputation score. Adds NO charge of its own: the call debits once (1 credit fresh, 0.5 from cache) whether or not you ask for it.

## Response `200`

A verdict, or `status: "pending"` when the check is still running.

- object
  - `success` unknown, required
  - `data` union, required
    - VerificationResult: A completed verification.
      - `job_id` string, uuid
      - `batch_job_id` string, uuid: The batch or file job this result belongs to, when it belongs to one: on a batch row of `GET /v1/jobs/{id}` it is the same id as `job_id`. Omitted from a single verification (`POST /v1/verify`, and its job read back through `GET /v1/jobs/{id}`), which belongs to no batch.
      - `email` string, email, required
      - `status` 'valid' | 'invalid' | 'risky' | 'unknown' | 'catch_all' | 'disposable' | 'role' | 'spam_trap', required: The verdict. `catch_all` means the domain accepts mail for every recipient, so this particular mailbox could not be checked: it is neither a yes nor a no. `unknown` means the check could not conclude.
      - `sub_status` string: Why the verdict came out as it did. New values can be added without a version change; treat an unfamiliar one as informational. Deliberately NOT a closed enum: the list above is what this build emits, and a new reason ships without a version bump. A generated validator built from a closed set would reject an otherwise valid verification result the day one is added, which is the opposite of the forward compatibility this field promises.
      - `quality_score` integer, required
      - `verification` object, required
        - `syntax_valid` boolean, required
        - `domain_exists` boolean, required
        - `mx_found` boolean, required
        - `mx_records` string[]
        - `smtp_check` boolean
        - `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. Absent is not `false`.
        - `greylisted` boolean, required
        - `smtp_code` integer, nullable
        - `smtp_message` string, nullable
        - `mailbox_exists` boolean: `true` when the mail server accepted the mailbox at RCPT TO, on a domain that does not accept every address. `false` when the server answered RCPT TO without accepting it: read it as "not confirmed", not "confirmed absent". It 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 ran, the connection failed, or the conversation was refused before RCPT, for example 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. Absent is not `false`.
        - `provider_accept_all` boolean: The PROVIDER accepts recipient probes too broadly for an individual mailbox to be confirmed. Not the same as `catch_all`, which is the domain's own configuration. Present when the worker could tell. SMTP acceptance here is not evidence the mailbox exists, and a client that treats it as such publishes a deliverability claim nothing measured.
      - `flags` object, required: Independent of the verdict: an address can be valid and also role_account.
        - `disposable` boolean, required
        - `role_account` boolean, required
        - `free_provider` boolean, required
        - `spam_trap` boolean, required
        - `abuse_email` boolean, required: 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. A match on the address itself, not a finding about what the mailbox is used for or anything its owner has done.
        - `honeypot` boolean, required
        - `complainer` boolean, required: 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, required
      - `domain_info` object: The domain's provider type and email-authentication posture. The SPF, DKIM and DMARC fields (`auth_score`, `auth_grade`, `has_spf`, `spf_policy`, `has_dmarc`, `dmarc_policy`, `has_dkim`) are each omitted when their lookup failed or did not run. The lookup 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.
        - `domain` string
        - `provider_type` string
        - `auth_score` integer
        - `auth_grade` string
        - `has_spf` boolean
        - `spf_policy` string, nullable
        - `has_dmarc` boolean
        - `dmarc_policy` string, nullable
        - `has_dkim` boolean
      - `ai` object: Present when the worker has something to report: it is built when there is a typo suggestion or a catch-all confidence, and omitted otherwise. That holds whether or not `include_ai` is set: without it the built-in rules produce both; with it, AI analysis is tried first and the rules are the fallback. The four fields below are every field it can carry: it never includes a `fraud_score` or `patterns` field.
        - `typo_suggestion` string, nullable: A corrected address when one looks likely; omitted when there is none. The same value as the top-level `suggestion`. This is the field name the API uses; there is no `suggested_email`.
        - `typo_confidence` number, nullable: How confident the correction is, when one was made.
        - `typo_method` string, nullable: How the suggestion was arrived at, as the detector names it. Present whenever `typo_suggestion` is. Treat it as an open set: it is the detector's own label, and a new detector adds a new one.
        - `catch_all_confidence` number, nullable: 0-100, and only for a domain that accepts everything: how likely this particular mailbox is real despite the catch-all. It is the one piece of evidence that separates two `catch_all` addresses, and it is absent on every other result.
      - `suggestion` string: The corrected address when the domain looks like a misspelling of a well-known one, such as `jane@gmail.com` for `jane@gmial.com`. The same value as `ai.typo_suggestion`, and present exactly when that is. It is advice only: `status` and every check describe the address that was sent, not the correction. The typo check runs whether or not `include_ai` is set; with it, AI analysis can also catch misspellings the built-in list does not. Omitted (never `null`) when there is no correction.
      - `credits_used` number
      - `credits_remaining` number
      - `processing_time_ms` integer
      - `verified_at` string, date-time
      - `from_cache` boolean
      - `request_id` string
      - `idempotent_replay` boolean: `POST /v1/verify` only. True when this request, sent without an `Idempotency-Key`, joined an identical request whose verification was still in flight and was answered from it: `credits_used` is `0` and nothing was charged. Absent when this request paid for the verdict.
      - `activity` ActivityData
        - `level` 'active' | 'inactive' | 'dormant' | 'unknown', required: How recently this address was seen active. `inactive` sits between the active and dormant windows; `unknown` means there was nothing to judge from, not that the address is inactive.
        - `score` number, required
        - `last_activity_at` string, date-time, nullable, required
        - `engagement` object, required
          - `open_count` integer, required
          - `click_count` integer, required
          - `reply_count` integer, required: Counts replies recorded by Kawaa email warmup campaigns only. It is not a measurement of replies to your own mail, which Kawaa cannot see. Warmup is switched off, so no new replies are recorded, and new opens and clicks come only from your own tracking pixels and links.
        - `deliverability` object, required
          - `bounce_count` integer, nullable, required
          - `complaint_count` integer, nullable, required
        - `confidence` number, required: 0 means your account has recorded no activity for this address, not that it is inactive.
        - `data_points` integer, required
        - `message` string: Present only when no activity data is available for a single-address lookup.
      - `domain_reputation` object, nullable: 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.
      - `enrichment` object, nullable: Attached when the request set `options.enrich` and the plan includes data enrichment. Null when nothing is known, which is not a judgement about the address.
        - `first_name` string, nullable
        - `last_name` string, nullable
        - `full_name` string, nullable
        - `gender` string, nullable
        - `country` string, nullable
        - `country_code` string, nullable
      - `cache_age_seconds` integer: How old the cached result is, in seconds. Present on every cache hit, beside `from_cache: true`. Without it a result reused from days ago reads exactly like one measured this second.
      - `classifier_version` integer: Which verdict classifier produced this result. Stamped by the worker onto every newly produced result. When the classifier changes a cached result from an older version is never reused (the address is verified afresh), so two results with different values here were not decided by the same rules.
    - PendingVerification: No verdict yet. Not a result: poll the job.
      - `job_id` string, uuid, required
      - `email` string, email
      - `status` 'pending', required
      - `message` string
      - `credits_held` number: Held, not spent. Refunded by reconciliation if the worker never produces a result. Absent on a request that joined an identical one still in flight (`idempotent_replay: true`): that request holds nothing.
      - `activity` ActivityData
        - `level` 'active' | 'inactive' | 'dormant' | 'unknown', required: How recently this address was seen active. `inactive` sits between the active and dormant windows; `unknown` means there was nothing to judge from, not that the address is inactive.
        - `score` number, required
        - `last_activity_at` string, date-time, nullable, required
        - `engagement` object, required
          - `open_count` integer, required
          - `click_count` integer, required
          - `reply_count` integer, required: Counts replies recorded by Kawaa email warmup campaigns only. It is not a measurement of replies to your own mail, which Kawaa cannot see. Warmup is switched off, so no new replies are recorded, and new opens and clicks come only from your own tracking pixels and links.
        - `deliverability` object, required
          - `bounce_count` integer, nullable, required
          - `complaint_count` integer, nullable, required
        - `confidence` number, required: 0 means your account has recorded no activity for this address, not that it is inactive.
        - `data_points` integer, required
        - `message` string: Present only when no activity data is available for a single-address lookup.
      - `domain_reputation` object, nullable: 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.
      - `credits_used` number: What this call charged. Present on a pending result too: the debit happens before the verdict does. `0` on a request that joined an identical one still in flight (`idempotent_replay: true`).
      - `credits_remaining` number: The balance after that charge.
      - `idempotent_replay` boolean: True when this repeated an earlier `POST /v1/verify` under the same `Idempotency-Key`, or, without a key, joined an identical request whose verification was still in flight: the job is the ORIGINAL one and nothing more was charged. **Absent means this request created the job**: the field is added only on a replay. `credits_used: 0` accompanies it, which on its own is ambiguous because a pending answer can also report held rather than spent credits; this is the field that says which. Poll `GET /v1/jobs/{job_id}` either way.
      - `request_id` string: Identifier of this API request. Quote it when contacting support.

## 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`: One of two codes, and this answer adds no charge in either case. `IDEMPOTENCY_KEY_CONFLICT`: the `Idempotency-Key` was used before for a different request on this route. Repeat the original request exactly, or use a new key. `CONFLICT` with `retryable: true` and a `Retry-After`: a copy of this same request under the same key is being queued right now. Repeat it with the SAME key after that delay, and the repeat returns the job (`idempotent_replay: true`, `credits_used: 0`). A new key buys the verification a second time.
- `410`: `IDEMPOTENT_JOB_EXPIRED`, not retryable. The prior job for this `Idempotency-Key` has reached the end of the data-retention period of the plan it was submitted under (7 days on Free) while the key's receipt, kept at least 30 days, still stands. The job cannot be replayed or restarted from that receipt. This retry was not charged. Send a new key to run the request again.
- `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`: Something failed after the request was accepted, and **the message says what that means for the money**. A keyed dispatch whose lease could not be confirmed asks you to retry with the SAME `Idempotency-Key`: the work may already be running, and a fresh key buys it again. A batch that was charged and could not create its job attempts a refund and says whether that succeeded. A debit whose outcome could not be confirmed (it was cancelled at the request's time limit, or timed out, and its write may still land) says so: with an `Idempotency-Key` the answer carries `retryable: true`; repeat it with the SAME key (without one, the identical request within 15 minutes), and the repeat returns this verification if it was charged or charges it once if it was not. Do not retry blind, and do not assume the debit was restored. Read `error.message`, and check `GET /v1/jobs` before resubmitting.
- `503`: Temporarily unable to serve; `error.retryable` says whether waiting cures it. All but one of these charged nothing: a balance contended by concurrent spending, a concurrent request that changed this `Idempotency-Key`'s charge or refunded an earlier attempt under it, a request that ran out of time before its charge could start, or, without a key, a verification that could not be started, or an identical request this one joined that ended without a result, could not be queued or could not be read. Retry as the message says, with the SAME `Idempotency-Key` if you sent one. **One is charged**, never carries `nothing_charged: true`, and its message says it was charged: the check was charged, but its verification could not be queued yet. Repeat the request within 15 minutes of sending it, with the SAME `Idempotency-Key` (or, without a key, the identical request): the repeat takes the verification over and is not charged again. The message says when to repeat it: with a key, a few seconds from now; when an earlier refund attempt may still hold the verification, two minutes from now, which `Retry-After` (120) also says. A verification that is never queued is refunded automatically. A `503` whose body is only `{"message":"Service Unavailable"}` is not one of these: the gateway in front of the API answered it (see the description at the top of this document), the request may still have run, and only a repeat with the SAME `Idempotency-Key` is safe.
- `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)
