Sign in Create account
Audience

Email verification API

Check whether an address receives email before you send: one at a time, in bulk or across your contacts, with monthly plan credits.

Postal checks the syntax, the domain and the mail servers of each address, flags disposable addresses, role accounts (such as info@) and likely typos, tries to confirm the mailbox with the recipient's server, and cross-checks the result with your team's sending history (bounces, complaints and suppressions). There are three ways to use it: one address at a time, a list in bulk (a CSV, a segment or all contacts) and a health preview of a list. The latest result for each address becomes the contact's email_status and can filter the contact list and dynamic segments. Every endpoint uses a full-access API key and requires a plan that includes verification.

#Credits

Each address verified uses 1 credit. The plan gives credits per month (Free: 100, Pro: 5,000, Scale: 50,000, Enterprise: unlimited) that renew on the 1st of each month (UTC); a plan with 0 does not include verification (403 plan_limit_reached). The packs of the Validation add-on, bought in the dashboard (or granted by Postal), add to the plan's credits and show up in credits.grants. Each pack has the validity set for it, in days (validity_days, on the packs for sale), and some have none: for those, validity_days is 0 and, in credits.grants, expires_at is null. The month's credits are used first; then the packs that expire soonest, and last the ones that never expire.

WhatCost
An address with a stored result less than 90 days oldNothing: the result is reused (cached: true or already_verified), re-scored against the team's current history. With recheck_stale, older ones are verified again.
Malformed address or DNS failure (unknown)Nothing. unknown results are not stored, so the next verification tries again.
List health previewFree up to 1,000 sampled addresses per month; above that, normal credits.
Everything else1 credit per address.

Without credits, the response is 429 with verification_credits_exhausted. The endpoints return the balance in credits: used and limit (monthly credits), monthly_remaining, grant_credits (from packs), remaining (the sum; null if unlimited), resets_at and free_preview_remaining.

#Reading a result

Every result gives four readings of the same address, from the simplest to the most detailed:

FieldValues
statusvalid (deliverable, with low or medium risk), invalid (undeliverable or should not receive), risky (catch_all, disposable, a role account without engagement, or high risk) and unknown (the checks could not finish).
resultdeliverable, undeliverable, do_not_send (complained or is suppressed), catch_all (the domain accepts any recipient) and unknown.
risklow, medium, high or unknown. Your team's engagement adjusts the risk: someone who received, opened or clicked before gets a lower risk; a role account or a typo that never received anything goes up one level.
reason / reasonsThe main reason (ok for valid ones) and every code that applies, in order of importance.
Code (reason)Meaning
complained, suppressedThe address complained about spam or is on the team's suppression list (do_not_send).
previously_bouncedAlready bounced permanently on one of the team's sends.
invalid_syntax, domain_not_found, no_mxMalformed, a domain that does not exist and a domain with no mail server.
mailbox_not_foundThe recipient's server said the mailbox does not exist.
dns_timeout, mailbox_unverifiableCould not conclude (unknown): DNS failed or the provider does not allow confirming the mailbox.
catch_allThe domain accepts any recipient, so there is no way to know whether the mailbox exists.
mailbox_full, disposable, typo_suspected, role_accountRisk signals: full mailbox, disposable address, likely typo (see suggestion) and role account (info@, sales@).
free_providerInformational: a free provider. On its own, it never explains a status.
has_engagement, no_historyThe team's history: it has sent and the address received, opened or clicked, or it never sent anything to it.

checks has the verifications as booleans: syntax, mx (the domain accepts email; null if it could not be determined), smtp (the mailbox exists; null if not confirmed), disposable, role, free and suppressed (complained, is suppressed or already bounced). mailbox and smtp_code are the outcome of talking to the recipient's server (deliverable, undeliverable, catch_all, full or unknown), or null if the mailbox was not queried. suggestion is the correction of a likely typo, such as gmail.com for gmial.com.

#Check one address

POST/email-verifications/check

Full-access key

