# Octeth email verification API

The Octeth email verification API checks whether email addresses can receive mail, from your own code. Check one address and get the answer at once, or send a list of up to 50,000 addresses (or a CSV file) that runs in the background, then page or download the results. Every check costs 1 credit per unique address from your team's balance in the Octeth Client Portal (https://my.octeth.com/email-verification). Results group each address as safe, risky, invalid or unknown.

This document: https://my.octeth.com/email-verification/api.md

## Authentication

Send a team API key as a bearer token on every request. Send JSON with Accept: application/json.

```http
Authorization: Bearer YOUR_API_KEY
Accept: application/json
```

Owners and Admins of the team create keys on the API tab of Email verification: https://my.octeth.com/email-verification?tab=api. A key is shown once, when it is created.

Octeth installations authenticate with their license key and need no setup beyond the Octeth installations tab.

## Base URL

```text
https://my.octeth.com/api/email-verification/v1
```

Paths are relative to the base URL. Replace YOUR_API_KEY and LIST_ID with your own values. JSON in and out, except the CSV download.

## Endpoints

### Ping: `GET /ping`

Checks that the key works. Costs no credit and checks nothing.

caller is api_key or installation. A switched-off Octeth installation gets 403 installation_disabled with enable_url.

Request:

```bash
curl -sS https://my.octeth.com/api/email-verification/v1/ping \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Response:

```json
{ "ok": true, "caller": "api_key" }
```

### Credits: `GET /credits`

The team's credit balance.

Request:

```bash
curl -sS https://my.octeth.com/api/email-verification/v1/credits \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Response:

```json
{ "credits": 4210 }
```

### Verify one address: `POST /verify`

Checks one address and waits for the result. Costs 1 credit.

group is one of safe, risky, invalid, unknown. reasons says why a risky address is risky: accept_all, role, disposable, low_quality.

reason is the mail server outcome: accepted, mailbox_not_found, invalid_syntax, invalid_domain, smtp_error, smtp_unavailable, no_connection, timeout, low_deliverability, low_quality, unknown_error. domain, mail_server and duration (seconds) can be null. duration is set for single checks only.

Request:

```bash
curl -sS -X POST https://my.octeth.com/api/email-verification/v1/verify \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "ayse@acme.com"}'
```

Response:

```json
{
  "email": "ayse@acme.com",
  "group": "risky",
  "reasons": ["role"],
  "flags": {
    "accept_all": false,
    "role": true,
    "disposable": false,
    "free": false,
    "mailbox_full": false,
    "no_reply": false
  },
  "did_you_mean": null,
  "reason": "accepted",
  "domain": "acme.com",
  "mail_server": "mx1.acme.com",
  "duration": 0.493
}
```

### Create a list: `POST /lists`

Creates a list from a file upload or an emails array and starts it. Costs 1 credit per unique address. Answers 201.

Send name and exactly one of file (CSV, TSV or TXT, the email column is found automatically) or emails (1 to 50,000 strings). Duplicates are charged once.

File upload:

```bash
curl -sS -X POST https://my.octeth.com/api/email-verification/v1/lists \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F name="May signups" \
  -F file=@signups.csv
```

Request:

```bash
curl -sS -X POST https://my.octeth.com/api/email-verification/v1/lists \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "May signups", "emails": ["ayse@acme.com", "bob@acme.com"]}'
```

Response:

```json
{
  "id": "0b8c1c4e-5d0f-4a52-9f0e-2f0c5a7f6a11",
  "name": "May signups",
  "status": "processing",
  "progress": 0,
  "emails": 2,
  "credits_charged": 2,
  "credits_returned": 0,
  "counts": {
    "safe": 0, "risky": 0, "invalid": 0, "unknown": 0,
    "reasons": { "accept_all": 0, "role": 0, "disposable": 0, "low_quality": 0 }
  },
  "created_at": "2026-10-23T09:00:00+00:00",
  "finished_at": null,
  "purge_at": null,
  "purged": false
}
```

### List status: `GET /lists/{id}`

One list: status, progress and counts. Poll it every 10 seconds while it runs.

status is processing, then completed or failed. Addresses that could not be checked are credited back (credits_returned). purge_at is when the results are deleted, and purged turns true after that.

Request:

