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, 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), 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 to receive the completion notification |
webhook_secret | string | Optional | HMAC key used to sign the completion webhook (X-Kawaa-Signature). Requires webhook_url; max 256 characters |
include_activity | boolean | Optional | Include activity data in per-email results when available |
Any other field, at the top level or inside options, is rejected with 400 BAD_REQUEST naming 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, 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. 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 throughout its retention window (at least 30 days); only the fully refunded rotation described above changes that current job. The Node and Python SDKs send one automatically from 0.2.0; earlier releases and the other SDKs do not.
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-app.com/webhook",
"webhook_secret": "your-signing-secret"
}'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."
}
}File upload
/v1/verify/fileUpload a CSV 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"
}'
# Response
{
"success": true,
"data": {
"upload_url": "https://kawaa-uploads.s3.amazonaws.com/uploads/...",
"file_key": "uploads/user_abc123/2c9c1f8e-.../emails.csv",
"expires_in_seconds": 900,
"next_step": "Upload file to upload_url using PUT, then call this endpoint with action=process and file_key"
}
}Step 2: Upload the file
curl -X PUT "UPLOAD_URL_FROM_STEP_1" \
--data-binary @emails.csvStep 3: Start processing
curl -X POST https://api.kawaa.com/v1/verify/file \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Idempotency-Key: 6f1c2a4e-3b7d-4e0a-9c11-2d8f5a7b9e01" \
-H "Content-Type: application/json" \
-d '{
"action": "process",
"file_key": "uploads/user_abc123/2c9c1f8e-.../emails.csv",
"webhook_url": "https://your-app.com/webhook"
}'
# Response (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,
"message": "File verification started. 12 duplicate(s) removed. Use GET /v1/jobs/{job_id} to check status."
}
}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:
- Comma-, semicolon- or tab-separated (detected from the first line) with optional headers; the first column containing an
@is used - TXT:
- One email per line
- Max file size:
- 50MB (all plans)
- Max emails per file:
- your plan's batch cap — 100 (Free) up to 10,000 (Enterprise)
- 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 |
limit | Results per page (default: 100, max: 1,000) |
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), plus has_more and next_key for the next page. Each result 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. For a full download in one request use GET /v1/jobs/{job_id}/download below.
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",
"credits_used": 1000,
"credits_refunded": 0,
"billing_status": "charged",
"results": [ { "email": "john@example.com", "status": "valid", "sub_status": "mailbox_exists", "quality_score": 91 } ],
"has_more": true,
"next_key": "eyJqb2JfaWQiOi..."
}
}Job status values
| Status | Description |
|---|---|
pending | Accepted and queued; no email has been verified yet |
processing | Verification in progress |
completed | All emails verified |
failed | Job failed (check error_message) |
cancelled | Reserved. There is no way to stop a running job today, and nothing produces this status — once accepted, a job runs until it completes or fails. Size the batch before you send it. |
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) |
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,
"results": [
{ "email": "john@example.com", "status": "valid", "sub_status": "mailbox_exists", "quality_score": 91 },
{ "email": "jane@example.com", "status": "valid", "sub_status": "mailbox_exists", "quality_score": 88 }
]
}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 pre-signed link, no API key needed) within expires_in_seconds to get the CSV or JSON file:
{
"download_url": "https://kawaa-uploads.s3.amazonaws.com/downloads/...",
"format": "csv",
"result_count": 4812,
"expires_in_seconds": 3600
}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.
Example
# Small job: the body is the CSV
curl -X GET "https://api.kawaa.com/v1/jobs/9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90/download?format=csv&filter=valid" \
-H "X-Api-Key: YOUR_API_KEY" \
-o results.csv
# Any size: save the body, then follow the envelope if one came back
curl -s "https://api.kawaa.com/v1/jobs/9f8f6d2e-4c1b-4f6e-9a3b-2d1e5c7a8b90/download?format=csv&filter=valid" \
-H "X-Api-Key: YOUR_API_KEY" -o results.csv
if jq -e .download_url results.csv >/dev/null 2>&1; then
curl -s "$(jq -r .download_url results.csv)" -o results.csv
fiList all jobs
/v1/jobsList all verification jobs for your account with pagination.
Query parameters
| Parameter | Description |
|---|---|
limit | Number of results (default: 50, max: 100) |
offset | Opaque pagination cursor. Pass the next_offset value from the previous page; the response also includes has_more |
status | Filter by job status |
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",
"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"
}
],
"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). 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.
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)
- Credits held for emails the workers never process are refunded by job reconciliation