Verifies an address right away. A stored result less than 90 days old comes back for free, with cached: true; otherwise the verification uses one credit (the month's credits first, then packs). Malformed addresses and DNS failures cost no credit. For lists, use the bulk verification. Limit: 30 verifications per minute per team (429 rate_limit_exceeded).

Request body

  • emailstringrequired
    The address, up to 320 characters.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 403plan_limit_reached
    The plan does not include email verification.
  • 429verification_credits_exhausted
    The credits ran out. The plan's credits renew on the 1st (UTC); the Validation add-on allows new verifications right away.
  • 429rate_limit_exceeded
    More than 30 verifications in a minute.
POST /email-verifications/check

cURL

curl -X POST https://api.postal.so/email-verifications/check \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]"
  }'

Node.js

const response = await fetch('https://api.postal.so/email-verifications/check', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: '[email protected]',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/email-verifications/check",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "email": "[email protected]",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('email-verifications/check', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'email' => '[email protected]',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "email_verification",
  "email": "[email protected]",
  "normalized": "[email protected]",
  "status": "valid",
  "result": "deliverable",
  "risk": "low",
  "reason": "ok",
  "reasons": ["has_engagement"],
  "checks": {
    "syntax": true,
    "mx": true,
    "smtp": true,
    "disposable": false,
    "role": false,
    "free": false,
    "suppressed": false
  },
  "mailbox": "deliverable",
  "smtp_code": 250,
  "suggestion": null,
  "domain": "example.com",
  "cached": false,
  "verified_at": "2026-10-24T14:00:00Z",
  "credits": {
    "used": 1240,
    "limit": 5000,
    "remaining": 4760,
    "monthly_remaining": 3760,
    "grant_credits": 1000,
    "grants": [
      {
        "id": "019a1f00-4d5e-7f60-9a70-7c8d9e0f1a2b",
        "source": "purchase",
        "name": "500-credit pack",
        "credits": 500,
        "remaining": 500,
        "expires_at": "2027-10-20T12:00:00Z"
      },
      {
        "id": "019a1f00-4d5f-7f61-8b71-8d9e0f1a2b3c",
        "source": "purchase",
        "name": "500-credit pack with no expiry",
        "credits": 500,
        "remaining": 500,
        "expires_at": null
      }
    ],
    "resets_at": "2026-11-01T00:00:00Z",
    "free_preview_remaining": 1000
  }
}

#Estimate a list's cost

POST/email-verifications/estimate

Full-access key

Reads the source and says what verifying it would cost, without verifying or spending anything: how many addresses there are (total), how many are unique (unique), how many already have a stored result (already_verified) and how many would be verified (to_check, which is also credits_needed). enough_credits says whether the balance covers it. preview shows the sample size of the preview and what it would cost; max_addresses is the maximum per verification (200,000).

Request body (JSON or multipart/form-data)

  • sourcestringrequired
    Where the addresses come from: csv (a file), segment (a segment) or contacts (all contacts).
  • filefile
    Only with source=csv: the CSV, up to 20 MB, with a header row, sent as multipart/form-data. Not allowed for the other sources.
  • email_columnstring
    Only with source=csv: the header column with the email addresses. If missing, Postal tries to guess.
  • segment_idstring
    Required with source=segment.
  • recheck_staleboolean
    Verifies again the addresses whose stored result is more than 90 days old. Default: false.

Errors

  • 422validation_error
    Invalid source, missing file, file too large or not a CSV, a column that is not in the header, a list with no addresses or with more than 200,000.
  • 403plan_limit_reached
    The plan does not include email verification.
POST /email-verifications/estimate

cURL

curl -X POST https://api.postal.so/email-verifications/estimate \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "segment",
    "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"
  }'

Node.js