```bash
curl -sS https://my.octeth.com/api/email-verification/v1/lists/LIST_ID \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Response:

```json
{
  "id": "0b8c1c4e-5d0f-4a52-9f0e-2f0c5a7f6a11",
  "name": "May signups",
  "status": "completed",
  "progress": 100,
  "emails": 2,
  "credits_charged": 2,
  "credits_returned": 0,
  "counts": {
    "safe": 1, "risky": 1, "invalid": 0, "unknown": 0,
    "reasons": { "accept_all": 0, "role": 1, "disposable": 0, "low_quality": 0 }
  },
  "created_at": "2026-10-23T09:00:00+00:00",
  "finished_at": "2026-10-23T09:01:12+00:00",
  "purge_at": "2026-11-02T09:01:12+00:00",
  "purged": false
}
```

### List results: `GET /lists/{id}/results`

Per-address results in file order, cursor-paged. Addresses without a result yet are not listed.

groups[] filters by group (default: all). reasons[] narrows the risky group (default: every risky address). per_page is 1 to 1000 (default 100). Pass next_cursor as cursor for the next page.

Request:

```bash
curl -sS -g "https://my.octeth.com/api/email-verification/v1/lists/LIST_ID/results?groups[]=risky&reasons[]=role&per_page=500" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Response:

```json
{
  "data": [
    {
      "email": "info@acme.com",
      "group": "risky",
      "reasons": ["role"],
      "flags": { "accept_all": false, "role": true, "disposable": false, "free": false, "mailbox_full": false, "no_reply": false },
      "did_you_mean": null,
      "reason": "accepted",
      "domain": "acme.com",
      "mail_server": "mx1.acme.com",
      "duration": null
    }
  ],
  "next_cursor": "eyJpZCI6MTAwLCJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9"
}
```

### Download results: `GET /lists/{id}/download`

The results as a CSV file, with the same groups[] and reasons[] filters.

original=1 (default) adds the columns of the uploaded file. result=1 (default) adds status, reasons, accept_all, role, disposable, free, did_you_mean. At least one of the two must be on.

Request:

```bash
curl -sS -g "https://my.octeth.com/api/email-verification/v1/lists/LIST_ID/download?groups[]=safe&original=1&result=1" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -o results.csv
```

Response:

```csv
email,status,reasons,accept_all,role,disposable,free,did_you_mean
ayse@acme.com,safe,,0,0,0,0,
info@acme.com,risky,role,0,1,0,0,
```

### All lists: `GET /lists`

The lists of the team, newest first, cursor-paged. An Octeth installation sees only its own lists.

per_page is 1 to 100 (default 25). Pass next_cursor as cursor for the next page.

Request:

