---
title: "Verify a CSV, TSV or TXT upload"
method: POST
path: "/v1/verify/file"
tags: ["Verification"]
---

# Verify a CSV, TSV or TXT upload

`POST /v1/verify/file`

## Request

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

Two steps. `action: "get_upload_url"`, with the file's `filename` and its `file_size` in bytes, returns a presigned URL and a `file_key`; PUT the file to that URL, then call again with `action: "process"` and the `file_key`. The URL accepts exactly `file_size` bytes, up to 50 MB.

**CSV, TSV and TXT only.** An `.xlsx` filename is refused at the handshake with `400 Only CSV, TSV and TXT files are supported`, before any upload, and a workbook uploaded under a text name is refused by `process` with `400 This looks like an Excel file. Save it as CSV UTF-8 and upload again.` The name also says how the file is read: a `.csv` or `.tsv` as delimited records whatever `Content-Type` it was PUT with, and a `.txt` one address per line unless it was PUT with a CSV `Content-Type` such as `text/csv`. Text in UTF-8, UTF-16 (with or without a byte-order mark) or Windows-1252 (Excel's plain CSV on Windows) is read as such.

**Addresses come from one column**, which the response names in `email_column`: the column whose header names it (`Email`, `E-mail`, `E-Mail-Adresse`, `Correo electrónico`, `Courriel` and other languages), else the first header that mentions email with addresses under it, preferring one that is not a manager's, owner's, CC or second address, else the column with the most addresses. The full rule is at https://kawaa.com/docs/api/batch/#file-upload.

**What `process` charges: one credit per address**, counted after invalid entries and duplicates are removed, and debited up front: a 50,000-row file with 9,300 distinct valid addresses is a 9,300-credit debit before any verification starts. Check the balance first; the plan's batch ceiling also applies. An address that definitively fails to enqueue is refunded; one whose dispatch is ambiguous is reported in `enqueue_unknown` with its credits in `credits_held` until reconciliation settles it.

The `process` action accepts `Idempotency-Key`. Any field the chosen action does not read is a 400 rather than silently ignored. A `webhook_secret` alongside `webhook_url` signs the completion callback, as on `POST /v1/verify/batch`; without one the callback is delivered **unsigned**, so treat its body as a hint to go and read `GET /v1/jobs/{job_id}` rather than as evidence.

## Headers

- `Idempotency-Key` string

## Request body

- union
  - FileUploadUrlRequest
    - `action` 'get_upload_url', required
    - `filename` string, required: The name to store the upload under. **Measured after trimming**: the value is trimmed before its length and extension are checked, so `" report.csv "` is accepted and stored as `report.csv`. No `pattern` or `maxLength` is declared here for that reason: an end-anchored pattern and a raw length check both refuse values this route accepts. The trimmed name must be 1-255 characters and end in `.csv`, `.tsv` or `.txt`; `.csv` on its own is an accepted name.
    - `file_size` integer, required: The size of the file in bytes, as it will be uploaded. The upload URL is signed for exactly this many bytes, so S3 refuses a body of any other length (`403 SignatureDoesNotMatch`); ask for a new URL if the file changes. Required: a missing, `null`, fractional or non-positive value is `400`, and a file over 50 MB (52,428,800 bytes) is `413 BATCH_SIZE_EXCEEDED` before any upload.
  - FileProcessRequest
    - `action` 'process', required
    - `file_key` string, required: The key returned by `get_upload_url`. The TRIMMED value must be 1-1,024 characters and must sit under your own upload prefix. **Measured after trimming.** the value is trimmed before its length is checked, so no length bound is declared on the raw string here: a raw `maxLength` refuses a valid value padded with spaces, and a raw `minLength` accepts a whitespace-only one the route then rejects with 400.
    - `webhook_url` string: Where to POST the completion callback. The TRIMMED value must be at most 2,048 characters and must use HTTPS. **Measured after trimming.** the value is trimmed before its length is checked, so no length bound is declared on the raw string here: a raw `maxLength` refuses a valid value padded with spaces, and a raw `minLength` accepts a whitespace-only one the route then rejects with 400. HTTPS only: anything else is refused with 400 WEBHOOK_URL_INVALID. Omit the field for no callback; an explicit `null` is refused with 400 before the file is read or credits are charged. Without a `webhook_secret` the callback is unsigned. No `format: uri` or anchored scheme `pattern` is declared on this REQUEST field: the URL is parsed first, which tolerates surrounding whitespace and normalizes the protocol, so `" https://x"` and `"HTTPS://x"` are both accepted while either constraint refuses them. HTTPS is required, checked on the PARSED protocol, not on the string you sent. The response fields keep `format: uri`, because what the server hands back really is a well-formed URI.
    - `webhook_secret` string: Signs the callback to `webhook_url`, as on `POST /v1/verify/batch`. 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. Without it the callback is UNSIGNED and anything that can reach your URL can post a convincing completion. 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 file is read or credits are charged. Part of the request an `Idempotency-Key` identifies: the same key with a different secret is `409 IDEMPOTENCY_KEY_CONFLICT`.
    - `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

## Response `200`

A presigned upload URL and the key to submit with it (for `get_upload_url`). Nothing is charged by this step.

- object
  - `success` unknown, required
  - `data` object, required
    - `upload_url` string, uri, required: PUT the file here, unchanged, with no extra headers. It accepts exactly the `file_size` declared: a body of any other length is refused with `403 SignatureDoesNotMatch`. It is a presigned S3 URL: anyone holding it can write that one object, so treat it as a credential.
    - `file_key` string, required: Pass this back as `file_key` on the `process` call. It is scoped to your account: a key from another account is refused with 403.
    - `expires_in_seconds` integer, required: How long `upload_url` stays valid. Ask for a new one rather than retrying an expired PUT.
    - `next_step` string: The same instruction in prose, for a caller reading the response rather than this document.

## Other responses

- `202`: The file was accepted and charged (for `process`). Poll `GET /v1/jobs/{job_id}`; the counts here describe what was accepted, not what was verified. **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.
- `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.
- `404`: `action: "process"` named a `file_key` with no object behind it: the presigned PUT was never completed, or the upload failed. Recoverable and free: nothing was charged, because the charge happens after the file is read. Get a fresh URL with `action: "get_upload_url"`, upload again, and process the new key. Distinct from the 500 below, which happens AFTER the debit.
- `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`: `BATCH_SIZE_EXCEEDED`: the file is more than one job takes: more distinct addresses than the plan allows, more than 100,000 distinct addresses on any plan, or over 50 MB (`get_upload_url` refuses a `file_size` over 50 MB the same way, before the upload). Also a file that could not be read within 20 seconds of the request arriving, such as millions of blank or repeated rows: it is refused rather than charged after the connection would have timed out. (When the 20 seconds ran out before the file was read at all, the answer is the 503 below, not this.) The message quotes the limit that applies; counting stops there, so it says "more than" the limit rather than the file's total. A capacity refusal, not malformed input, answered the same way as a batch over its limit. Nothing was charged. Split the file and submit the parts as separate jobs.
- `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 file was charged for, and **the message says what that means for the money**. A keyed attempt that could not be recovered asks you to retry with the SAME `Idempotency-Key`. A file job 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 file 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`, and a repeat with the SAME key returns the job if it was charged or charges it once if it was not; without a key a repeat may be charged again, so check the balance or credit history first. Do not retry with a fresh key, and do not assume the debit was restored. Read `error.message` and check `GET /v1/jobs`.
- `503`: `retryable: true`. All but one of these charged nothing; the one that charged never carries `nothing_charged: true`, and its message says it was charged. Among the reasons: - The retry protection for this `Idempotency-Key` could not be resolved: its receipt was unreadable, a concurrent attempt is still confirming, or an earlier attempt was refunded. Nothing was charged for this request. **Retry with the SAME key**: a fresh one starts a new paid job. - A request without an `Idempotency-Key` could not reserve its job before the charge. Nothing was charged: send the same request again. - **Charged:** with an `Idempotency-Key`, the file 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 with the SAME key and the uploaded file unchanged, after its `Retry-After` (3 seconds) and within 15 minutes of sending it: the repeat queues them and is not charged again. A repeat sooner is told the file is still being queued (`202`, `dispatch_in_progress`) and queues nothing. Any email that is never verified is refunded automatically. - The request used up its 20-second charge budget before the file was read at all (authentication, the key's receipt, the webhook's DNS check, a slow start or the first read of the uploaded file took it), so no job was started. The file was not the cause: send the same request again, with the same `Idempotency-Key` if it had one. A file that is itself too slow to read is the 413 instead.
- `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)