const response = await fetch('https://api.postal.so/email-verifications/estimate', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    source: 'segment',
    segment_id: '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/email-verifications/estimate",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "source": "segment",
        "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('email-verifications/estimate', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'source' => 'segment',
        'segment_id' => '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "email_verification_estimate",
  "source": "segment",
  "email_column": null,
  "columns": null,
  "total": 5200,
  "unique": 5200,
  "already_verified": 800,
  "to_check": 4400,
  "credits_needed": 4400,
  "enough_credits": true,
  "max_addresses": 200000,
  "preview": {
    "sample_size": 260,
    "credits_needed": 0
  },
  "credits": {
    "used": 1240,
    "limit": 5000,
    "remaining": 4760,
    "monthly_remaining": 3760,
    "grant_credits": 1000,
    "grants": [
      {
        "id": "019a1f00-4d5e-7f60-9a70-7c8d9e0f1a2b",
        "source": "purchase",
        "name": "500-credit pack",
        "credits": 500,
        "remaining": 500,
        "expires_at": "2027-10-20T12:00:00Z"
      },
      {
        "id": "019a1f00-4d5f-7f61-8b71-8d9e0f1a2b3c",
        "source": "purchase",
        "name": "500-credit pack with no expiry",
        "credits": 500,
        "remaining": 500,
        "expires_at": null
      }
    ],
    "resets_at": "2026-11-01T00:00:00Z",
    "free_preview_remaining": 1000
  }
}
POST /email-verifications/estimate (CSV)

cURL

curl -X POST https://api.postal.so/email-verifications/estimate \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -F "[email protected]" \
  -F "source=csv" \
  -F "email_column=email"

Node.js

import { readFile } from 'node:fs/promises';

const form = new FormData();
form.append('file', new Blob([await readFile('customers.csv')]), 'customers.csv');
form.append('source', 'csv');
form.append('email_column', 'email');

