---
title: "Download a job's results"
method: GET
path: "/v1/jobs/{id}/download"
tags: ["Jobs"]
---

# Download a job's results

`GET /v1/jobs/{id}/download`

## Request

- Base URL: `https://api.kawaa.com`
- URL: `https://api.kawaa.com/v1/jobs/{id}/download`
- 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:read`)

The whole result set as CSV or JSON, optionally filtered to one status. A large result set is answered with a short-lived presigned URL rather than inline.

**Check `complete` before treating the file as the job's results.** The export is read from an eventually-consistent index, so a job that has just flipped to `completed` can be exported before its last rows are visible. Every JSON response here (the inline one and the presigned-link one) carries `complete`, `expected_rows` and `rows_missing`, measured against the job's own `processed_emails`. A short JSON export is reported, not refused: the index normally catches up within seconds, so request it again.

**A `format=csv` export that is known to be short is refused with `409` (`retryable: true`), not served.** The bytes of a CSV have nowhere to carry the verdict, and a file that looks complete and is not is worse than no file. The refusal names the shortfall and offers `format=json`, which returns the rows that do exist together with the shortfall, inline. Note the size of a *filtered* export does not bound this: a selective filter on a large job can produce a handful of rows from a read that was badly short.

**An empty read is not automatically a 404.** If the job counts addresses it finished and none of their rows are readable, the route answers `409` with `retryable: true` rather than `404 No results found for this job`. Retrying is the right next action and also distinguishes the two possible causes: the index catching up, which clears in seconds, or the rows having passed the plan's data-retention window, in which case they are gone. A job that genuinely produced no rows still answers 404.

The presigned URL is bound to the exact object version this request wrote, so the `complete` it arrives with describes the bytes it serves, even when two requests for the same export overlap. If that version cannot be bound, the route answers `503` rather than hand out an unbound link.

## Path parameters

- `id` string, uuid, required

## Query parameters

- `format` string
- `filter` string

## Response `200`

The rows themselves, or a link to them when the set is large. Neither form is wrapped in the standard `{success, data}` envelope, so a deserializer expecting one rejects every successful download. `format=csv` answers with `text/csv`, not JSON.

- union
  - JobResultsInline: `format=json` under the inline threshold. NOT the standard envelope: this route answers with the object itself, so there is no `success` or `data` wrapper to unwrap. Carries the completeness verdict: read `complete` before treating `results` as the job.
    - `job_id` string, uuid, required
    - `status` Status, required (unresolved $ref)
    - `result_count` integer, required
    - `complete` boolean, nullable, required: Whether this export is the job's whole result set. `true` when the rows returned reach the job's own `processed_emails`; `false` when they fall short; `null` when the job row carries no count to compare against, which is "not verified" and never a stand-in for "complete". The export is read from an eventually-consistent index, so a job that has just finished can be exported before its last rows are visible: a short file that used to be reported as the job's results with nothing saying otherwise. Measured on the UNFILTERED read, so a `filter=` export inherits the verdict of the read it was narrowed from.
    - `expected_rows` integer, nullable, required: The job's `processed_emails` (how many addresses it finished), which is what the export was counted against. `null` when the job row carries none.
    - `rows_missing` integer, nullable, required: `expected_rows` minus the rows the read returned, or `0` when nothing is missing. `null` when completeness was not verified. Never negative: a read returning more than `processed_emails` is not short. Measured on the UNFILTERED read, like `complete`, so on a `filter=` export it counts the rows missing from the whole job, not from the filtered file: `result_count` can be far below `expected_rows` while `rows_missing` is `0`.
    - `message` string: Present only when `complete` is not `true`. Says what is missing and what to do: normally request the download again in a few seconds, because the index catches up. A persistent shortfall means those rows aged out of the plan's data-retention window.
    - `results` DownloadedResult[], required
      - `email` string, required
      - `status` string, required
      - `score` number, required: The quality score, named `score` in this shape.
      - `disposable` boolean
      - `role` boolean
      - `free_provider` boolean
      - `catch_all` boolean: Whether a mailbox probe found an accept-everything domain. Omitted when no probe measured it (a disposable or spam-trap verdict, a DNS failure, a refused or unanswered mailbox, an inconclusive probe); absent is not `false`. In `format=csv` the cell is empty.
      - `suggestion` string, nullable
      - `mx_records` string[]
      - `smtp_response` string, nullable
      - `verified_at` string, date-time, nullable
      - `sub_status` string: The reason code behind `status`, the same value as `sub_status` on the verification result (see `SubStatus`; new values can be added). Omitted when the result has none, as on a disposable or spam-trap verdict whose DNS lookups answered; in `format=csv` the cell is empty. A disposable or spam-trap verdict whose DNS lookup failed carries `temporary_failure`: the verdict stands, but the domain checks beside it were not measured. The last column, so the columns before it keep their positions.
  - JobResultsLink: Returned instead of the rows when the result set is over the inline threshold, whatever `format` says, so a large CSV export is announced through this object. Also unwrapped. The link is time-limited and anyone holding it can read the results, which contain customer addresses. Read `complete` before treating the file as the job: the link is minted for whatever the index returned, including a short read.
    - `download_url` string, uri, required
    - `format` 'json' | 'csv', required
    - `result_count` integer, required
    - `complete` boolean, nullable, required: Whether this export is the job's whole result set. `true` when the rows returned reach the job's own `processed_emails`; `false` when they fall short; `null` when the job row carries no count to compare against, which is "not verified" and never a stand-in for "complete". The export is read from an eventually-consistent index, so a job that has just finished can be exported before its last rows are visible: a short file that used to be reported as the job's results with nothing saying otherwise. Measured on the UNFILTERED read, so a `filter=` export inherits the verdict of the read it was narrowed from.
    - `expected_rows` integer, nullable, required: The job's `processed_emails` (how many addresses it finished), which is what the export was counted against. `null` when the job row carries none.
    - `rows_missing` integer, nullable, required: `expected_rows` minus the rows the read returned, or `0` when nothing is missing. `null` when completeness was not verified. Never negative: a read returning more than `processed_emails` is not short. Measured on the UNFILTERED read, like `complete`, so on a `filter=` export it counts the rows missing from the whole job, not from the filtered file: `result_count` can be far below `expected_rows` while `rows_missing` is `0`.
    - `message` string: Present only when `complete` is not `true`. Says what is missing and what to do: normally request the download again in a few seconds, because the index catches up. A persistent shortfall means those rows aged out of the plan's data-retention window.
    - `expires_in_seconds` integer, required

## Other responses

- `400`: The request is malformed, or names a field this endpoint does not accept. Retrying it unchanged will fail identically.
- `401`: No credential, or one that is not accepted. `code` distinguishes `API_KEY_SUSPENDED` (subscription ended; restartable) from `API_KEY_REVOKED`, `SESSION_EXPIRED` and `OAUTH_TOKEN_EXPIRED` (a connected app's one-hour access token lapsed: refresh it and retry, or sign in again if no refresh token was issued or the refresh is refused).
- `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`: No such resource on this account.
- `409`: The export could not be given to you as asked, for one of two reasons. Both are `retryable: true`, and the message says which. **The job counts results and none of their rows are readable.** Either the results index has not caught up, which clears within seconds, or the rows have passed the data-retention window for the job's plan, in which case they are gone and retrying will keep returning this. The route cannot tell those apart; retrying distinguishes them at no cost, which is why it is marked retryable, but do **not** present this to a user as "nothing was lost". **A `format=csv` export is known to be short.** A CSV body has nowhere to report a shortfall, so it is refused rather than handed over looking complete. The message names the missing row count; ask for `format=json` to read the rows that do exist right now, with the shortfall alongside. Nothing is charged for either.
- `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.
- `503`: Told apart by `error.code`. **`RESULT_SUPERSEDED`** (`retryable: false`): a result in this job was produced by an older version of the verification classifier (for example a free-mail address stored as `disposable` before the provider exception), and the API withholds the whole export rather than publish a verdict the current classifier would not give, or silently drop the row. A completed job's rows are final, so the same request is refused the same way after any wait, and it carries no `Retry-After`. Verify those addresses again (a new job, charged as usual) to get current verdicts. Until 2026-10-02 this was `SERVICE_UNAVAILABLE` with `retryable: true`, and clients retried it as an outage. **`SERVICE_UNAVAILABLE`** (`retryable: true`): for example, a large export was written but could not be linked to the exact version this request produced, so no link was handed out. Try again.
- `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)
