# Get Tuski email validation results as JSON pages or a CSV export

Tuski validates a list as an asynchronous job. You create it from `csv_text` or `emails`, check its status when it is due to finish, then read the verdicts as JSON pages or a CSV export. Creating a job costs $0.01 per address per address. Status, results and cancel calls are listed at $0. Send every request to `https://agents.tuski.ai/zcj/c1qcbp0v9jms`. The create call answers with a 402 price quote that you pay and retry.

## At a glance

- **Create:** `POST https://agents.tuski.ai/zcj/c1qcbp0v9jms/api/v1/email-validation/jobs` with an `Idempotency-Key` header
- **Body:** exactly one of `csv_text` (a header row and an email column) or `emails` (a list), plus optional `fresh_probe`
- **Price:** $0.01 per address per address pay as you go, or from $0.0036 per address on prepaid credit.
- **Payment:** a 402 quote, paid over x402 or MPP (USDC wallet rails) or by card, then retried
- **Status:** `GET …/jobs/{job_id}`, listed at $0
- **Results:** `GET …/jobs/{job_id}/results` with `format` (`json` or `csv`), `limit` (1 to 5000), `offset` and `status`
- **Cancel:** `POST …/jobs/{job_id}/cancel`, listed as free
- **Full purchase flow:** [the buyer skill](https://agents.tuski.ai/zcj/c1qcbp0v9jms/SKILL.md)

## Validate a list and collect the results

1. Create the job. Send the list once, with a key of your own:

   ```http
   POST https://agents.tuski.ai/zcj/c1qcbp0v9jms/api/v1/email-validation/jobs
   Content-Type: application/json
   Idempotency-Key: crm-export-0001

   {"csv_text": "email\njane@example.com\nsam@example.com\nlee@example.com\n"}
   ```

   The storefront answers with a 402 that prices the job. Pay it and retry the same request. The retry returns `201` with this shape:

   ```text
   {"job_id": <integer>, "total": <integer>, "queued": <integer>,
    "projected_hours": <number or null>, "projected_finish_at": "<UTC, ISO 8601>",
    "long_queue": <boolean>, "idempotency": "explicit", "idempotent_hit": false}
   ```

   Store `job_id` and `projected_finish_at`. A retry with the same `Idempotency-Key` replays this job instead of creating a second one.

2. Check the job's status at `projected_finish_at`:

   ```http
   GET https://agents.tuski.ai/zcj/c1qcbp0v9jms/api/v1/email-validation/jobs/{job_id}
   ```

   Through the storefront the call answers at once with the current status. The response carries `status`, `total`, `queued`, `sent`, the per-outcome counters (`valid`, `invalid`, `unknown`, `unreachable`, `mailbox_full`, `blocked`, `soft_temp`, `routing_error`, `other_bounce`), `eta`, `paused_reason` and `error`. When `status` is `done`, fetch the results. Otherwise check again later, spacing the calls out.

3. Read the results as JSON pages:

   ```http
   GET https://agents.tuski.ai/zcj/c1qcbp0v9jms/api/v1/email-validation/jobs/{job_id}/results?format=json&limit=1000&offset=0
   ```

   The response has `job_id`, `rows`, `count`, `limit`, `offset` and `has_more`. While `has_more` is `true`, add `limit` to `offset` and request again.

4. Or export the results as CSV:

   ```http
   GET https://agents.tuski.ai/zcj/c1qcbp0v9jms/api/v1/email-validation/jobs/{job_id}/results?format=csv&limit=5000&offset=0
   ```

   A request with `format=csv&limit=10&offset=0` returns a `text/csv` body. The CSV body has no `has_more`. The headers carry it instead: `X-Total-Count` is the true row count for the job and filter, and `Link: <…>; rel="next"` appears while more rows remain, naming the next `offset`. Stop when the `Link` header is absent.

## Wait for a job without polling hard

The create response projects the finish, so plan one check per signal rather than a loop.

| Signal | What to do |
| --- | --- |
| `projected_hours` is a number | Check status at `projected_finish_at` |
| `projected_hours` is `null` | No mailbox can send, so no finish is projected |
| `long_queue` is `true` | Expect a run of over 48 hours |
| `status` is not yet `done` | Check again later, guided by `eta` |
| `429` | Wait for `Retry-After` |

Tuski's status route can hold a request open with `wait=<seconds>`, but it ignores `wait` on a ZeroClick-signed request, so through the storefront every status call answers at once. Space the checks out instead of looping.

## Filter the rows

`status` filters rows by their outcome. Omit it to get every row.

| Rows you want | `status` value |
| --- | --- |
| Finished outcomes | `valid`, `invalid`, `unknown`, `unreachable`, `mailbox_full`, `blocked`, `soft_temp`, `routing_error`, `other_bounce` |
| Not yet finished | `pending`, `queued`, `sending`, `sent` |
| Stopped by a cancel | `cancelled` |

Each row also carries `verdict`, one of `deliverable`, `catch_all`, `role`, `typo`, `invalid`, `full_box`, `no_mx`, `burner`, `no_reply`, `blocked`, `unknown`, `soft_temp`, `routing_error`, `other_bounce` or `not_judged`.

## Cancel a job

```http
POST https://agents.tuski.ai/zcj/c1qcbp0v9jms/api/v1/email-validation/jobs/{job_id}/cancel
```

The response has `job_id`, `cancelled`, `refunded`, `refunded_to`, `status` and `zeroclick_refund_note`. `refunded` counts one credit for each charged address stopped before it was sent. Addresses already sent still get a verdict, and their no-MX and send-failure refunds follow later. Cancelling a job that is `done` or `cancelled` changes nothing. For a job paid through the storefront, the refund lands in Tuski's shared wallet, not yours, and `zeroclick_refund_note` says so.

## Limits

- `emails` takes 1 to 100,000 entries, and the whole body is capped at 26,214,400 bytes.
- The create body takes only `emails`, `csv_text` and `fresh_probe`. It takes no callback URL.
- `fresh_probe: true` probes every address again, even one remembered from a recent check.

## Errors

| Status or code | Meaning | Action |
| --- | --- | --- |
| `402` | The price quote | Pay and retry, or stop |
| `404 job_not_found` | No such job, or another organization's | Check `job_id` |
| `413` | Request too large on create, or CSV export too large on results | On create, keep the body within 26,214,400 bytes and `emails` within 100,000 entries |
| `422 unknown_status_filter` | `status` is not a listed value | Use a value from the table above |
| `422` on create | Invalid body or missing `Idempotency-Key` | Fix the body, add the header |
| `429` | Rate limited | Wait for `Retry-After` |

Storefront guard failures come back as `{"error": ...}`. Tuski's own errors come back as `{"detail": ...}`.

## Frequently asked questions

### Does Tuski send a webhook when a validation job finishes?

No. The create body takes no callback URL. Check the status route at the job's `projected_finish_at`, then again at spaced intervals until its status is `done`.

### How do I know a CSV export has more rows?

Read the headers. `X-Total-Count` gives the total rows for the job and filter, and a `Link` header with `rel="next"` appears while rows remain past the current page. When it is absent, you have the last page.

### Can I paginate email validation results as JSON?

Yes. Set `format=json`, a `limit` up to 5000 and an `offset`. Keep requesting with the offset advanced by `limit` until `has_more` is `false`.

### Do status and results requests cost anything?

They are listed at $0. Only creating a job is charged, at $0.01 per address per address.

### What does cancelling a job refund?

One credit for each charged address that had not been sent. On a job paid through the storefront, the refund goes to Tuski's shared wallet, as `zeroclick_refund_note` states.

## Keep reading

- [Buyer skill](https://agents.tuski.ai/zcj/c1qcbp0v9jms/SKILL.md): the full purchase flow
- [How to pay](https://agents.tuski.ai/zcj/c1qcbp0v9jms/payment.md): rails, credit plans and top-ups
- [Email Validation API reference](https://agents.tuski.ai/zcj/c1qcbp0v9jms/services/email-validation/openapi.json): every field and response
- [Error reference](https://agents.tuski.ai/zcj/c1qcbp0v9jms/errors.md): storefront error codes

---

Updated 2026-10-22. All Tuski guides: https://agents.tuski.ai/guides.md. Storefront: https://agents.tuski.ai/zcj/c1qcbp0v9jms/llms.txt.