const response = await fetch('https://api.postal.so/email-verifications/estimate', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.POSTAL_API_KEY}` },
  body: form,
});

const verification = await response.json();

Python

import os
import requests

with open("customers.csv", "rb") as csv_file:
    response = requests.post(
        "https://api.postal.so/email-verifications/estimate",
        headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
        files={"file": ("customers.csv", csv_file, "text/csv")},
        data={
            "source": "csv",
            "email_column": "email",
        },
        timeout=60,
    )

verification = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('email-verifications/estimate', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'multipart' => [
        ['name' => 'file', 'contents' => fopen('customers.csv', 'r'), 'filename' => 'customers.csv'],
        ['name' => 'source', 'contents' => 'csv'],
        ['name' => 'email_column', 'contents' => 'email'],
    ],
]);

$verification = json_decode($response->getBody(), true);

#Preview a list's health

POST/email-verifications/preview

Full-access key

Verifies a random sample of the source (5% of the list, 50 to 500 addresses) so you can see the share of valid, invalid and risky addresses before spending credits on everything. The sample is free up to 1,000 addresses per month; above that, it uses normal credits. The body is the same as the estimate's. Returns the verification (preview: true), which runs in the background: follow it at GET /email-verifications/{id}. A preview cannot be applied to contacts.

Request body (JSON or multipart/form-data)

  • sourcestringrequired
    Where the addresses come from: csv (a file), segment (a segment) or contacts (all contacts).
  • filefile
    Only with source=csv: the CSV, up to 20 MB, with a header row, sent as multipart/form-data. Not allowed for the other sources.
  • email_columnstring
    Only with source=csv: the header column with the email addresses. If missing, Postal tries to guess.
  • segment_idstring
    Required with source=segment.
  • recheck_staleboolean
    Verifies again the addresses whose stored result is more than 90 days old. Default: false.

Errors

  • 422validation_error
    Invalid source, missing file, file too large or not a CSV, a column that is not in the header, a list with no addresses or with more than 200,000.
  • 403plan_limit_reached
    The plan does not include email verification.
  • 429verification_credits_exhausted
    The credits ran out. The plan's credits renew on the 1st (UTC); the Validation add-on allows new verifications right away.
  • 429rate_limit_exceeded
    There are already 2 verifications running.
  • 404not_found
    The segment does not exist.
POST /email-verifications/preview

cURL

curl -X POST https://api.postal.so/email-verifications/preview \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "segment",
    "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"
  }'

Node.js

const response = await fetch('https://api.postal.so/email-verifications/preview', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    source: 'segment',
    segment_id: '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/email-verifications/preview",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "source": "segment",
        "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('email-verifications/preview', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'source' => 'segment',
        'segment_id' => '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
    ],
]);

$data = json_decode($response->getBody(), true);
201Response
{
  "object": "email_verification_job",
  "id": "019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "source": "segment",
  "preview": true,
  "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
  "filename": null,
  "email_column": null,
  "recheck_stale": false,
  "status": "pending",
  "total": null,
  "processed": 0,
  "counts": {
    "valid": 0,
    "invalid": 0,
    "risky": 0,
    "unknown": 0
  },
  "rates": {
    "valid": null,
    "invalid": null,
    "risky": null,
    "unknown": null
  },
  "reasons": {},
  "already_verified": 0,
  "checked": 0,
  "credits_used": 0,
  "free_checks": 0,
  "credits_exhausted": false,
  "population": 5200,
  "estimated_credits": 0,
  "applied": null,
  "error": null,
  "created_at": "2026-10-24T14:00:00.000000Z",
  "started_at": null,
  "completed_at": null,
  "results_expire_at": null,
  "results_filename": null,
  "download_url": null
}

#Verify a list in bulk

POST/email-verifications

Full-access key

Starts verifying all the addresses in the source, in the background. Before it starts, Postal checks the balance against the to_check of the estimate (429 verification_credits_exhausted if it does not cover it). Each team can have up to 2 verifications running, and each one up to 200,000 addresses. Follow the progress at GET /email-verifications/{id}; at the end, read the results or download the CSV from download_url (a signed link, valid for 1 hour). Results are kept for 7 days; the verification itself, for 30. With source=contacts or segment, each result has the contact_id, and the result becomes the contact's email_status.

Request body (JSON or multipart/form-data)

  • sourcestringrequired
    Where the addresses come from: csv (a file), segment (a segment) or contacts (all contacts).
  • filefile
    Only with source=csv: the CSV, up to 20 MB, with a header row, sent as multipart/form-data. Not allowed for the other sources.
  • email_columnstring
    Only with source=csv: the header column with the email addresses. If missing, Postal tries to guess.
  • segment_idstring
    Required with source=segment.
  • recheck_staleboolean
    Verifies again the addresses whose stored result is more than 90 days old. Default: false.

Errors

  • 422validation_error
    Invalid source, missing file, file too large or not a CSV, a column that is not in the header, a list with no addresses or with more than 200,000.
  • 403plan_limit_reached
    The plan does not include email verification.
  • 429verification_credits_exhausted
    The credits ran out. The plan's credits renew on the 1st (UTC); the Validation add-on allows new verifications right away.
  • 429rate_limit_exceeded
    There are already 2 verifications running.
  • 404not_found
    The segment does not exist.
POST /email-verifications

cURL

curl -X POST https://api.postal.so/email-verifications \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "segment",
    "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"
  }'

Node.js

const response = await fetch('https://api.postal.so/email-verifications', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    source: 'segment',
    segment_id: '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/email-verifications",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "source": "segment",
        "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('email-verifications', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'source' => 'segment',
        'segment_id' => '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
    ],
]);

$data = json_decode($response->getBody(), true);
201Response
{
  "object": "email_verification_job",
  "id": "019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "source": "segment",
  "preview": false,
  "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
  "filename": null,
  "email_column": null,
  "recheck_stale": false,
  "status": "pending",
  "total": null,
  "processed": 0,
  "counts": {
    "valid": 0,
    "invalid": 0,
    "risky": 0,
    "unknown": 0
  },
  "rates": {
    "valid": null,
    "invalid": null,
    "risky": null,
    "unknown": null
  },
  "reasons": {},
  "already_verified": 0,
  "checked": 0,
  "credits_used": 0,
  "free_checks": 0,
  "credits_exhausted": false,
  "population": 5200,
  "estimated_credits": 4400,
  "applied": null,
  "error": null,
  "created_at": "2026-10-24T14:00:00.000000Z",
  "started_at": null,
  "completed_at": null,
  "results_expire_at": null,
  "results_filename": null,
  "download_url": null
}
POST /email-verifications (CSV)

cURL

curl -X POST https://api.postal.so/email-verifications \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -F "[email protected]" \
  -F "source=csv" \
  -F "email_column=email"

Node.js

import { readFile } from 'node:fs/promises';

const form = new FormData();
form.append('file', new Blob([await readFile('customers.csv')]), 'customers.csv');
form.append('source', 'csv');
form.append('email_column', 'email');

const response = await fetch('https://api.postal.so/email-verifications', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.POSTAL_API_KEY}` },
  body: form,
});

const verification = await response.json();

Python

import os
import requests

