# MailSift API Reference

- **Base URL:** `https://mailsift.dev/api/v1`
- **Authentication:** `X-API-Key: msk_...` header on every request.
- **Machine-readable:** [OpenAPI 3.1 spec](https://mailsift.dev/openapi.json)

Errors use the shape `{ "error": "message" }`. Common status codes: `400` invalid request, `401` unauthorized, `402` insufficient credits, `404` not found, `409` idempotency key in flight, `422` idempotency key reused for a different request, `429` rate limited.

---

## Verification modes

Every verification endpoint accepts an optional `mode` of `fast` or `deep`.

### fast (default)

The standard MailSift engine: syntax, normalization, domain and DNS, MX, disposable and temp-mail detection, relay and forwarder detection, role accounts, free providers, SPF, DMARC, typo suggestions, domain intelligence, domain age, local-part risk signals, catch-all detection, and the MailSift trust score.

**1 credit per email.**

### deep (Deep Verification)

Everything `fast` does, plus an SMTP conversation with the recipient's mail server to check the mailbox itself: mailbox acceptance, recipient-specific rejection, catch-all resolution, greylisting detection, provider-specific interpretation, and STARTTLS.

**Up to 5 credits per email.** Deep responses carry an extra `smtp` object.

### Backward compatibility

Omitting `mode` means `fast`. Existing integrations are unchanged: same latency, same 1-credit cost, same response fields. The `smtp` object appears only on deep responses. Fast responses do not include it at all.

### Latency

Standard verification is fast. Deep verification takes longer, because MailSift opens a connection to the recipient's mail server and waits for its replies, and that server's speed is outside our control. No exact completion time is promised for deep verification.

### What deep verification can and cannot prove

Deep verification confirms a mailbox where the receiving mail server supports it. Some providers deliberately conceal mailbox existence:

- Yahoo and AOL accept recipients they cannot confirm.
- Secure gateways such as Proofpoint and Mimecast accept every recipient at the perimeter and only decide later, behind the gateway.

For those, MailSift returns an honest `smtp.status` of `unknown`, usually with `catch_all: true`, instead of guessing. Deep verification does not prove every mailbox, and any product that claims otherwise is guessing on your behalf.

### `smtp.status` is not the overall status

This is the most common integration mistake. `smtp.status` describes only what the recipient's mail server said about the mailbox. The top-level `status` stays `valid | risky | invalid | unknown` and combines every signal.

An address can be SMTP-`deliverable` and still be overall `risky` because it is disposable, a role account, or an anonymized relay. Branch your business logic on the top-level `status`, and use `smtp` for the detail behind it.

### The `smtp` object

```json
{
  "checked": true,
  "status": "deliverable",
  "sub_reason": "mailbox_not_found",
  "response_code": 250,
  "enhanced_code": "2.1.5",
  "catch_all": false,
  "tls": true,
  "mx_host": "mx.example.com",
  "provider": "google",
  "latency_ms": 84
}
```

When no SMTP conversation ran:

```json
{ "checked": false, "status": "not_checked", "reason": "smtp_not_required" }
```

| Field | Description |
|-------|-------------|
| `checked` | Whether an SMTP conversation actually took place. |
| `status` | Mailbox-level outcome. See the table below. |
| `reason` | Why no conversation ran. Present only when `checked` is `false`. |
| `sub_reason` | Extra detail behind the status. Optional. |
| `response_code` | SMTP reply code, e.g. `250` or `550`. |
| `enhanced_code` | Enhanced status code, e.g. `"2.1.5"`. |
| `catch_all` | Whether the domain accepts every recipient. **Omitted entirely when the catch-all state is unknown**, so a missing field is not the same as `false`. |
| `tls` | Whether the conversation used STARTTLS. |
| `mx_host` | Mail host that answered. |
| `provider` | Recognized mail provider, e.g. `"google"`. |
| `latency_ms` | Time the SMTP conversation took. |

**`smtp.status` values**

| Value | Meaning |
|-------|---------|
| `deliverable` | The server accepted the recipient and its acceptance is trustworthy. |
| `undeliverable` | The server issued a recipient-specific permanent rejection. |
| `catch_all` | The domain accepts every recipient, so acceptance proves nothing about this mailbox. |
| `unknown` | The conversation completed but the answer is not conclusive, usually because the provider conceals mailbox existence. |
| `greylisted` | A temporary deferral typical of greylisting. A later retry usually succeeds. |
| `rate_limited` | The server is throttling right now. |
| `policy_blocked` | The server rejected on policy or reputation grounds aimed at the sender, not the recipient. |
| `timeout` | The server did not answer in time. |
| `not_checked` | No SMTP conversation was attempted. See `reason`. |

**`smtp.reason` values** (only when `checked` is `false`)

| Value | Meaning |
|-------|---------|
| `smtp_not_required` | The standard checks already settled the address. |
| `smtp_not_requested` | The caller asked for fast mode. |
| `smtp_unavailable` | The SMTP layer was not available for this request. |
| `smtp_no_mx` | The domain has no usable MX record. |
| `smtp_invalid_syntax` | The address is not well-formed. |
| `smtp_smtputf8` | The local part is internationalized (RFC 6531). The probe does not negotiate SMTPUTF8, so it does not ask. |
| `smtp_unsafe_mx` | The MX record resolved to an address MailSift will not connect to. |
| `smtp_throttled` | No probe slot was available in time. |
| `smtp_abuse_throttled` | The caller exceeded mailbox-enumeration limits. |

**`smtp.sub_reason` values** (optional)

`mailbox_not_found`, `mailbox_full`, `mailbox_disabled`, `domain_not_found`, `accept_all_gateway`, `provider_unverifiable`, `connection_failed`, `protocol_error`, `tls_required`, `sender_rejected`.

---

## Credits

One shared credit balance covers both modes. There is no separate SMTP subscription.

| Situation | Cost |
|-----------|------|
| `mode=fast` | 1 credit |
| `mode=deep`, SMTP was unnecessary (invalid syntax, nonexistent domain, no MX, null MX) | 1 credit |
| `mode=deep`, SMTP could not produce a useful answer (timeout, throttling, temporary policy block, MailSift infrastructure problem) | 1 credit |
| `mode=deep`, SMTP produced real mailbox- or domain-level intelligence | 5 credits |
| Overall `status: "unknown"` | 0 credits |

A deep request is billed **1 or 5 credits for the whole request**, never 1 + 5 = 6. The 1-credit standard verification is not charged on top of the 5-credit deep price.

An inconclusive overall result (`status: "unknown"`) costs 0 credits, as it always has.

A deep request needs at least 5 credits available before it starts. That amount is held up front, and the unused remainder is refunded as soon as the real cost is known. A bulk deep job reserves 5 credits per row the same way and refunds the remainder when the job finishes.

`credits_used` on the response tells you what you were actually charged. It is omitted where billing does not apply, such as the public demo.

---

## Verification

### GET /verify

Verify a single email address. Costs 1 credit in `fast` mode, up to 5 credits in `deep` mode.

**Query parameters**

| Name | Required | Description |
|------|----------|-------------|
| `email` | yes | Address to verify (max 254 characters). |
| `mode` | no | `fast` (default) or `deep`. `deep` adds an SMTP mailbox check and an `smtp` object to the response. See [Verification modes](#verification-modes). |

**Request headers**

| Name | Required | Description |
|------|----------|-------------|
| `X-API-Key` | yes | Your API key. |
| `Idempotency-Key` | no | Makes a retry safe. See [Idempotency](#idempotency). |

**Example**

```bash
curl "https://mailsift.dev/api/v1/verify?email=johnsmith@gmail.com" \
  -H "X-API-Key: msk_your_api_key_here"
```

Deep verification:

```bash
curl "https://mailsift.dev/api/v1/verify?email=johnsmith@gmail.com&mode=deep" \
  -H "X-API-Key: msk_your_api_key_here" \
  -H "Idempotency-Key: 3f1c9a7e-5d40-4c1b-9c2e-0a7b6d3e8f12"
```

**Response `200`**

```json
{
  "email": "johnsmith@gmail.com",
  "status": "valid",
  "score": 92,
  "mode": "fast",
  "credits_used": 1,
  "checks": {
    "syntax": true,
    "free": true,
    "disposable": false,
    "mx": true,
    "parked": false,
    "relay": false,
    "spf": true,
    "dmarc": true,
    "catch_all": false,
    "role": false
  },
  "domain": "gmail.com",
  "mx_records": ["gmail-smtp-in.l.google.com", "alt1.gmail-smtp-in.l.google.com"],
  "normalized_email": "johnsmith@gmail.com",
  "mx_provider": "Google",
  "is_catch_all": false,
  "domain_age_days": 11188,
  "response_time_ms": 46,
  "checked_at": "2026-04-01T17:32:18Z"
}
```

#### Reason codes and signals

Branch on `sub_status` instead of parsing the sentences in `reasons`. A check that did not run is left out of `checks` rather than reported as `false`, so `false` always means checked and clean.

| Field | Description |
|-------|-------------|
| `sub_status` | Stable code for the most decisive finding behind a status that is not valid. Omitted for valid addresses. Values may be added over time but are never renamed. |
| `catch_all_confidence` | Integer from 5 to 95: how likely the mailbox is to be real when the domain accepts every recipient, weighed from domain age, email authentication, mail provider and the shape of the local part. Present only for catch-all domains. The catch-all score penalty scales with it: 5 points at 70 or above, 15 at 40 to 69, 25 below 40. |
| `dmarc_policy` | The DMARC policy that applies to the domain: none, quarantine or reject. Taken from the organizational domain (honoring sp=) when the domain itself publishes no record. |
| `checks.dnsbl` | Domain or primary mail server is on a blocklist. Present only when the blocklist check produced an answer: false means checked and clean, and an absent key means the check did not run. |
| `checks.mx_routable` | At least one MX host resolves to a public address. Omitted when a resolver failure kept it from being determined. |
| `checks.forwarder` | Mail for the domain is handled by a forwarding service (ImprovMX, Forward Email, Cloudflare Email Routing, Namecheap forwarding). Context only; it does not affect the score. |
| `checks.smtputf8` | The local part contains non-ASCII characters (RFC 6531). Such addresses are valid but not every mail system can deliver to them, and deep verification reports smtp_smtputf8 for them. |
| `checks.mta_sts`, `checks.tls_rpt`, `checks.bimi` | The domain publishes an MTA-STS, TLS-RPT or BIMI record. Context only. Each is omitted when its lookup failed. |

**`sub_status` values**

| Value | Status | Meaning |
|-------|--------|---------|
| `failed_syntax_check` | `invalid` | The address is not well-formed. |
| `domain_not_found` | `invalid` | The domain does not exist. |
| `does_not_accept_mail` | `invalid` | The domain publishes a null MX: it declares that it accepts no mail. |
| `mx_unroutable` | `invalid` | The domain's MX records name hosts that do not exist, or that resolve only to loopback or private addresses. |
| `mailbox_not_found` | `invalid` | Deep only. The receiving mail server rejected the mailbox as nonexistent. |
| `mailbox_disabled` | `invalid` | Deep only. The mailbox exists but has been disabled or suspended. |
| `domain_rejected_by_mail_server` | `invalid` | Deep only. The receiving server does not handle mail for this domain. |
| `dns_error` | `unknown` | A temporary DNS failure kept the domain from being resolved. Retry. |
| `no_mx_records` | `risky` | The domain exists but publishes no MX records, so deliverability cannot be confirmed. |
| `disposable` | `risky` | Throwaway address provider. |
| `blocklisted` | `risky` | The domain or its mail server is on a blocklist. See dnsbl_listings. |
| `parked` | `risky` | The domain is parked or for sale. |
| `role_based` | `risky` | A shared functional inbox such as info@ or support@. |
| `role_based_catch_all` | `risky` | A role address on a domain that accepts every recipient. |
| `relay` | `risky` | A privacy alias service that hides the real recipient. |
| `catch_all` | `risky` | The domain accepts every recipient, so this mailbox cannot be confirmed. See catch_all_confidence. |
| `synthetic_local_part` | `risky` | The local part looks machine-generated or like a test address, on a free provider. |
| `new_domain` | `risky` | The domain was registered less than 30 days ago. |
| `low_score` | `risky` | No single finding; the combined score is below 80. |

#### Worked examples

Fields not relevant to the point being made are trimmed from these responses.

**1. Fast, valid**

```bash
curl "https://mailsift.dev/api/v1/verify?email=johnsmith@gmail.com" \
  -H "X-API-Key: msk_your_api_key_here"
```

```json
{
  "email": "johnsmith@gmail.com",
  "status": "valid",
  "score": 92,
  "mode": "fast",
  "credits_used": 1,
  "checks": { "syntax": true, "mx": true, "disposable": false, "role": false, "catch_all": false },
  "domain": "gmail.com",
  "mx_provider": "Google",
  "response_time_ms": 46
}
```

No `smtp` object: fast responses never include one.

**2. Fast, invalid**

```bash
curl "https://mailsift.dev/api/v1/verify?email=hello@no-such-domain-9x8y.com" \
  -H "X-API-Key: msk_your_api_key_here"
```

```json
{
  "email": "hello@no-such-domain-9x8y.com",
  "status": "invalid",
  "score": 0,
  "mode": "fast",
  "credits_used": 1,
  "checks": { "syntax": true, "mx": false, "disposable": false, "role": false },
  "domain": "no-such-domain-9x8y.com",
  "mx_records": [],
  "reasons": ["domain has no MX records"],
  "response_time_ms": 38
}
```

**3. Deep, deliverable**

```bash
curl "https://mailsift.dev/api/v1/verify?email=johnsmith@gmail.com&mode=deep" \
  -H "X-API-Key: msk_your_api_key_here"
```

```json
{
  "email": "johnsmith@gmail.com",
  "status": "valid",
  "score": 97,
  "mode": "deep",
  "credits_used": 5,
  "checks": { "syntax": true, "mx": true, "disposable": false, "role": false, "catch_all": false },
  "smtp": {
    "checked": true,
    "status": "deliverable",
    "response_code": 250,
    "enhanced_code": "2.1.5",
    "catch_all": false,
    "tls": true,
    "mx_host": "gmail-smtp-in.l.google.com",
    "provider": "google",
    "latency_ms": 84
  },
  "domain": "gmail.com",
  "mx_provider": "Google",
  "response_time_ms": 612
}
```

**4. Deep, undeliverable**

The mail server rejected this specific recipient, so the mailbox does not exist.

```json
{
  "email": "no.such.person@gmail.com",
  "status": "invalid",
  "score": 4,
  "mode": "deep",
  "credits_used": 5,
  "checks": { "syntax": true, "mx": true, "disposable": false, "role": false, "catch_all": false },
  "smtp": {
    "checked": true,
    "status": "undeliverable",
    "sub_reason": "mailbox_not_found",
    "response_code": 550,
    "enhanced_code": "5.1.1",
    "catch_all": false,
    "tls": true,
    "mx_host": "gmail-smtp-in.l.google.com",
    "provider": "google",
    "latency_ms": 96
  },
  "domain": "gmail.com",
  "reasons": ["mail server rejected this recipient"],
  "response_time_ms": 640
}
```

**5. Deep, catch-all**

The domain accepts every recipient, so acceptance of this address proves nothing about the mailbox. The overall status is `risky`, not `valid`.

```json
{
  "email": "sales@catchall-corp.com",
  "status": "risky",
  "score": 58,
  "mode": "deep",
  "credits_used": 5,
  "checks": { "syntax": true, "mx": true, "disposable": false, "role": true, "catch_all": true },
  "smtp": {
    "checked": true,
    "status": "catch_all",
    "sub_reason": "accept_all_gateway",
    "response_code": 250,
    "catch_all": true,
    "tls": true,
    "mx_host": "mx.catchall-corp.com",
    "provider": "other",
    "latency_ms": 219
  },
  "domain": "catchall-corp.com",
  "is_catch_all": true,
  "reasons": ["domain accepts all recipients", "role-based mailbox"],
  "response_time_ms": 750
}
```

**6. Deep, unknown (accepted but unverifiable)**

Yahoo accepted the recipient but does not distinguish real mailboxes from fake ones at this stage. MailSift reports that honestly rather than calling it deliverable.

```json
{
  "email": "johnsmith@yahoo.com",
  "status": "risky",
  "score": 61,
  "mode": "deep",
  "credits_used": 5,
  "checks": { "syntax": true, "free": true, "mx": true, "disposable": false, "role": false },
  "smtp": {
    "checked": true,
    "status": "unknown",
    "sub_reason": "provider_unverifiable",
    "response_code": 250,
    "catch_all": true,
    "tls": true,
    "mx_host": "mta5.am0.yahoodns.net",
    "provider": "yahoo",
    "latency_ms": 302
  },
  "domain": "yahoo.com",
  "mx_provider": "Yahoo",
  "reasons": ["provider does not confirm mailbox existence"],
  "response_time_ms": 840
}
```

This result is still billed at the deep rate, because establishing that the domain accepts everything is a real finding about the address.

**7. Deep requested, SMTP unnecessary**

The standard checks already settled the address, so no SMTP conversation was needed and you are charged the fast price.

```bash
curl "https://mailsift.dev/api/v1/verify?email=hello@no-such-domain-9x8y.com&mode=deep" \
  -H "X-API-Key: msk_your_api_key_here"
```

```json
{
  "email": "hello@no-such-domain-9x8y.com",
  "status": "invalid",
  "score": 0,
  "mode": "deep",
  "credits_used": 1,
  "checks": { "syntax": true, "mx": false, "disposable": false, "role": false },
  "smtp": {
    "checked": false,
    "status": "not_checked",
    "reason": "smtp_not_required"
  },
  "domain": "no-such-domain-9x8y.com",
  "mx_records": [],
  "reasons": ["domain has no MX records"],
  "response_time_ms": 41
}
```

#### Idempotency

Send an optional `Idempotency-Key` header on `GET /verify` to make retries safe. Deep verification costs more than one credit and involves a third-party mail server, so a client that retries after a network blip should not be charged twice or trigger a second SMTP conversation.

- Retrying with the **same key and the same request** replays the stored response instead of re-running the verification or charging again. The replay carries an `Idempotent-Replay: true` response header.
- Reusing a key with a **different request** returns `422`.
- A key whose original request is **still in flight** returns `409`. Retry shortly.
- Stored responses last **24 hours**.

```bash
curl "https://mailsift.dev/api/v1/verify?email=johnsmith@gmail.com&mode=deep" \
  -H "X-API-Key: msk_your_api_key_here" \
  -H "Idempotency-Key: 3f1c9a7e-5d40-4c1b-9c2e-0a7b6d3e8f12"
```

Without the header, behavior is exactly as it was before idempotency existed.

### POST /verify/batch

Verify up to 100 addresses synchronously. In `fast` mode each verified address costs 1 credit; in `deep` mode each costs up to 5. If credits run out mid-batch the response has `truncated: true`.

**Body** (`application/json`)

| Field | Required | Description |
|-------|----------|-------------|
| `emails` | yes | Array of 1–100 addresses. |
| `mode` | no | `fast` (default) or `deep`. Applies to every address in the batch. |

```json
{ "emails": ["a@example.com", "b@gmail.com", "c@yahoo.com"], "mode": "deep" }
```

**Response `200`**

```json
{
  "results": [
    { "email": "a@example.com", "result": { "status": "valid", "score": 90, "...": "..." } }
  ],
  "count": 3,
  "credits_used": 11,
  "mode": "deep",
  "truncated": false
}
```

Each item is `{ "email": "...", "result": { ...VerifyResult... }, "error": "..." }`, where `error` is present only if that address failed to verify. In `deep` mode each `result` carries its own `smtp` object, and `credits_used` is the total across the batch — individual addresses may have cost 1 or 5.

---

## Bulk Jobs

### POST /verify/bulk

Create an asynchronous bulk job from a CSV.

**Body** (`multipart/form-data`)

| Field | Required | Description |
|-------|----------|-------------|
| `file` | yes | CSV with a header row and an email column (header containing "email"). Max 32 MB. |
| `mode` | no | `fast` (default) or `deep`. Applies to every row in the job. |

```bash
curl -X POST "https://mailsift.dev/api/v1/verify/bulk" \
  -H "X-API-Key: msk_your_api_key_here" \
  -F "file=@emails.csv" \
  -F "mode=deep"
```

**Response `201`**

```json
{
  "id": "1f3c…",
  "filename": "emails.csv",
  "total_rows": 16,
  "processed_rows": 0,
  "valid_count": 0,
  "invalid_count": 0,
  "risky_count": 0,
  "unknown_count": 0,
  "status": "pending",
  "mode": "deep",
  "credits_reserved": 80,
  "started_at": null,
  "completed_at": null,
  "created_at": "2026-04-01T17:30:00Z"
}
```

`credits_reserved` is the maximum the job can cost: 1 credit per row for a fast job, 5 per row for a deep job. It is held when the job is created and the unused remainder is refunded when the job finishes.

### GET /jobs

List your bulk jobs, newest first. Optional query `limit` caps the count.

### GET /jobs/{id}

Return one job. While processing, the counts (`processed_rows`, `valid_count`, …) update live. `status` is one of `pending`, `processing`, `complete`, `failed`, `cancelled`. `mode` reports the verification mode the job runs in.

### GET /jobs/{id}/results

Paginated, parsed result rows for a completed job.

**Query parameters**

| Name | Default | Description |
|------|---------|-------------|
| `page` | 1 | 1-indexed page. |
| `page_size` | 50 | Rows per page (1–100). |
| `status` | — | Filter by verification status. |
| `search` | — | Case-insensitive substring match on email. |

**Response `200`**

```json
{
  "results": [
    {
      "email": "test123456@gmail.com",
      "verification_status": "risky",
      "verification_score": 82,
      "is_disposable": false,
      "has_mx": true,
      "is_role_based": false,
      "is_free_provider": true,
      "is_relay": false,
      "is_blacklisted": false,
      "has_spf": true,
      "has_dmarc": true,
      "domain_age_days": "11188",
      "mx_provider": "Google",
      "normalized_email": "test123456@gmail.com",
      "did_you_mean": ""
    }
  ],
  "total_rows": 16,
  "page": 1,
  "page_size": 50,
  "total_pages": 1
}
```

> Note: in this view `domain_age_days` is returned as a string.

### GET /jobs/{id}/stream

Server-Sent Events stream of job progress snapshots. Each event's `data:` payload is a JSON job object. The stream ends when the job reaches a terminal status. Consume with an `EventSource`-compatible client.

### GET /jobs/{id}/download

Download the final results CSV (`text/csv`) as a file attachment. Available once the job is `complete`.

A deep job appends two extra columns after all existing columns. Existing column positions are unchanged, so a parser that reads by index keeps working.

| Column | Values |
|--------|--------|
| `smtp_status` | Any `smtp.status` value: `deliverable`, `undeliverable`, `catch_all`, `unknown`, `greylisted`, `rate_limited`, `policy_blocked`, `timeout`, `not_checked`. |
| `mailbox_exists` | `yes`, `no`, or `unknown`. |

### DELETE /jobs/{id}

Cancel a pending/processing job, or delete a finished job and its results.

**Response `200`**

```json
{ "message": "job cancelled" }
```

(or `{ "message": "job deleted" }` for a finished job).

---

## Account

### GET /account

Return the current credit balance, this month's usage, and webhook configuration.

**Response `200`**

```json
{
  "email": "you@company.com",
  "credits": 480,
  "webhook_url": "https://example.com/hooks/mailsift",
  "webhook_secret": "whsec_…",
  "usage": {
    "year": 2026,
    "month": 6,
    "total_checks": 1200,
    "single_checks": 800,
    "bulk_checks": 350,
    "api_checks": 1000,
    "public_checks": 50
  },
  "auth_mode": "api_key",
  "checked_at": "2026-06-01T12:00:00Z"
}
```

`email` is omitted when authenticating via API key; `webhook_secret` is only returned to dashboard (session) callers.
