---
title: "Verify a list of addresses"
method: POST
path: "/v1/verify/batch"
tags: ["Verification"]
---

# Verify a list of addresses

`POST /v1/verify/batch`

## Request

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

Submits up to your plan's batch size in unique, well-formed addresses (Free 100, Starter 1,000, Professional and Business 5,000, Enterprise 10,000; more is a 413, and the raw list may hold at most 10,000 entries) and answers immediately with a `job_id`. Charges about 1 credit per address at the moment the job is accepted, not when it finishes.

Follow it with `GET /v1/jobs/{id}`, or supply `webhook_url` and be told. Send `Idempotency-Key` to make a retry safe.

`options.timeout_ms` is refused: it bounds the synchronous wait on `POST /v1/verify` and nothing on this path honours it.

## Headers

- `Idempotency-Key` string

## Request body

- VerifyBatchRequest
  - `emails` string[], required: The addresses to verify. `maxItems` here is the ABSOLUTE ceiling (Enterprise); the limit that applies is your plan's (Free 100, Starter 1,000, Professional and Business 5,000, Enterprise 10,000), and a longer list is **413** `BATCH_SIZE_EXCEEDED` before anything is charged: a capacity refusal, not malformed input, so an integration can branch on the status and split or upgrade rather than treating it as a bad request. `GET /v1/account` reports the plan. At least one entry must be a usable address; a list with none is a 400.
  - `webhook_url` string: Where to POST the completion callback. HTTPS only: anything else is refused with 400 WEBHOOK_URL_INVALID. Without a `webhook_secret` the callback is unsigned. Omit the field for no callback; an explicit `null` is a wrong-type value and is refused before the job is created or charged. No scheme `pattern` is declared: the URL is parsed first, which tolerates surrounding whitespace and normalizes the protocol, so `" https://x"` and `"HTTPS://x"` are both accepted, and an anchored lower-case pattern refused both. It must still be HTTPS; that is checked on the parsed protocol, not on the string you sent.
  - `options` AsyncVerifyOptions: Verification options for an ASYNCHRONOUS submission. Deliberately not `VerifyOptions`: that schema permits `timeout_ms`, which these routes refuse with 400 INVALID_OPTION because there is no synchronous wait to bound. Omit `options` to use the defaults; a present `null` is refused with the same code before work starts or credits are charged.
    - `deep_verify` boolean
    - `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 submission is refused with `403 PLAN_UPGRADE_REQUIRED` and nothing is charged.
    - `skip_cache` boolean
  - `include_activity` boolean
  - `webhook_secret` string: Signs the callback to `webhook_url`. Stored with the job, and every delivery then carries `X-Kawaa-Signature-V2` (an HMAC of the send time and the body, which lets you refuse a replay) and the legacy `X-Kawaa-Signature` computed from it. The flat callback body has no timestamp of its own, so check the `t` in `X-Kawaa-Signature-V2`. Without it the callback is UNSIGNED and anything that can reach your URL can post a convincing completion. Only meaningful alongside `webhook_url`. It has no meaning on its own: a secret with no `webhook_url` is 400 `INVALID_WEBHOOK_SECRET`, because there is no callback to sign. `dependentRequired` says so above. Omit the field for unsigned callbacks; an explicit `null` is refused with `400 INVALID_WEBHOOK_SECRET` before the job is created or charged.

## Response `202`

Accepted and charged. The verdicts arrive on the job.

**A replay is also a 202, and it charged nothing.** Repeating the submission under the same `Idempotency-Key` returns the ORIGINAL job with `idempotent_replay: true` and `credits_used: 0`. Read those two fields before reconciling a debit from this status: the 202 alone does not distinguish a new paid job from a recovered one. A repeat that arrives while another request under the key is still queueing the batch is a replay too, with `dispatch_in_progress: true` and a `Retry-After` header: repeat after the delay, or poll the job.

- object
  - `success` unknown, required
  - `data` BatchAccepted, required: What `POST /v1/verify/batch` answers with on acceptance. `unique_emails` is what the job will verify; `credits_used` is what was charged, and the two can differ when queueing some addresses could not be confirmed; see the fields themselves.
    - `job_id` string, uuid, required
    - `status` Status, required (unresolved $ref)
    - `total_submitted` integer, required: Addresses in the request.
    - `unique_emails` integer, required: Addresses CONFIRMED queued: what the job will actually verify. It is not always what was charged: when queueing some addresses cannot be confirmed, the API counts what it could confirm and reports the rest in `enqueue_unknown`, so this can be lower than `credits_used` and, in the extreme, 0 on a job that charged. Reconcile billing against `credits_used` and `credits_held`, never against this count.
    - `duplicates_removed` integer: Removed before charging: the same address twice costs once.
    - `invalid_emails_skipped` integer: Rejected as malformed and never verified. The results will be shorter than the list you sent.
    - `credits_used` number, required: 0 on an idempotent replay.
    - `credits_remaining` number: Absent when the post-charge balance could not be safely recovered.
    - `idempotent_replay` boolean: True when this returned an earlier submission's job and charged nothing.
    - `dispatch_in_progress` boolean: A request under this same `Idempotency-Key` is still queueing the batch. Nothing more was charged. Retry after `Retry-After`, or poll the job.
    - `enqueue_unknown` integer: How many sends could not be confirmed. Credits for them are HELD, not spent. Poll the job until it reconciles.
    - `credits_held` number: Held pending reconciliation of an ambiguous enqueue. Not a settled charge.
    - `include_activity` boolean
    - `message` string
    - `estimated_time_seconds` integer: A rough estimate of how long the job will take, at about two seconds per address. An estimate for a progress bar, not a deadline. Poll `GET /v1/jobs/{job_id}` for the truth.
    - `enqueue_failures` integer: How many addresses the queue DEFINITIVELY rejected, present only when some were. Distinct from `enqueue_unknown`, which is "we do not know". Their credits are refunded where the refund succeeds; where it does not they stay held and `message` says so, so this number beside an unchanged `credits_used` is the case to reconcile rather than to retry.

## 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`: The `Idempotency-Key` was used before for a different request on this route. No credit moved. Repeat the original request exactly, or use a new key.
- `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.
- `413`: Too much in one request: `BATCH_SIZE_EXCEEDED`, more addresses than the plan's per-batch limit or the 10,000 cap allows, refused before anything is charged. Split the list. A request body over about 6 MB never reaches Kawaa: the gateway answers `413` itself with `{"message":"Request Entity Too Large"}` and no envelope, and nothing is run or charged.
- `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 batch that was charged but whose state could not be read before the answer was due says so and names its job id: read it with `GET /v1/jobs/{job_id}` instead of sending the batch again. 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 batch within 15 minutes), and the repeat returns the batch 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 (among them a contended balance, a batch that could not reserve its job, an earlier attempt under the key refunded while this one ran, or a request that ran out of time before its charge could start): retry as the message says. **One is charged**, never carries `nothing_charged: true`, and its message says it was charged: the batch was charged, but some of its emails could not be confirmed as queued before the request had to answer. The message says how many and names the job. Repeat the request after its `Retry-After` (3 seconds) and within 15 minutes of sending it, with the SAME `Idempotency-Key` (or, without a key, the identical batch): the repeat queues them and is not charged again. Without a key the error also carries `retry_may_charge_again: true` and the message adds that a repeat sent later may be charged again: past its window the identical batch is a new purchase. A repeat sooner is told the batch is still being queued (`202`, `dispatch_in_progress`) and queues nothing. Any email that is never verified is refunded automatically.
- `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)