with open("customers.csv", "rb") as csv_file:
    response = requests.post(
        "https://api.postal.so/email-verifications",
        headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
        files={"file": ("customers.csv", csv_file, "text/csv")},
        data={
            "source": "csv",
            "email_column": "email",
        },
        timeout=60,
    )

verification = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('email-verifications', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'multipart' => [
        ['name' => 'file', 'contents' => fopen('customers.csv', 'r'), 'filename' => 'customers.csv'],
        ['name' => 'source', 'contents' => 'csv'],
        ['name' => 'email_column', 'contents' => 'email'],
    ],
]);

$verification = json_decode($response->getBody(), true);

#List verifications

GET/email-verifications

Full-access key

The team's bulk verifications and previews, newest first.

Query parameters

  • limitinteger
    From 1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • previewboolean
    true for previews only; false for full verifications only.
GET /email-verifications

cURL

curl https://api.postal.so/email-verifications \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/email-verifications', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/email-verifications",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('email-verifications', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "email_verification_job",
      "id": "019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "source": "csv",
      "preview": false,
      "segment_id": null,
      "filename": "customers.csv",
      "email_column": "email",
      "recheck_stale": false,
      "status": "completed",
      "total": 5200,
      "processed": 5200,
      "counts": {
        "valid": 4410,
        "invalid": 390,
        "risky": 310,
        "unknown": 90
      },
      "rates": {
        "valid": 0.8481,
        "invalid": 0.075,
        "risky": 0.0596,
        "unknown": 0.0173
      },
      "reasons": {
        "mailbox_not_found": 210,
        "invalid_syntax": 120,
        "disposable": 140,
        "role_account": 120,
        "catch_all": 50
      },
      "already_verified": 800,
      "checked": 4400,
      "credits_used": 4390,
      "free_checks": 0,
      "credits_exhausted": false,
      "population": 5200,
      "estimated_credits": 4400,
      "applied": null,
      "error": null,
      "created_at": "2026-10-24T14:00:00.000000Z",
      "started_at": "2026-10-24T14:00:02.000000Z",
      "completed_at": "2026-10-24T14:03:40.000000Z",
      "results_expire_at": "2026-10-31T14:03:40.000000Z",
      "results_filename": "email-verification-customers.csv",
      "download_url": "https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/download?expires=1792850620&signature=…"
    }
  ]
}

#Get a verification

GET/email-verifications/{id}

Full-access key

status: pending, processing, completed, canceled or failed (with error). processed out of total shows the progress; counts and rates give the split by status, and reasons the most common reasons. already_verified are the reused addresses, checked the ones verified now, credits_used and free_checks what was charged and what was free, and credits_exhausted warns that the verification stopped for lack of credits. contacts also comes here: how many contacts apply would change, for invalid and risky results. A verification with no progress for 30 minutes (for example, a server that went down) resumes by itself, up to 3 times in a row.

Path parameters

  • idstringrequired
    Verification ID.

Errors

  • 404not_found
    The verification does not exist or belongs to another team.
GET /email-verifications/{id}

cURL

curl https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "email_verification_job",
  "id": "019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "source": "csv",
  "preview": false,
  "segment_id": null,
  "filename": "customers.csv",
  "email_column": "email",
  "recheck_stale": false,
  "status": "completed",
  "total": 5200,
  "processed": 5200,
  "counts": {
    "valid": 4410,
    "invalid": 390,
    "risky": 310,
    "unknown": 90
  },
  "rates": {
    "valid": 0.8481,
    "invalid": 0.075,
    "risky": 0.0596,
    "unknown": 0.0173
  },
  "reasons": {
    "mailbox_not_found": 210,
    "invalid_syntax": 120,
    "disposable": 140,
    "role_account": 120,
    "catch_all": 50
  },
  "already_verified": 800,
  "checked": 4400,
  "credits_used": 4390,
  "free_checks": 0,
  "credits_exhausted": false,
  "population": 5200,
  "estimated_credits": 4400,
  "applied": null,
  "error": null,
  "created_at": "2026-10-24T14:00:00.000000Z",
  "started_at": "2026-10-24T14:00:02.000000Z",
  "completed_at": "2026-10-24T14:03:40.000000Z",
  "results_expire_at": "2026-10-31T14:03:40.000000Z",
  "results_filename": "email-verification-customers.csv",
  "download_url": "https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/download?expires=1792850620&signature=…",
  "contacts": {
    "invalid": 360,
    "risky": 280
  }
}

