Skip to main content

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

POST/v1/verify/batch

Submit an array of emails for asynchronous verification. Returns a job ID to track progress.

Request body

ParameterTypeRequiredDescription
emailsstring[]RequiredArray 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)
optionsobjectOptionalVerification options (e.g. deep_verify, include_ai)
webhook_urlstringOptionalHTTPS URL notified when the job completes or fails (job.completed or job.failed)
webhook_secretstringOptionalHMAC 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_activitybooleanOptionalInclude 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
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)

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

POST/v1/verify/file

Upload 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
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.

200 OK
{
  "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
curl -X PUT "UPLOAD_URL_FROM_STEP_1" \
  --data-binary @emails.csv

Step 3: Start processing

cURL
# 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.

202 Accepted
{
  "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 .csv file, read as CSV whatever Content-Type it 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 of sep=; (or sep=, or sep=|), 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 as email_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 as Jane Doe <jane@example.com>) is skipped and counted in invalid_emails_skipped.
TSV:
A .tsv file, read the same way as a CSV.
TXT:
A .txt file: 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 .txt uploaded with a CSV Content-Type such as text/csv is 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 (.xlsx or .xls) is refused with 400, 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_EXCEEDED and 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 is 503 SERVICE_UNAVAILABLE with retryable: 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/limits reports yours as limits.bulk_job_size.
Upload URL expiry:
15 minutes

Check job status

GET/v1/jobs/{job_id}

Query parameters

ParameterDescription
include_resultsEmbed 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
limitResults per page (default: 100, max: 1,000). A value outside 1–1,000 returns HTTP 400 rather than being clamped
next_keyOpaque 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

200 OK
{
  "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

StatusDescription
pendingAccepted and queued; no email has been verified yet
processingVerification in progress
completedFinished. Every address has a result, unless the job carries partial: true: then some have none and were refunded (see requested_emails above)
failedJob failed (check error_message)
cancelledStopped 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_unknownSome emails have unconfirmed queue status; credits are held and reconciled automatically

Download results

GET/v1/jobs/{job_id}/download

Query parameters

ParameterDescription
formatcsv or json (default: csv)
filterFilter 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:

200 OK · format=json
{
  "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:

ColumnDescription
emailThe address
statusThe 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
scoreThe quality score, 0–100 (quality_score in a verification result)
disposabletrue when the domain is on the list of disposable or temporary mail providers, or, with AI analysis on, its name looks like one
roletrue for a role address such as info@ or support@
free_providertrue for a free consumer provider such as Gmail
catch_alltrue 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
suggestionThe corrected address when the domain looks like a typo of a known one; null (an empty cell in CSV) otherwise
mx_recordsThe domain’s MX hosts: an array in JSON, joined with ; in CSV
smtp_responseThe mail server’s last SMTP reply; null (an empty cell in CSV) when no probe ran
verified_atWhen the check began (ISO 8601)
sub_statusThe 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:

200 OK · envelope
{
  "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:

FieldDescription
completetrue 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_rowsThe job's processed_emails: how many addresses it finished. null when the job carries none
rows_missingHow 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
messagePresent 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 complete is true; asking again a few seconds later normally returns the rest.
  • A short CSV of up to 1,000 rows is refused with 409 and "retryable": true, because a CSV body has nowhere to say it is incomplete. Retry it, or ask for format=json to 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 only format=json says so ("complete": null).
  • No readable rows at all for a job that finished some is also a retryable 409, not a 404. If it persists, the rows have passed your plan's retention window. A job that produced no rows answers 404.
  • A job that is still running answers 400 with "retryable": true; a job that failed before checking any address answers 400 for 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.

download.sh
#!/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 1

Cancel a job

POST/v1/jobs/{job_id}/cancel

Stops 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 cancelled as soon as this returns, and its results are final. The refund waits for the addresses already being checked to finish, so billing_status reads charged for up to about 11 minutes, then refunded. A cancel that lands after the last address owes nothing and keeps charged. Wait for completed_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_url nor as job.completed or job.failed to 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 200 with the job and tries the outstanding refund again; that does not guarantee the refund lands. Watch completed_at, and contact support if billing_status reads refund_failed, which no retry changes.
cURL
curl -X POST https://api.kawaa.com/v1/jobs/9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90/cancel \
  -H "X-Api-Key: YOUR_API_KEY"
200 OK
{
  "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"
  }
}
StatusWhen
400The job id is malformed, or the job is a single verification (it finishes in seconds and settles its own credit)
404No such job on your account
409The 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

GET/v1/jobs

List all verification jobs for your account with pagination.

Query parameters

ParameterDescription
limitNumber of results, 1 to 100 (default: 50). Any other value, including 0, returns HTTP 400 rather than being clamped
offsetOpaque pagination cursor. Pass the next_offset value from the previous page; the response also includes has_more
statusFilter by job status
typePass 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

200 OK
{
  "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, risky and catch_all rows keep their full 1 credit. They are never served from the cache, so the 0.5 rate never applies to them