API reference
Batch & file verification
Verify large lists of emails asynchronously. Submit batches or upload CSV/TXT files. The per-batch cap is plan-dependent: 100 emails on Free(1,000 once the account has bought any credit pack), up to 10,000 on Enterprise.
Batch verification
/v1/verify/batchSubmit an array of emails for asynchronous verification. Returns a job ID to track progress.
Request body
| Parameter | Type | Required | Description |
|---|---|---|---|
emails | string[] | Required | Array of email addresses. Plan-dependent cap: 100 (Free, or 1,000 once the account has bought any credit pack), 1,000 (Starter), 5,000 (Professional/Business), 10,000 (Enterprise) |
options | object | Optional | Verification options (e.g. deep_verify, include_ai) |
webhook_url | string | Optional | HTTPS URL notified when the job completes or fails (job.completed or job.failed) |
webhook_secret | string | Optional | HMAC key used to sign the webhook_url delivery (X-Kawaa-Signature-V2, with the legacy X-Kawaa-Signature; see Signature verification). Requires webhook_url; max 256 characters |
include_activity | boolean | Optional | Include your own account's activity data in per-email results when available |
Any other top-level field is rejected with 400 BAD_REQUEST, and any other field inside options (or timeout_ms, which only POST /v1/verify takes) 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
A batch is charged in full before the job is queued, so a request that times out or fails with a 5xx on the way back may already have been charged. Send an optional Idempotency-Key header (any opaque token of 1–255 printable ASCII characters with no spaces or commas, unique per logical request). After dispatch, a repeat of the same list with the same key answers 202 with "idempotent_replay": true, the receipt's current job_id and "credits_used": 0; nothing more is charged while that attempt remains active or its refund is pending. 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 shape without a second charge. A repeat that arrives while another request under the key is still queueing the batch answers 202 with dispatch_in_progress: true and a Retry-After header, charging nothing more: repeat it after that delay, or poll the job. 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, and until the refund completes a repeat returns it uncharged. A key identifies one batch: reusing it with a different list, options or webhook is refused with 409 IDEMPOTENCY_KEY_CONFLICT before anything is charged. 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. The Node and Python SDKs send one automatically from 0.2.0; earlier releases and the other SDKs do not.
Without the header, an identical batch (the same list, options, include_activity and webhook) sent from your account within 15 minutes of the first one being charged is the same purchase: it answers 202 with that job, whether it is still running or already finished, with "idempotent_replay": true and "credits_used": 0, and charges nothing. After those 15 minutes, or once that batch was fully refunded, the same batch is a new purchase. Only a key keeps a later retry free.
On any replay, keyed or not, credits_used is what this request charged, which is 0. What the batch cost is on its job: the credits_used (less credits_refunded) that GET /v1/jobs/{job_id} reports.
Example request
curl -X POST https://api.kawaa.com/v1/verify/batch \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"emails": [
"user1@example.com",
"user2@example.com",
"user3@example.com"
],
"webhook_url": "https://YOUR-ENDPOINT.example.com/kawaa",
"webhook_secret": "your-signing-secret"
}'Replace https://YOUR-ENDPOINT.example.com/kawaa with your own public HTTPS endpoint, here and in every example below, or leave webhook_url out. The API looks the host up before it accepts the job and refuses one that does not resolve, or resolves to a private address, with HTTP 400 WEBHOOK_URL_INVALID. The placeholder is a reserved name that never resolves, so pasted as it stands it is refused rather than delivered to someone else.
Response (202 Accepted)
HTTP/1.1 202 Accepted
{
"success": true,
"data": {
"job_id": "9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90",
"status": "processing",
"total_submitted": 3,
"duplicates_removed": 0,
"invalid_emails_skipped": 0,
"unique_emails": 3,
"estimated_time_seconds": 6,
"credits_used": 3,
"credits_remaining": 4997,
"message": "Batch verification started. Use GET /v1/jobs/{job_id} to check status."
}
}Once a batch or a file is charged, the request answers within the API's 29-second limit even if queueing it stalls. If some addresses were not queued in time, the answer is 503 with retryable: true and Retry-After: 3, and its message says the job was charged, how many emails could not be confirmed as queued, and the job id. Repeating the same request after that delay and within 15 minutes of sending it (with the same Idempotency-Key, or the identical batch if you sent no key; for a file, with the uploaded file unchanged) queues them and is not charged again. Without a key, the error also carries retry_may_charge_again: true: an identical batch sent later than that may be charged as a new purchase. A repeat sooner is told the job is still being queued and queues nothing. If every address was sent but some sends could not be confirmed, the answer is the 202 above with enqueue_unknown and credits_held counting them. Either way, any email that is never verified is refunded automatically. A file processed without an Idempotency-Key always gets the 202: no repeat can find that job, so a repeat would be a new purchase.
File upload
/v1/verify/fileUpload a CSV, TSV or TXT file containing email addresses for verification. File verification is a two-step JSON flow: request a presigned upload URL, PUT your file to it, then start processing with the returned file_key.
Step 1: Request an upload URL
curl -X POST https://api.kawaa.com/v1/verify/file \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "get_upload_url",
"filename": "emails.csv",
"file_size": '"$(wc -c < emails.csv)"'
}'file_size is the file's size in bytes, up to 52,428,800 (50 MB). The upload URL accepts exactly that many bytes, so ask for a new one if the file changes. A larger file is refused here with 413 BATCH_SIZE_EXCEEDED, before any upload.
{
"success": true,
"data": {
"upload_url": "https://BUCKET.s3.REGION.amazonaws.com/uploads/...?X-Amz-Algorithm=AWS4-HMAC-SHA256&...&X-Amz-Signature=...",
"file_key": "uploads/user_abc123/2c9c1f8e-.../emails.csv",
"expires_in_seconds": 900,
"next_step": "Upload the file to upload_url using PUT (exactly file_size bytes), then call this endpoint with action=process and file_key"
}
}upload_url is a presigned Amazon S3 URL that expires after expires_in_seconds. Use it exactly as returned: the bucket and region in the host are not fixed, and the query string is the signature, so do not build the URL yourself or change any part of it.
Step 2: Upload the file
curl -X PUT "UPLOAD_URL_FROM_STEP_1" \
--data-binary @emails.csvStep 3: Start processing
# Requires Python 3. Run once per file; 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/file \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "process",
"file_key": "uploads/user_abc123/2c9c1f8e-.../emails.csv",
"webhook_url": "https://YOUR-ENDPOINT.example.com/kawaa",
"webhook_secret": "your-signing-secret"
}'webhook_url and webhook_secret work as they do on a batch: the secret signs the completion callback, it requires webhook_url and is at most 256 characters, and without it the callback is sent unsigned.
{
"success": true,
"data": {
"job_id": "9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90",
"status": "processing",
"total_submitted": 1000,
"duplicates_removed": 12,
"invalid_emails_skipped": 3,
"unique_emails": 985,
"credits_used": 985,
"credits_remaining": 4015,
"email_column": { "index": 1, "name": "Email" },
"message": "File verification started. 12 duplicate(s) removed. Use GET /v1/jobs/{job_id} to check status."
}
}email_column is the column the addresses were read from: its position, counting from 0, and its header (null when the file has none). Check it when the file has more than one column of addresses, such as a contact's and a manager's: only that column is verified and charged for.
Safe file retries
Keep the same Idempotency-Key when retrying the process request. If the original invocation is still dispatching the file, the retry returns 202, dispatch_in_progress: true, the original job_id, and a Retry-After header without reading the upload again or charging again. This remains an in-progress receipt even if the object at that file_key was overwritten after dispatch began. After the dispatch lease expires, recovery reads the upload and refuses changed contents with 409 IDEMPOTENCY_KEY_CONFLICT rather than sending different work under the original charge. If reconciliation later finalizes only part of the file, a replay reports the completed unique_emails and credits_refunded counts instead of claiming the whole parsed file was accepted.
Supported formats & limits
- CSV:
- A
.csvfile, read as CSV whateverContent-Typeit was uploaded with. Comma-, semicolon-, tab- or pipe-separated (|), with or without a header row. An address may itself contain a pipe, so a file is read by the pipe only when its first row names the email column between pipes, or it has at least two rows and each of its first 20 rows holds a pipe. A one-row pipe file is read whole. A first line ofsep=;(orsep=,orsep=|), the line some tools write so that Excel opens the file in columns, is not read as a row: the file is read with the separator it names. A quoted cell may contain the separator and quotes written as"", and, in a row of more than one column, line breaks. Addresses are read from one column for the whole file, named in the response asemail_column. It is the column whose header names it:Email,E-mail,Email Address,Mail,E-Mail-Adresse,Correo electrónico,Courriel,Adresse e-mail,Indirizzo email,E-mailadres,Endereço de email,E-postadress,メールアドレス,이메일,电子邮件and the like (any case, with or without accents, spaces, hyphens or underscores), the first of them with an address under it when several are. Otherwise it is the first column holding an address whose header mentions email (Work Email), preferring one that is not someone else's or a second address (Manager Email,Owner Email,CC,Alt Email,Email 2). Otherwise it is the column with the most valid addresses in the first 1,000 rows, the leftmost on a tie. A row with only one value is read as that value. The first row is taken as a header, and not counted, when its address cell holds text with no@. Any other row whose address cell is empty or is not a bare address (such asJane Doe <jane@example.com>) is skipped and counted ininvalid_emails_skipped. - TSV:
- A
.tsvfile, read the same way as a CSV. - TXT:
- A
.txtfile: one email per line, each line a row of its own (a quoted value never runs on to the next line). A line that holds the file's separator (a comma, semicolon, tab or pipe, chosen as for a CSV) is split like a CSV row, and its address is read from the same column as the other lines. A line that is one quoted value ("ann@example.com") is read without its quotes, and any other line is read whole. A.txtuploaded with a CSVContent-Typesuch astext/csvis read as CSV - Encoding:
- UTF-8, UTF-16 (with or without a byte-order mark), or Windows-1252, the encoding Excel on Windows saves a plain CSV in. A spreadsheet workbook (
.xlsxor.xls) is refused with400, whatever its name: save it as CSV UTF-8 first. - Max file size:
- 50 MB (all plans)
- Reading time:
- Up to 20 seconds from when the request arrives. A file that takes longer, such as millions of blank or repeated rows, is refused with
413 BATCH_SIZE_EXCEEDEDand nothing is charged. Split it into smaller files. If the time runs out before the file is read at all (a slow start, not the file), the answer is503 SERVICE_UNAVAILABLEwithretryable: true, and nothing is charged: send the same request again. - Max emails per file:
- your account's batch cap: 100 (Free, or 1,000 once the account has bought any credit pack) up to 10,000 (Enterprise).
GET /v1/usage/limitsreports yours aslimits.bulk_job_size. - Upload URL expiry:
- 15 minutes
Check job status
/v1/jobs/{job_id}Query parameters
| Parameter | Description |
|---|---|
include_results | Embed per-email results in the response (default: true). Pass false when polling for status only; it is cheaper and faster on large jobs. Accepts true/false or 1/0 in any case; any other value, including an empty one, returns HTTP 400 |
limit | Results per page (default: 100, max: 1,000). A value outside 1–1,000 returns HTTP 400 rather than being clamped |
next_key | Opaque cursor from the previous page's next_key |
When results are included, the job carries a results[] array of verification results (the same shape as POST /v1/verify; each field, including classifier_version, is explained in the verification response fields), plus has_more and next_key for the next page. Each result from a batch or file job also carries job_id and batch_job_id, both the id of this job, so a result can be traced back after it has been copied out of the response. A job_id returned by a single POST /v1/verify is readable here too; its one result carries that job_id and no batch_job_id, since it was not part of a batch. A result served from the cache has from_cache: true. The discount for cached results is applied only after the job reaches completed or cancelled, and shows up as credits_refunded on the job; once it has been applied, each cached row also reads credits_used: 0.5, the price it was billed at. Before that (while the job is still running, or on a finished job whose discount could not be applied yet), the row carries the full credit the job is still holding for it. Reconcile the charge from the job's status, credits_used and credits_refunded, not from the absence of cache_discount_pending, which only marks a finished job whose discount attempt failed. For a full download in one request use GET /v1/jobs/{job_id}/download below.
Page through the results once the job's status is completed, failed or cancelled. Results come back in hash order, not the order you sent them, so while the job is running a result written after you started paging can land behind your next_key and be missed.
Response
{
"success": true,
"data": {
"job_id": "9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90",
"status": "completed",
"total_emails": 1000,
"processed_emails": 1000,
"progress_percent": 100,
"summary": {
"valid": 800,
"invalid": 100,
"risky": 50,
"unknown": 50
},
"created_at": "2026-02-03T14:30:00.000Z",
"updated_at": "2026-02-03T14:35:42.000Z",
"completed_at": "2026-02-03T14:35:42.000Z",
"file_counts": {
"total_submitted": 1015,
"invalid_emails_skipped": 3,
"duplicates_removed": 12,
"unique_emails": 1000
},
"credits_used": 1000,
"credits_refunded": 0,
"billing_status": "charged",
"results": [
{
"job_id": "9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90",
"batch_job_id": "9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90",
"email": "john@example.com",
"status": "valid",
"sub_status": "mailbox_exists",
"quality_score": 91,
"verification": {
"syntax_valid": true,
"domain_exists": true,
"mx_found": true,
"mx_records": ["mx1.example.com", "mx2.example.com"],
"smtp_check": true,
"catch_all": false,
"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
},
"credits_used": 1,
"processing_time_ms": 1312,
"verified_at": "2026-02-03T14:30:04.000Z",
"from_cache": false,
"classifier_version": 7
}
],
"has_more": true,
"next_key": "eyJqb2JfaWQiOi..."
}
}file_counts appears only on a job created from a file: what the file held, as POST /v1/verify/file counted it when it accepted the file. It is not on a job submitted as a list, and not on the job listing below.
requested_emails and partial: true appear when some of the addresses you submitted will never have a result: the job was cancelled or stopped early, some addresses could not be queued when you submitted them, or some never came back from the queue. total_emails then counts only the addresses that were checked, requested_emails is how many you submitted, and the difference was refunded. A completed job can carry them. They are the same figures the job.completed webhook sends, so reading the job after a missed delivery tells you what the delivery would have. The job listing carries both too.
Job status values
| Status | Description |
|---|---|
pending | Accepted and queued; no email has been verified yet |
processing | Verification in progress |
completed | Finished. Every address has a result, unless the job carries partial: true: then some have none and were refunded (see requested_emails above) |
failed | Job failed (check error_message) |
cancelled | Stopped with POST /v1/jobs/{id}/cancel. The addresses checked before it stopped are kept, stay billed, and stay readable through GET /v1/jobs/{id}; the credits held for the rest come back. The refund waits for the addresses already in flight to finish, so a cancelled job reads billing_status: "charged" for up to about 11 minutes before it reads "refunded". It does not always get there: a cancel arriving after the last address owes nothing and keeps charged for good, and a refund that could not be applied ends at refund_failed. The verdicts are final as soon as the status says cancelled: each result is written together with the job’s progress counter in one conditional write that requires the job to still be running, so the cancel closes the result set as it lands. Only the bill trails: wait for completed_at rather than for the billing status if you are reconciling the charge. Read progress as processed_emails of requested_emails: total_emails collapses onto what was actually checked and billed. |
enqueue_unknown | Some emails have unconfirmed queue status; credits are held and reconciled automatically |
Download results
/v1/jobs/{job_id}/downloadQuery parameters
| Parameter | Description |
|---|---|
format | csv or json (default: csv) |
filter | Filter by status: all, valid, invalid, risky, unknown, spam_trap, disposable, role, catch_all (default: all). invalid, risky and unknown are the summary buckets, not exact verdicts: invalid also returns disposable addresses, risky also returns catch_all, role and spam_trap, and unknown also returns rows with no stored status. valid, catch_all, disposable, role and spam_trap each return only themselves; ask for one of those when you need the exact verdict. Case-insensitive. |
Response
The response depends on how many results match. Up to 1,000 results are returned in the body: a CSV file (Content-Type: text/csv, sent as an attachment) for format=csv, or for format=json a plain object without the usual success/data wrapper:
{
"job_id": "9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90",
"status": "completed",
"result_count": 2,
"complete": true,
"expected_rows": 2,
"rows_missing": 0,
"results": [
{
"email": "john@example.com",
"status": "invalid",
"score": 10,
"disposable": false,
"role": false,
"free_provider": false,
"suggestion": null,
"mx_records": [],
"smtp_response": null,
"verified_at": "2026-02-03T14:33:10.000Z",
"sub_status": "no_mx"
},
{
"email": "jane@example.com",
"status": "invalid",
"score": 10,
"disposable": false,
"role": false,
"free_provider": false,
"suggestion": null,
"mx_records": [],
"smtp_response": null,
"verified_at": "2026-02-03T14:33:11.000Z",
"sub_status": "no_mx"
}
]
}A downloaded row is flat, and is not the nested result POST /v1/verify and GET /v1/jobs/{job_id} return: the quality score is score, and there is no quality_score, flags or verification. The reason code is there, as sub_status in the last column. Neither sample row has catch_all: example.com accepts no mail, so no mailbox probe measured it, and the key is left out rather than sent as false. A CSV download has the same columns, in the same order, as its header row, with an empty cell where a JSON row leaves a key out:
| Column | Description |
|---|---|
email | The address |
status | The verdict: valid, invalid, risky, unknown, catch_all, disposable, role or spam_trap. A result stored before those names were settled is exported as it was stored, so it can read catchall or spamtrap: treat those as catch_all and spam_trap, as the filter parameter does |
score | The quality score, 0–100 (quality_score in a verification result) |
disposable | true when the domain is on the list of disposable or temporary mail providers, or, with AI analysis on, its name looks like one |
role | true for a role address such as info@ or support@ |
free_provider | true for a free consumer provider such as Gmail |
catch_all | true when the domain accepts mail for any address, false when a mailbox probe found it does not. Omitted (an empty cell in CSV) when no probe measured it, for example on a disposable or spam-trap verdict, or at a domain with no usable mail server |
suggestion | The corrected address when the domain looks like a typo of a known one; null (an empty cell in CSV) otherwise |
mx_records | The domain’s MX hosts: an array in JSON, joined with ; in CSV |
smtp_response | The mail server’s last SMTP reply; null (an empty cell in CSV) when no probe ran |
verified_at | When the check began (ISO 8601) |
sub_status | The reason code behind status, the same value as sub_status on the verification result, for example mailbox_reject or policy_block. Omitted (an empty cell in CSV) when the result has none, as on a disposable or spam-trap verdict whose DNS lookups answered; one whose DNS lookup failed carries temporary_failure. The last column, so the columns before it keep their positions |
A CSV download is UTF-8 without a byte-order mark, and every address in it is plain ASCII: an internationalized domain is written in its xn-- form. Text that a spreadsheet would run as a formula gets an apostrophe in front so that it opens as text: a cell starting with =, +, - or @, and the same characters just after a semicolon or line break inside a cell, where a spreadsheet set to split on semicolons starts a new cell. An address such as -info@example.com is therefore written '-info@example.com. Remove that apostrophe before importing the CSV into another system, or use format=json, which carries every value unchanged.
More than 1,000 results are written to a file instead, and the endpoint answers with a JSON envelope whatever format you asked for. Fetch download_url (a presigned Amazon S3 URL, no API key needed) within expires_in_seconds to get the CSV or JSON file. Use it exactly as returned; its host and query string are not fixed:
{
"download_url": "https://BUCKET.s3.REGION.amazonaws.com/downloads/...?X-Amz-Algorithm=AWS4-HMAC-SHA256&...&X-Amz-Signature=...",
"format": "csv",
"result_count": 4812,
"expires_in_seconds": 3600,
"complete": true,
"expected_rows": 4812,
"rows_missing": 0
}A client that always saves the body to a file will therefore save this envelope as results.csv on large jobs. Check the response Content-Type: text/csv is the file, application/json with a download_url is the envelope.
Is it the whole result set?
The export is read from an index that is eventually consistent, so a job that has only just finished can be exported before its last rows are visible. Every JSON answer from this endpoint (the inline format=json object and the envelope) says whether it is the job's whole result set, counted against the job's own processed_emails:
| Field | Description |
|---|---|
complete | true when the rows returned reach the job's processed_emails; false when some are missing; null when the job carries no count to compare against, which means “not verified”, never “complete”. Measured before filter is applied, so a filtered export inherits the verdict of the read it came from |
expected_rows | The job's processed_emails: how many addresses it finished. null when the job carries none |
rows_missing | How many of those rows the read did not return: 0 when nothing is missing, null when completeness was not verified. Counted like complete, before filter is applied, so on a filtered export it counts rows missing from the whole job, not from the filtered file |
message | Present only when complete is not true: what is missing and what to do |
- A short JSON export is reported, not refused. Treat it as a partial set until
completeistrue; asking again a few seconds later normally returns the rest. - A short CSV of up to 1,000 rows is refused with
409and"retryable": true, because a CSV body has nowhere to say it is incomplete. Retry it, or ask forformat=jsonto read the rows that exist now. An inline CSV body is therefore never a measured shortfall, but for a job that carries no row count it cannot have been measured at all, and onlyformat=jsonsays so ("complete": null). - No readable rows at all for a job that finished some is also a retryable
409, not a404. If it persists, the rows have passed your plan's retention window. A job that produced no rows answers404. - A job that is still running answers
400with"retryable": true; a job that failed before checking any address answers400for good.
Example
Saves the file only once it is known to be complete. -f makes an error status fail the request instead of being written to results.csv. A large export's envelope carries complete; a small CSV cannot, so the script reads the verdict from the same export as JSON, which at that size comes back inline. A 409, or a verdict of false, is retried; null stops, because retrying cannot verify it.
#!/usr/bin/env bash
# Save a job's valid addresses as results.csv, once the export is complete.
JOB=9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90
URL="https://api.kawaa.com/v1/jobs/$JOB/download?filter=valid"
for attempt in 1 2 3 4 5 6; do
# -f: an error status fails curl instead of being saved as the file.
code=$(curl -sS -f -o download.tmp -w '%{http_code}' -H "X-Api-Key: $KAWAA_API_KEY" "$URL&format=csv")
if [ "$code" = 409 ]; then
sleep 10; continue # retryable: the results index is still catching up
elif [ "$code" != 200 ]; then
echo "Download failed with HTTP $code" >&2; exit 1
fi
if jq -e 'has("download_url")' download.tmp >/dev/null 2>&1; then
# Over 1,000 rows: an envelope, which says whether the file is complete.
complete=$(jq -r .complete download.tmp)
else
# Up to 1,000 rows: the body is the CSV, which has nowhere to say. Read the
# verdict from the same export as JSON (also inline at this size).
complete=$(curl -sS -f -H "X-Api-Key: $KAWAA_API_KEY" "$URL&format=json" | jq -r .complete)
fi
case "$complete" in
true) ;;
null) echo "This job has no row count, so the export cannot be verified" >&2; rm -f download.tmp; exit 1 ;;
*) echo "Export not complete yet; retrying" >&2; sleep 10; continue ;;
esac
if jq -e 'has("download_url")' download.tmp >/dev/null 2>&1; then
curl -sS -f -o results.csv "$(jq -r .download_url download.tmp)" || exit 1
rm download.tmp
else
mv download.tmp results.csv
fi
echo "Saved results.csv"; exit 0
done
echo "The export was still incomplete after 6 attempts" >&2; exit 1Cancel a job
/v1/jobs/{job_id}/cancelStops a running batch or file job. No request body. Free, and it needs the verification:write scope. Addresses already checked stay in the job, stay billed and stay readable through GET /v1/jobs/{job_id} and the download above; the credits held for the rest are refunded. A stopped job cannot be resumed.
- The stop is immediate; the refund follows. The job reads
cancelledas soon as this returns, and its results are final. The refund waits for the addresses already being checked to finish, sobilling_statusreadschargedfor up to about 11 minutes, thenrefunded. A cancel that lands after the last address owes nothing and keepscharged. Wait forcompleted_at, not the billing status, if you are reconciling the charge. - No webhook is sent for a cancelled job, neither to the job's
webhook_urlnor asjob.completedorjob.failedto your account's endpoints. Those fire only when a job completes or fails, so treat the response to this request as the notification. - Repeating it succeeds. Cancelling an already cancelled job returns
200with the job and tries the outstanding refund again; that does not guarantee the refund lands. Watchcompleted_at, and contact support ifbilling_statusreadsrefund_failed, which no retry changes.
curl -X POST https://api.kawaa.com/v1/jobs/9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90/cancel \
-H "X-Api-Key: YOUR_API_KEY"{
"success": true,
"data": {
"job_id": "9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90",
"status": "cancelled",
"total_emails": 1000,
"processed_emails": 412,
"progress_percent": 41,
"summary": { "valid": 330, "invalid": 41, "risky": 29, "unknown": 12 },
"created_at": "2026-02-03T14:30:00.000Z",
"updated_at": "2026-02-03T14:31:10.000Z",
"completed_at": null,
"cancelled_at": "2026-02-03T14:31:10.000Z",
"credits_used": 1000,
"credits_refunded": 0,
"billing_status": "charged"
}
}| Status | When |
|---|---|
400 | The job id is malformed, or the job is a single verification (it finishes in seconds and settles its own credit) |
404 | No such job on your account |
409 | The job already completed or failed, so there is nothing to stop and its results are unaffected; or it has not started yet, which carries "retryable": true |
List all jobs
/v1/jobsList all verification jobs for your account with pagination.
Query parameters
| Parameter | Description |
|---|---|
limit | Number of results, 1 to 100 (default: 50). Any other value, including 0, returns HTTP 400 rather than being clamped |
offset | Opaque pagination cursor. Pass the next_offset value from the previous page; the response also includes has_more |
status | Filter by job status |
type | Pass batch to list only batches and uploaded files, including older saved lists. Omit it to include single-address verifications. Any other value returns HTTP 400. This filter is applied on the server as the job index is read, and to the reported total, so a page can be short or empty (see below) |
Response
{
"success": true,
"data": {
"jobs": [
{
"job_id": "9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90",
"filename": "emails.csv",
"total_emails": 1000,
"processed_emails": 1000,
"status": "completed",
"summary": { "valid": 800, "invalid": 100, "risky": 50, "unknown": 50 },
"created_at": "2026-02-03T14:30:00.000Z",
"completed_at": "2026-02-03T14:35:42.000Z"
},
{
"job_id": "063a7a5f-a27c-49ff-ab6b-a29bf3bb81b2",
"job_type": "single_verification",
"filename": null,
"total_emails": 1,
"processed_emails": 1,
"status": "completed",
"summary": { "valid": 0, "invalid": 1, "risky": 0, "unknown": 0 },
"created_at": "2026-02-03T14:20:17.310Z",
"completed_at": "2026-02-03T14:20:17.464Z"
}
],
"limit": 50,
"total": 1395,
"total_is_approximate": false,
"has_more": true,
"next_offset": "eyJ1c2VyX2lkIjoi..."
}
}filename is the uploaded file's name for file jobs and null for batch and single verifications (single verifications made through POST /v1/verify are listed here too, as one-email jobs, including a cached result, "from_cache": true, from October 7, 2026, although its own response carries no job_id; earlier cached answers appear in Credit history only). A single verification's row also carries "job_type": "single_verification"; batch and file rows usually have no job_type, and an older one can carry another label, so read anything other than single_verification as a batch. limit is the page size that was applied. total counts every job matching the listing, not just the page returned; total_is_approximate: true means the count stopped at its scan bound, so total is a floor. When has_more is false, next_offset is null.
A page can be short, or empty, before the end
The status and type filters are applied as the job index is read, and one request reads a bounded part of it, so a page can hold fewer jobs than limit, or none, while has_more is still true. type=batch reads an index of batch and file jobs only, so it does not walk past single verifications; its pages are short only where status or the retention window also filter them. Keep sending next_offset back as offset until has_more is false; stopping at a short or empty page misses older jobs.
Credits
- 1 credit per email in batch
- Credits deducted upfront when job is created
- Emails that cannot be queued are refunded automatically; cached results are billed at 0.5 credits (the discount is reconciled on completion; on a cancelled job it applies to the addresses checked before the cancel, right after the refund for the rest)
- Credits held for emails that are never checked are refunded automatically
- The verdict does not change the price:
unknown,riskyandcatch_allrows keep their full 1 credit. They are never served from the cache, so the 0.5 rate never applies to them