#Cancel a verification

POST/email-verifications/{id}/cancel

Full-access key

Stops a verification that is still queued or running. What was already verified stays in the results (and was charged); the rest is not verified. A verification that had not started deletes the uploaded file.

Path parameters

  • idstringrequired
    Verification ID.

Errors

  • 422validation_error
    The verification is no longer running.
  • 404not_found
    The verification does not exist or belongs to another team.
POST /email-verifications/{id}/cancel

cURL

curl -X POST https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/cancel \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/cancel', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/cancel",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/cancel', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "email_verification_job",
  "id": "019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "source": "csv",
  "preview": false,
  "segment_id": null,
  "filename": "customers.csv",
  "email_column": "email",
  "recheck_stale": false,
  "status": "canceled",
  "total": 5200,
  "processed": 1800,
  "counts": {
    "valid": 4410,
    "invalid": 390,
    "risky": 310,
    "unknown": 90
  },
  "rates": {
    "valid": 0.8481,
    "invalid": 0.075,
    "risky": 0.0596,
    "unknown": 0.0173
  },
  "reasons": {
    "mailbox_not_found": 210,
    "invalid_syntax": 120,
    "disposable": 140,
    "role_account": 120,
    "catch_all": 50
  },
  "already_verified": 800,
  "checked": 4400,
  "credits_used": 4390,
  "free_checks": 0,
  "credits_exhausted": false,
  "population": 5200,
  "estimated_credits": 4400,
  "applied": null,
  "error": null,
  "created_at": "2026-10-24T14:00:00.000000Z",
  "started_at": "2026-10-24T14:00:02.000000Z",
  "completed_at": "2026-10-24T14:01:10.000000Z",
  "results_expire_at": "2026-10-31T14:03:40.000000Z",
  "results_filename": "email-verification-customers.csv",
  "download_url": "https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/download?expires=1792850620&signature=…"
}

#A verification's results

GET/email-verifications/{id}/results

Full-access key

One result per address, in file order. Each one has the readings from Reading a result, plus line (the CSV line, counting the header as 1), already_verified and the contact_id of the matching contact, when there is one. Results are deleted 7 days after the verification ends.

Path parameters

  • idstringrequired
    Verification ID.

Query parameters

  • limitinteger
    From 1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • statusstring
    One or more statuses, comma-separated: valid, invalid, risky, unknown.

Errors

  • 422validation_error
    status with a value that does not exist.
  • 404not_found
    The verification does not exist or belongs to another team.
GET /email-verifications/{id}/results

cURL

curl 'https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/results?status=invalid,risky&limit=50' \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/results?status=invalid,risky&limit=50', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/results",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    params={
        "status": "invalid,risky",
        "limit": 50,
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/results', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'query' => [
        'status' => 'invalid,risky',
        'limit' => 50,
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "has_more": true,
  "data": [
    {
      "object": "email_verification_result",
      "id": "019a1f10-2b3c-7d4e-8f50-6b7c8d9e0f1a",
      "email": "[email protected]",
      "normalized": "[email protected]",
      "status": "invalid",
      "result": "undeliverable",
      "risk": "high",
      "reason": "mailbox_not_found",
      "reasons": ["mailbox_not_found"],
      "checks": {
        "syntax": true,
        "mx": true,
        "smtp": false,
        "disposable": false,
        "role": false,
        "free": false,
        "suppressed": false
      },
      "mailbox": "undeliverable",
      "smtp_code": 550,
      "suggestion": null,
      "domain": "example.invalid",
      "already_verified": false,
      "contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "line": 37
    }
  ]
}

#Apply the results to contacts

POST/email-verifications/{id}/apply

Full-access key

Cleans the audience using what the verification found: unsubscribes or deletes the contacts whose address came back invalid (and, with include_risky, risky too). It runs in the background and returns the verification (202); applied shows the action, and count and applied_at are filled in when it finishes. It only works for a completed (or canceled) full verification whose results have not expired; previews cannot be applied. Unsubscribed contacts stop receiving broadcasts, and deleted ones leave the audience.

