Email verification API: verdicts, limits, pricing, code
EmailPal email verification API: POST /verify for one address, POST /lists/verify for files. Eight statuses, 500 to 4,500 checks a day included, extra blocks of 500/day at $9.95/month.
The short answer
The EmailPal email verification API checks one address per synchronous call (POST /api/public/v1/verify) or a whole file asynchronously (POST /api/public/v1/lists/verify). Both need a write-scope API key and both draw on one daily allowance: 500 checks on Starter ($69/mo), 1,500 on Growth and 4,500 on Scale, plus blocks of 500 a day at $9.95/month. Every address costs one credit and comes back as one of eight statuses: safe, catch_all, role_account, disposable, inbox_full, disabled, invalid or unknown.
Send {"email": "..."} to POST /verify with a Bearer key that has write scope and you get the status, sub_status, domain, MX host and your credit counters back in one response. Send a file to POST /lists/verify and poll for the results. The allowance is daily and resets at midnight UTC, there is no per-check price, and verification needs an active subscription. Nothing is ever delivered: the check stops after RCPT TO.
How it works, step by step
Step 1
Create an API key with write scope
Create a key on the API keys page in the dashboard. Both verification endpoints need write scope; admin keys also work. Keys start with ep_live_ and are shown once, so store it in a server-side secret.
Step 2
Check one address
POST {"email": "..."} to /api/public/v1/verify with the key as a Bearer token. The response is always 200 for an address that was checked, including undeliverable ones. An undeliverable address is a result, not an error.
POST /api/public/v1/verifybashcurl -X POST https://www.emailpal.io/api/public/v1/verify \ -H "Authorization: Bearer ep_live_..." \ -H "Content-Type: application/json" \ -d '{"email":"priya@acme.example"}'Step 3
Branch on status and sub_status
Treat safe as deliverable, invalid, disabled and inbox_full as do-not-send, catch_all, role_account and disposable as risky, and unknown as unchecked rather than bad. sub_status narrows invalid and unknown further.
Step 4
Check a file with the list endpoint
For a CSV or a pasted list, POST the raw contents to /api/public/v1/lists/verify. It answers 202 with an upload id, reserves one credit per address up front, and works through the list in the background. Poll GET /lists/{id} for the verdict and GET /lists/{id}/results for per-address answers.
POST /api/public/v1/lists/verifybashcurl -X POST https://www.emailpal.io/api/public/v1/lists/verify \ -H "Authorization: Bearer ep_live_..." \ -H "Content-Type: application/json" \ -d '{"filename":"q3-prospects.csv","content":"email\npriya@acme.example\nsam@example.org"}'Step 5
Handle the limit and capacity errors
Back off until midnight UTC on 429 verification_limit_reached, wait for the Retry-After header on 503 capacity_exhausted, and treat 402 as a billing problem rather than something to retry.
01
Authentication, scopes and request limits
The base URL is https://www.emailpal.io/api/public/v1 and every request carries Authorization: Bearer ep_live_.... Scopes are hierarchical: admin implies write, and write implies read. POST /verify and POST /lists/verify require write. Reading a submitted list back (GET /lists/{id} and GET /lists/{id}/results) needs only read, so a write key can do both.
Verification has its own daily allowance, but every call also counts as one API request against your plan’s hourly limit. That limit is 1,000 requests an hour on Starter, Growth and Scale. When it is spent the API answers 429 with the code rate_limited and a Retry-After header. Successful responses carry X-RateLimit-Limit. A single POST /lists/verify counts as one request however many addresses it holds, so use the list endpoint rather than looping over /verify for anything large.
The full machine-readable schema is served at https://www.emailpal.io/api/public/v1/openapi.json, and the human reference is on the docs page.
02
Check one address: POST /verify
The body is a single field, email, a valid address of at most 320 characters. The address is trimmed and lowercased before it is checked. The call is synchronous: it holds the connection open while a live SMTP check runs through EmailPal’s own pool of verification IPs, never through the addresses that send your mail.
The check runs in this order. A free classifier settles syntax errors, disposable domains and role accounts without opening a connection. Everything else gets a probe of a mailbox that cannot exist at the domain (cached per domain for a day), then a probe of your address if the domain proved it rejects strangers. Each probe is a connection that ends after RCPT TO. No message is ever sent.
The worker has a 45 second budget for one check and the route allows up to 60 seconds, so a client timeout shorter than that is a client decision, not an API limit. We do not publish a typical latency figure.
{
"email": "priya@acme.example",
"status": "safe",
"sub_status": null,
"domain": "acme.example",
"mx_host": "mx1.acme.example",
"checked_at": "2026-10-06T09:12:44.000Z",
"credits": { "used": 214, "limit": 1500 }
}03
The eight statuses and what each one means
safe: the server turned away an invented mailbox and then accepted yours at RCPT TO, so the accept carries information. The dashboard and product page call this valid. sub_status is null.
catch_all: the domain accepted the invented mailbox, so it will accept anything and its yes about your address means nothing. Your address is not probed individually. sub_status is null. See the catch-all page for how to treat these.
role_account: the local part is on the role list (info, sales, support, admin, billing, contact, hello, team and similar), settled without a connection. sub_status is role_account. disposable: the domain is on a short, curated list of throwaway providers, settled without a connection. The list is not exhaustive. sub_status is disposable.
inbox_full: the server rejected the address with a 5xx message that says the mailbox is full or over quota. sub_status is mailbox_full. disabled: the server rejected it with a message that says the account is disabled, deactivated, suspended or closed. sub_status is mailbox_disabled. Both bounce today.
invalid: the address is malformed, or the server rejected the mailbox outright. sub_status is mailbox_not_found for a server rejection and null when syntax alone decided it.
unknown: no definitive answer. A 450, 451, 452 or 421 deferral (greylisting), a timeout or a connection that could not be made all land here. The API does not retry a greylisted address inline, so antispam_system comes back immediately. The documented sub_status values to branch on are antispam_system, unable_to_connect and no_mx_record. Unknown is never rounded up to safe.
04
Check a file: POST /lists/verify
The body is {"content": "...", "filename": "..."}. content is the raw file text, 3 to 12,000,000 characters, as a CSV with addresses in any column, one address per line, or anything else with addresses in it. filename is optional and only labels the upload in the dashboard. Addresses are extracted from the text, and extraction stops at 250,000 addresses per call. Duplicates are reported as duplicate rather than checked twice.
The credits for the whole list are taken when it is accepted, one per extracted address. A list that does not fit in what is left of today’s allowance is refused with 429 verification_limit_reached and nothing is queued, so you can split the file. The error carries addresses, used and limit.
The response is 202 with upload_id, addresses, job_id, a poll URL and a results URL. Large lists take minutes to hours because probing a receiving domain faster than a real sender would gets a verifier blocked. Results stream as they are decided, so you can read GET /lists/{id}/results (oldest first, 1 to 200 per page, cursor-based) while the list is still running. GET /lists/{id} returns the whole-list verdict (passed, flagged, blocked or failed), a sendable flag, and counts per status.
Per the API reference, a list that is still unchecked after 24 hours comes back unknown and is refunded, and credits are refunded for every address that finishes unknown. Per-address results are kept for 90 days. The list.progress and list.completed webhook events are available if you would rather not poll.
{
"upload_id": "lst_7c31ee",
"addresses": 2,
"job_id": "job_6j40wl",
"poll": "/api/public/v1/lists/lst_7c31ee",
"results": "/api/public/v1/lists/lst_7c31ee/results",
"credits": { "used": 216, "limit": 1500 }
}05
Errors
Every error has the same body, { "error": { "code": "...", "message": "...", ...extra fields } }, so branch on code and not on the message. Authentication errors are 401 (missing_credentials, invalid_credentials, key_revoked, key_expired), 403 insufficient_scope when the key lacks write, and 403 account_suspended.
402 subscription_required: the account has no active subscription and no operator grant. Nothing was probed and no credit was taken. 402 verification_capacity_required: subscribed, but the plan includes no daily allowance and no blocks were bought. 429 verification_limit_reached: today’s allowance is spent. It resets at midnight UTC, and this error carries no Retry-After header, so compute the wait yourself. 429 rate_limited: the hourly request limit, with a Retry-After header. 503 capacity_exhausted: every verification IP was at its hourly limit, so nothing was probed. This one is not counted against your allowance, and the wait in seconds is in the Retry-After header and in error.retryAfter.
Any other 5xx means the verification worker did not answer. It is safe to retry. Do not assume the credit came back: only the 503 states that it was refunded, so read credits.used on the next successful response.
{
"error": {
"code": "capacity_exhausted",
"message": "Verification capacity is fully committed right now. This check was not counted against your daily allowance — retry after the time in the Retry-After header.",
"retryAfter": 42
}
}06
Limits and pricing
Verification is sold as a daily allowance, not as per-check credits. Starter ($69/mo) includes 500 checks a day, Growth ($189/mo) 1,500 and Scale ($499/mo) 4,500. Extra capacity comes in blocks of 500 a day at $9.95/month each, on any plan, up to 200 blocks. A block is 15,000 checks over a 30 day month.
The credit is taken before the address is probed. A single check costs one credit whatever it returns, including role_account, disposable and unknown, and a failed probe still spends it. The 503 is the exception. The allowance is shared by POST /verify and POST /lists/verify, so a large list uses the same budget as your instant checks.
Buying blocks can be refused with 409 capacity_unavailable when EmailPal has no unsold verification capacity left, so the cap you can buy is not unlimited in practice.
07
TypeScript example
A minimal Node 18+ call using the real shapes. Run it on a server, never in a browser, because the key carries write scope.
type Status =
| 'safe' | 'catch_all' | 'role_account' | 'disposable'
| 'inbox_full' | 'disabled' | 'invalid' | 'unknown';
interface VerifyResponse {
email: string;
status: Status;
sub_status: string | null;
domain: string;
mx_host: string | null;
checked_at: string;
credits: { used: number; limit: number };
}
export async function verifyEmail(email: string): Promise<VerifyResponse> {
const res = await fetch('https://www.emailpal.io/api/public/v1/verify', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.EMAILPAL_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(50_000),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
const code = body?.error?.code ?? 'unknown_error';
const retryAfter = res.headers.get('retry-after');
throw new Error(code + ' (HTTP ' + res.status + ')' + (retryAfter ? ', retry after ' + retryAfter + 's' : ''));
}
return (await res.json()) as VerifyResponse;
}Limits, prices and names, in one table
The figures this page relies on, so you do not have to hunt for them.
| Single check endpoint | POST /api/public/v1/verifySynchronous. Body: {"email": "..."}, max 320 characters. |
| List check endpoint | POST /api/public/v1/lists/verifyAsynchronous, answers 202. content up to 12,000,000 characters; up to 250,000 addresses extracted. |
| Scope required | writeReading a list back needs read. admin implies write implies read. |
| Starter allowance | 500 checks/dayIncluded in $69/mo. |
| Growth allowance | 1,500 checks/dayIncluded in $189/mo. |
| Scale allowance | 4,500 checks/dayIncluded in $499/mo. |
| Extra capacity | $9.95/month per 500/day15,000 checks over 30 days. Up to 200 blocks. |
| Credits per address | 1Same for a single check and for each address in a list. |
| Allowance resets | 00:00 UTC |
| API request limit | 1,000 per hourStarter, Growth and Scale. 429 rate_limited with Retry-After. |
| Worker budget for one check | 45 secondsThe route allows up to 60 seconds. |
| Status values | 8safe, catch_all, role_account, disposable, inbox_full, disabled, invalid, unknown. Lists add suppressed and duplicate. |
| List results kept | 90 days |
Checked against the product and the API on . Plans and limits change, so confirm in the docs before you build on them.
This is not for you if
- You want pay-per-check pricing. EmailPal sells a daily allowance on a subscription, and there is no prepaid per-check credit.
- You need verification without a subscription. Without one the API answers 402 subscription_required.
- You need an answer in a fixed few hundred milliseconds. A live SMTP check can take up to the 45 second worker budget, and we publish no latency guarantee.
- You need a hosted white-label verification product. Embedding the API in your own product is allowed; white-labelling the dashboard needs written agreement.
- You need more than 200 purchasable blocks a day on top of your plan allowance, or capacity guaranteed at any hour. Capacity is finite and a 503 is possible.
Common questions
What does the EmailPal verification API cost?
There is no per-check price. Starter at $69/mo includes 500 checks a day, Growth at $189/mo includes 1,500, and Scale at $499/mo includes 4,500. More capacity is sold in blocks of 500 a day at $9.95/month each, which is about $0.0007 per check if every check in the block is used.
Which API scope does verification need?
POST /verify and POST /lists/verify need a key with write scope, because they spend a metered daily credit and open a live SMTP connection. A key with only read scope gets 403 insufficient_scope. Reading list results needs only read.
Does the API send an email to the address it checks?
No. The check connects to the receiving server, issues RCPT TO and closes the connection without sending DATA. Nothing is delivered, and the probes leave from a separate pool of verification IPs rather than from your sending mailboxes.
What happens when I run out of checks for the day?
The API answers 429 with the code verification_limit_reached and the limit and used counts. The allowance resets at midnight UTC. You can wait, or buy another block of 500 a day. A list that needs more credits than remain is refused whole, and nothing is queued.
Does a single check refund its credit if the result is unknown?
No. A single POST /verify spends its credit when it is accepted, and only a 503 capacity_exhausted gives it back. Lists behave differently: the API reference says credits are refunded for addresses that finish unknown.
Is the status called safe or valid?
The API returns safe. The dashboard and the product page describe the same result as valid. The other statuses keep the same names in the API: catch_all, role_account, inbox_full, disabled, invalid, disposable and unknown.
Build it on infrastructure that holds up
Domains, mailboxes, warming and verification over one REST API and MCP server, with the sequencer, Unibox and CRM on every plan.
No card to start — cancel any time.