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.
| What | Cost |
|---|---|
| An address with a stored result less than 90 days old | Nothing: 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 preview | Free up to 1,000 sampled addresses per month; above that, normal credits. |
| Everything else | 1 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:
| Field | Values |
|---|---|
status | valid (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). |
result | deliverable, undeliverable, do_not_send (complained or is suppressed), catch_all (the domain accepts any recipient) and unknown. |
risk | low, 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 / reasons | The main reason (ok for valid ones) and every code that applies, in order of importance. |
Code (reason) | Meaning |
|---|---|
complained, suppressed | The address complained about spam or is on the team's suppression list (do_not_send). |
previously_bounced | Already bounced permanently on one of the team's sends. |
invalid_syntax, domain_not_found, no_mx | Malformed, a domain that does not exist and a domain with no mail server. |
mailbox_not_found | The recipient's server said the mailbox does not exist. |
dns_timeout, mailbox_unverifiable | Could not conclude (unknown): DNS failed or the provider does not allow confirming the mailbox. |
catch_all | The domain accepts any recipient, so there is no way to know whether the mailbox exists. |
mailbox_full, disposable, typo_suspected, role_account | Risk signals: full mailbox, disposable address, likely typo (see suggestion) and role account (info@, sales@). |
free_provider | Informational: a free provider. On its own, it never explains a status. |
has_engagement, no_history | The 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
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
emailstringrequiredThe address, up to 320 characters.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
plan_limit_reachedThe plan does not include email verification. - 429
verification_credits_exhaustedThe credits ran out. The plan's credits renew on the 1st (UTC); the Validation add-on allows new verifications right away. - 429
rate_limit_exceededMore than 30 verifications in a minute.
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);
{
"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
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)
sourcestringrequiredWhere the addresses come from:csv(a file),segment(a segment) orcontacts(all contacts).filefileOnly withsource=csv: the CSV, up to 20 MB, with a header row, sent asmultipart/form-data. Not allowed for the other sources.email_columnstringOnly withsource=csv: the header column with the email addresses. If missing, Postal tries to guess.segment_idstringRequired withsource=segment.recheck_stalebooleanVerifies again the addresses whose stored result is more than 90 days old. Default:false.
Errors
- 422
validation_errorInvalid 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. - 403
plan_limit_reachedThe plan does not include email verification.
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);
{
"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
}
}
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
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)
sourcestringrequiredWhere the addresses come from:csv(a file),segment(a segment) orcontacts(all contacts).filefileOnly withsource=csv: the CSV, up to 20 MB, with a header row, sent asmultipart/form-data. Not allowed for the other sources.email_columnstringOnly withsource=csv: the header column with the email addresses. If missing, Postal tries to guess.segment_idstringRequired withsource=segment.recheck_stalebooleanVerifies again the addresses whose stored result is more than 90 days old. Default:false.
Errors
- 422
validation_errorInvalid 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. - 403
plan_limit_reachedThe plan does not include email verification. - 429
verification_credits_exhaustedThe credits ran out. The plan's credits renew on the 1st (UTC); the Validation add-on allows new verifications right away. - 429
rate_limit_exceededThere are already 2 verifications running. - 404
not_foundThe segment does not exist.
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);
{
"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
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)
sourcestringrequiredWhere the addresses come from:csv(a file),segment(a segment) orcontacts(all contacts).filefileOnly withsource=csv: the CSV, up to 20 MB, with a header row, sent asmultipart/form-data. Not allowed for the other sources.email_columnstringOnly withsource=csv: the header column with the email addresses. If missing, Postal tries to guess.segment_idstringRequired withsource=segment.recheck_stalebooleanVerifies again the addresses whose stored result is more than 90 days old. Default:false.
Errors
- 422
validation_errorInvalid 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. - 403
plan_limit_reachedThe plan does not include email verification. - 429
verification_credits_exhaustedThe credits ran out. The plan's credits renew on the 1st (UTC); the Validation add-on allows new verifications right away. - 429
rate_limit_exceededThere are already 2 verifications running. - 404
not_foundThe segment does not exist.
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);
{
"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
}
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
The team's bulk verifications and previews, newest first.
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.previewbooleantruefor previews only;falsefor full verifications only.
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);
{
"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}
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
idstringrequiredVerification ID.
Errors
- 404
not_foundThe verification does not exist or belongs to another team.
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);
{
"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
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
idstringrequiredVerification ID.
Errors
- 422
validation_errorThe verification is no longer running. - 404
not_foundThe verification does not exist or belongs to another team.
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);
{
"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
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
idstringrequiredVerification ID.
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.statusstringOne or more statuses, comma-separated:valid,invalid,risky,unknown.
Errors
- 422
validation_errorstatuswith a value that does not exist. - 404
not_foundThe verification does not exist or belongs to another team.
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);
{
"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
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
idstringrequiredVerification ID.
Request body
actionstringrequiredunsubscribe_invalid(unsubscribes) ordelete_invalid(deletes).include_riskybooleanAlso includes contacts with ariskyresult. Default:false.
Errors
- 422
validation_errorThe verification has not finished, is a preview, its results expired, it is already being applied, or no contact matches. - 404
not_foundThe verification does not exist or belongs to another team.
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);
{
"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
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_datestringStart of the period: a date or an ISO 8601 date-time. Default: 30 days ago.end_datestringEnd of the period. A date alone covers the whole day. Default: now; it never goes past now.timezonestringIANA time zone, such asAmerica/Sao_Paulo. Default:UTC.
Errors
- 422
validation_errorInvalid date,start_dateafterend_date, unknown time zone or a period longer than 400 days.
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);
{
"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=…"
}
]
}