Path parameters

  • idstringrequired
    Verification ID.

Request body

  • actionstringrequired
    unsubscribe_invalid (unsubscribes) or delete_invalid (deletes).
  • include_riskyboolean
    Also includes contacts with a risky result. Default: false.

Errors

  • 422validation_error
    The verification has not finished, is a preview, its results expired, it is already being applied, or no contact matches.
  • 404not_found
    The verification does not exist or belongs to another team.
POST /email-verifications/{id}/apply

cURL

curl -X POST https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/apply \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "unsubscribe_invalid",
    "include_risky": false
  }'

Node.js

const response = await fetch('https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/apply', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    action: 'unsubscribe_invalid',
    include_risky: false,
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/apply",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "action": "unsubscribe_invalid",
        "include_risky": False,
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/apply', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'action' => 'unsubscribe_invalid',
        'include_risky' => false,
    ],
]);

$data = json_decode($response->getBody(), true);
202Response
{
  "object": "email_verification_job",
  "id": "019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "source": "csv",
  "preview": false,
  "segment_id": null,
  "filename": "customers.csv",
  "email_column": "email",
  "recheck_stale": false,
  "status": "completed",
  "total": 5200,
  "processed": 5200,
  "counts": {
    "valid": 4410,
    "invalid": 390,
    "risky": 310,
    "unknown": 90
  },
  "rates": {
    "valid": 0.8481,
    "invalid": 0.075,
    "risky": 0.0596,
    "unknown": 0.0173
  },
  "reasons": {
    "mailbox_not_found": 210,
    "invalid_syntax": 120,
    "disposable": 140,
    "role_account": 120,
    "catch_all": 50
  },
  "already_verified": 800,
  "checked": 4400,
  "credits_used": 4390,
  "free_checks": 0,
  "credits_exhausted": false,
  "population": 5200,
  "estimated_credits": 4400,
  "applied": {
    "action": "unsubscribe_invalid",
    "include_risky": false,
    "count": null,
    "applied_at": null
  },
  "error": null,
  "created_at": "2026-10-24T14:00:00.000000Z",
  "started_at": "2026-10-24T14:00:02.000000Z",
  "completed_at": "2026-10-24T14:03:40.000000Z",
  "results_expire_at": "2026-10-31T14:03:40.000000Z",
  "results_filename": "email-verification-customers.csv",
  "download_url": "https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/download?expires=1792850620&signature=…"
}

#Verification overview

GET/email-verifications/overview

Full-access key

The verification dashboard in one call: the balance and the packs for sale (credits), the period's totals (totals: verifications, addresses verified, reused, split by outcome and credits used), the rates (rates), a per-day series (series), the most common reasons (reasons), the domains with the most verified addresses (domains) and the 5 most recent verifications (recent_jobs). Domains only cover results that are still stored (7 days); everything else comes from the verifications and charges, which are kept longer.

Query parameters

  • start_datestring
    Start of the period: a date or an ISO 8601 date-time. Default: 30 days ago.
  • end_datestring
    End of the period. A date alone covers the whole day. Default: now; it never goes past now.
  • timezonestring
    IANA time zone, such as America/Sao_Paulo. Default: UTC.

Errors

  • 422validation_error
    Invalid date, start_date after end_date, unknown time zone or a period longer than 400 days.
GET /email-verifications/overview

cURL