```bash
curl -sS "https://my.octeth.com/api/email-verification/v1/lists?per_page=25" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Response:

```json
{
  "data": [
    {
      "id": "0b8c1c4e-5d0f-4a52-9f0e-2f0c5a7f6a11",
      "name": "May signups",
      "status": "completed",
      "progress": 100,
      "emails": 2,
      "credits_charged": 2,
      "credits_returned": 0,
      "counts": { "safe": 1, "risky": 1, "invalid": 0, "unknown": 0, "reasons": { "accept_all": 0, "role": 1, "disposable": 0, "low_quality": 0 } },
      "created_at": "2026-10-23T09:00:00+00:00",
      "finished_at": "2026-10-23T09:01:12+00:00",
      "purge_at": "2026-11-02T09:01:12+00:00",
      "purged": false
    }
  ],
  "next_cursor": null
}
```

## Bulk flow

Bulk lists run in the background. Create a list, poll its status every 10 seconds until it is completed or failed, then page the results or download the CSV.

1. `POST /lists` with a name and a file or an emails array. Keep the `id` of the answer.
2. `GET /lists/{id}` every 10 seconds until `status` is `completed` or `failed`.
3. `GET /lists/{id}/results` (paged with `next_cursor`) or `GET /lists/{id}/download` (CSV), before `purge_at`.

## Errors

Every error is a JSON body with error (the code) and message, plus the extras named below.

```json
{ "error": "not_enough_credits", "message": "…", "needed": 2, "available": 0 }
```

| Status | Code | When |
|---|---|---|
| 401 | `key_missing` | No Authorization header, or an empty key. |
| 401 | `key_invalid` | The key is unknown, revoked or not an email verification key. |
| 402 | `not_enough_credits` | The balance is below the cost. The body adds needed and available. |
| 403 | `installation_disabled` | The Octeth installation is switched off on the Octeth installations tab. The body adds enable_url. |
| 403 | `daily_cap_reached` | The Octeth installation used its daily credit cap. |
| 404 | `list_not_found` | No such list for this key. A list of another team or installation is also 404. |
| 404 | `not_found` | Any other path. |
| 405 | `method_not_allowed` | The path does not take this method. |
| 410 | `list_purged` | Results or download of a list after its retention period. |
| 413 | `payload_too_large` | The request body is too large. |
| 422 | `invalid_input` | A parameter is missing or not valid. The body adds errors, per field. |
| 429 | `rate_limited` | Too many requests, or too many failed key attempts from one IP. Wait for the Retry-After seconds. |
| 500 | `server_error` | Something went wrong on our side. Retry later. |
| 503 | `provider_unavailable` | The address could not be checked right now. The credit is returned. Retry in a minute. |

## Limits and retention

- 120 requests a minute per key or Octeth installation.
- 10 new lists a minute.
- 1 to 50,000 addresses in one emails array.
- Files up to 50 MB and 1,000,000 rows.
- 60 failed key attempts an hour per IP.
- Results and files are deleted 10 days after the list finishes. Single checks keep their address for the same time.

## For AI assistants and code generators

Follow these rules when you write or run code against this API.

- Start with `GET /ping`. It confirms the key works, costs no credit and checks nothing. Then read the balance with `GET /credits`.
- Use `POST /verify` for one address at a time. It waits for the answer, so give the HTTP client a timeout of at least 30 seconds. Use `POST /lists` for more than a few addresses.
- Poll `GET /lists/{id}` every 10 seconds, never faster. On `429`, wait for the `Retry-After` seconds and double the interval. Stay under 120 requests a minute per key.
- Never retry POST /lists blindly. Each call that succeeds creates a new list and charges for it again. After a timeout or a network error, call `GET /lists` first and look for a list with the same name created in the last few minutes. Retry only if there is none.
- Credits are charged up front, when the list is created: 1 per unique address. Addresses that could not be checked are returned when the list finishes (`credits_returned`). A `failed` list returns every credit.
- Results and files are deleted 10 days after the list finishes (`purge_at`). Page or download them as soon as the list completes. After that the list answers `410 list_purged`.
- Keep the key in an environment variable or a secret store on a server. Never put it in client-side code (browser JavaScript, mobile apps), a repository or logs.
- A `503` whose body has no `error` code is planned maintenance. Retry later with back-off, like `500`.
- Treat `risky` and `unknown` as their own outcomes. Do not merge them into `safe` or `invalid`.

What to do on each error code:

- `401 key_missing`: Stop. Send the Authorization header with a key.
- `401 key_invalid`: Stop and ask for a working key. Retrying with the same key never helps and counts as a failed attempt.
- `402 not_enough_credits`: Stop. Report needed and available, and ask the team to buy credits. Nothing was charged.
- `403 installation_disabled`: Stop. Show enable_url, where an Owner or Admin switches the installation on.
- `403 daily_cap_reached`: Stop until the next day (UTC). The cap is set on the Octeth installations tab.
- `404 list_not_found`: Do not retry. Check the list id. A list of another team or installation is never visible.
- `404 not_found`: Do not retry. Fix the path against this reference.
- `405 method_not_allowed`: Do not retry. Fix the HTTP method against this reference.
- `410 list_purged`: Do not retry. The results are gone. Create a new list if they are needed again, which costs credits again.
- `413 payload_too_large`: Split the input into smaller requests or upload it as a file.
- `422 invalid_input`: Fix the fields named in errors. Retrying the same request gives the same answer.
- `429 rate_limited`: Wait for the Retry-After seconds, then retry the same request and slow down.
- `500 server_error`: Retry later with back-off (for example 30 s, then 1, 2 and 5 minutes). For POST /lists, check GET /lists first.
- `503 provider_unavailable`: Retry the same address after a minute, with back-off. The credit was returned.

## Example prompt

> Using the API reference above, write a <language> client that reads the key from the OCTETH_API_KEY environment variable, calls GET /ping first, uploads a CSV file as a new list, polls the list every 10 seconds with back-off on 429, and saves the safe addresses as a CSV when the list completes. Handle every error code as the reference says.