curl 'https://api.postal.so/email-verifications/overview?start_date=2026-10-01&end_date=2026-10-24&timezone=America/Sao_Paulo' \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/email-verifications/overview?start_date=2026-10-01&end_date=2026-10-24&timezone=America/Sao_Paulo', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/email-verifications/overview",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    params={
        "start_date": "2026-10-01",
        "end_date": "2026-10-24",
        "timezone": "America/Sao_Paulo",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('email-verifications/overview', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'query' => [
        'start_date' => '2026-10-01',
        'end_date' => '2026-10-24',
        'timezone' => 'America/Sao_Paulo',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "email_verification_overview",
  "from": "2026-10-01T00:00:00-03:00",
  "to": "2026-10-24T23:59:59-03:00",
  "timezone": "America/Sao_Paulo",
  "credits": {
    "included": true,
    "balance": {
      "used": 1240,
      "limit": 5000,
      "remaining": 4760,
      "monthly_remaining": 3760,
      "grant_credits": 1000,
      "grants": [
        {
          "id": "019a1f00-4d5e-7f60-9a70-7c8d9e0f1a2b",
          "source": "purchase",
          "name": "500-credit pack",
          "credits": 500,
          "remaining": 500,
          "expires_at": "2027-10-20T12:00:00Z"
        },
        {
          "id": "019a1f00-4d5f-7f61-8b71-8d9e0f1a2b3c",
          "source": "purchase",
          "name": "500-credit pack with no expiry",
          "credits": 500,
          "remaining": 500,
          "expires_at": null
        }
      ],
      "resets_at": "2026-11-01T00:00:00Z",
      "free_preview_remaining": 1000
    },
    "packs": [
      {
        "object": "verification_credit_pack",
        "id": "019a1f00-5e6f-7a70-8b80-8d9e0f1a2b3c",
        "name": "10,000-credit pack",
        "credits": 10000,
        "amount": 4900,
        "currency": "BRL",
        "prices": [],
        "validity_days": 365,
        "popular": true
      },
      {
        "object": "verification_credit_pack",
        "id": "019a1f00-5e70-7b71-9c81-9e0f1a2b3c4d",
        "name": "50,000-credit pack with no expiry",
        "credits": 50000,
        "amount": 19900,
        "currency": "BRL",
        "prices": [],
        "validity_days": 0,
        "popular": false
      }
    ]
  },
  "totals": {
    "jobs": 3,
    "addresses_checked": 9100,
    "reused": 1300,
    "by_outcome": {
      "valid": 7720,
      "invalid": 640,
      "risky": 560,
      "unknown": 180
    },
    "credits_used": 7800
  },
  "rates": {
    "valid_rate": 0.8484,
    "invalid_rate": 0.0703,
    "risky_rate": 0.0615,
    "unknown_rate": 0.0198
  },
  "series": [
    {
      "date": "2026-10-24T00:00:00-03:00",
      "checked": 5200,
      "by_outcome": {
        "valid": 4410,
        "invalid": 390,
        "risky": 310,
        "unknown": 90
      },
      "credits_used": 4390
    }
  ],
  "reasons": [
    {
      "reason": "mailbox_not_found",
      "count": 410
    },
    {
      "reason": "disposable",
      "count": 260
    }
  ],
  "domains": [
    {
      "domain": "example.com",
      "checked": 820,
      "invalid": 31,
      "invalid_rate": 0.0378
    }
  ],
  "recent_jobs": [
    {
      "object": "email_verification_job",
      "id": "019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "source": "csv",
      "preview": false,
      "segment_id": null,
      "filename": "customers.csv",
      "email_column": "email",
      "recheck_stale": false,
      "status": "completed",
      "total": 5200,
      "processed": 5200,
      "counts": {
        "valid": 4410,
        "invalid": 390,
        "risky": 310,
        "unknown": 90
      },
      "rates": {
        "valid": 0.8481,
        "invalid": 0.075,
        "risky": 0.0596,
        "unknown": 0.0173
      },
      "reasons": {
        "mailbox_not_found": 210,
        "invalid_syntax": 120,
        "disposable": 140,
        "role_account": 120,
        "catch_all": 50
      },
      "already_verified": 800,
      "checked": 4400,
      "credits_used": 4390,
      "free_checks": 0,
      "credits_exhausted": false,
      "population": 5200,
      "estimated_credits": 4400,
      "applied": null,
      "error": null,
      "created_at": "2026-10-24T14:00:00.000000Z",
      "started_at": "2026-10-24T14:00:02.000000Z",
      "completed_at": "2026-10-24T14:03:40.000000Z",
      "results_expire_at": "2026-10-31T14:03:40.000000Z",
      "results_filename": "email-verification-customers.csv",
      "download_url": "https://api.postal.so/email-verifications/019a1f10-1a2b-7c3d-8e4f-5a6b7c8d9e0f/download?expires=1792850620&signature=…"
    }
  ]
}