Buy domains, provision mailboxes, run warming, read and send mail, and check a list before you send to it. Every action in the dashboard is an endpoint here — the two sit on the same operations layer, so there is nothing that only works if you click it.
Base URL
https://www.emailpal.io/api/public/v1
Your first call
Create a key on the API keys page, then provision some mailboxes. Note the second half of the sample: anything that touches a registrar, a resolver or a mail server answers 202 and a job, and the work is not done when the request returns.
# Five mailboxes on a domain that is already connected and verifiedcurl -X POST https://www.emailpal.io/api/public/v1/mailboxes \
-H"Authorization: Bearer $EMAILPAL_API_KEY" \
-H"Content-Type: application/json" \
-d'{ "domain_id": "dom_91af22", "count": 5, "cold_daily_limit": 15 }'# The answer is 202 and a job. Poll it until it stops.curl https://www.emailpal.io/api/public/v1/jobs/job_5m71pq \
-H"Authorization: Bearer $EMAILPAL_API_KEY"
A bearer token on every request. Keys are prefixed ep_live_ and stored only as a SHA-256 hash, so we cannot show you one twice — if it is lost, revoke it and make another. Revocation takes effect on the next request rather than at the end of a cache window.
Hierarchical: admin implies write implies read. Each endpoint below is labelled with the least it will accept.
read
Read anything on the account. Cannot change or spend.
write
Create and change domains, mailboxes, warming and mail. Covers most of what an integration does.
admin
Adds the two things that are not just writes: exporting mailbox passwords, and spending money to top up the domain balance. Worth issuing separately from the key your provisioning script uses.
Per account, per hour, sized by plan. Every response carries X-RateLimit-Limit; a rejected one adds X-RateLimit-Reset and Retry-After, and the error body repeats limit, used and resetAt so a client that only parses JSON still knows when to come back.
The window is fixed to the clock hour rather than sliding: the allowance resets on the hour, every hour, and X-RateLimit-Reset is the exact moment. Sliding would be marginally fairer at the boundary and much harder to plan a batch against. Failed requests count — a retry loop against a broken endpoint costs its share.
Conventional status codes, and one envelope everywhere. Branch on error.code, which is stable — the message is written for a person and will change when we find a clearer way to say it. Some errors add fields beside those two, and those are documented on the endpoint that returns them.
Error envelope
{
"error": {
"code": "insufficient_balance",
"message": "That batch comes to $41.97 and the balance is $24.15. Top up $17.82 and try again.",
"requiredCents": 4197,
"balanceCents": 2415,
"shortfallCents": 1782
}
}
Codes any endpoint can return
400invalid_request
The body failed validation. The message names the fields.
401missing_credentials
No Authorization: Bearer header.
401invalid_credentials
The key is not one of ours.
401key_revoked
The key was revoked. Create a new one.
401key_expired
The key passed its expiry date.
403insufficient_scope
The key lacks the scope this endpoint needs. Carries granted and required.
403account_suspended
The account is suspended or closed. The credentials are fine, which is worth knowing before you rotate a key that was never the problem.
429rate_limited
The hour’s allowance is spent. Carries limit, used and resetAt, plus a Retry-After header.
500internal_error
Something broke on our side. Nothing was changed — safe to retry.
A 500 means nothing was changed. Writes that touch money or a registrar run behind an idempotency key, so retrying one cannot buy the same domain or charge the same balance twice.
Every list endpoint takes limit (1–200, default 50) and cursor, and returns the same envelope. Keep passing next_cursor while has_more is true.
Keyset rather than offset. An offset shifts under you when rows are created mid-page, and on an account provisioning mailboxes in the background that is not a rare edge case — it is Tuesday. With a cursor, rows created while you page turn up on a later page or not at all, and you never silently skip one.
Registering a domain, publishing DNS, creating a mailbox on the mail server and sending a message all involve a system that is not ours and does not answer promptly. Those endpoints return 202 Accepted with a job_id rather than holding the connection open.
This is the part of the API most likely to be integrated wrongly, and the failure is quiet: a client that treats 202 as success reports a domain as bought while the registrar is still deciding. A 202 means we accepted the instruction, nothing more.
Poll GET /jobs/{id} and loop on terminal rather than comparing against a list of statuses you maintain. A second or two between polls is plenty; jobs carry progress and progress_message if you are showing this to somebody.
Two exceptions worth knowing. Reading a message body returns 202 and asks you to poll the message URL itself rather than a job, and list verification puts its verdict on the upload rather than the job. In both cases the thing you want is the resource, so that is where the answer is.
Three endpoints in this API spend money. They are called out here rather than only in the reference, because an integration that discovers them by being billed has discovered them too late.
Charged against a prepaid domain balance, not the card on the subscription. The whole batch is priced and taken up front, so a batch the balance will not cover is refused in full rather than half-bought — you get a 402 carrying the shortfall and nothing is registered. Registrations that then fail at the registrar are refunded to the balance automatically. Top the balance up through the API or the dashboard.
A monthly per-mailbox charge for every mailbox with warming enabled. Disabling stops it; pausing does not, because a paused mailbox keeps its place on the ramp and that place is what you are paying to hold. Leased pre-warmed inboxes are never charged for warming separately.
Creating past the included count is billed as overage rather than refused, and the response says so with overage_billable. Read GET /account first if the batch size is coming from somewhere you do not control.
Everything is in cents, as integers, in the account currency. There are no floats in this API and no amount is ever a string.
The same operations, exposed to an AI assistant over the Model Context Protocol. It runs locally and talks to this API with your key, so it can do exactly what the key can do and nothing else — give it a read key and the worst it can do is tell you something.
The API keys page will generate this for you with a key already in it.
Reference
Every endpoint, its parameters, and what it returns when it works and when it does not.
Account
Plan, usage and the prepaid balance domains are bought from. Worth calling first and then rarely.
GET/accountread
Plan, usage, warming cost and domain balance
Everything needed to know whether a create will succeed before attempting it. A 402 halfway through a batch of forty mailboxes leaves you working out which ones landed, so the intended pattern is to read this, size the batch, and then send it.
It also carries domain_balance_cents, which is the figure a domain purchase is charged against. That is a separate pot from the subscription card and has to cover the whole batch before any of it is bought.
usage.warming_mailboxes is counted per inbox rather than per mailbox we host, so it includes addresses connected from Gmail, Microsoft or your own server as well as mailboxes here. It will not equal usage.mailboxes.used in either direction.
Returns a hosted payment page. Nothing is charged by this call: the balance goes up when the processor confirms the payment, which is usually a second or two after somebody completes that page.
This exists so an integration that hits insufficient_balance on a purchase has somewhere to go. Without it the automation ends in an email to a human who then has to find the dashboard.
admin scope rather than write, because spending money is not the same permission as creating a mailbox, and a key handed to a provisioning script should not be able to run up a card.
balance_cents in the response is the balance before this payment. It is there so a client can show the delta, not to be treated as the new figure.
Body
amount_cents
integerrequired2,000–500,000
Between $20 and $5,000. The floor is there because a payment smaller than it costs more in processor fees than it credits; the ceiling catches a mistyped amount before it becomes a refund.
return_url
stringmax 500 charactersdefaults to the dashboard billing page
Where the payer lands afterwards. Must be https://. We append topup=complete or topup=cancelled so the page can say which happened.
Returns
201
A payment page was created. No money has moved yet.
Errors
400invalid_request
return_url is not https://.
404account_missing
The account could not be read.
502billing_provider_error
The payment processor refused to open a session.
Request
POST https://www.emailpal.io/api/public/v1/account/topup
{
"payment_url": "https://checkout.stripe.com/c/pay/cs_live_a1b2c3",
"reference": "cs_live_a1b2c3",
"amount_cents": 25000,
"balance_cents": 24150,
"note": "Nothing has been charged yet. Open the payment URL to pay; the balance goes up when the processor confirms it, which is usually within a few seconds of that page completing."
}
Domains
Search, buy, connect one you already own, and manage its DNS. Buying spends real money from the domain balance.
GET/domainsread
List domains
Newest first. Filter by status to answer a specific question: needs_dns for domains waiting on somebody to delegate nameservers, aging for inventory not yet old enough to send from, ready and active for the ones that can take mailboxes.
Read dns_method before nameservers_verified. Delegation only applies to the nameservers method, so on a manual or cloudflare_token domain that flag stays false however healthy the domain is, and treating it as a readiness signal would report a working domain as broken.
Query
limit
integer1–200defaults to 50
How many to return.
cursor
stringmax 200 characters
The next_cursor from the previous page. Keyset rather than an offset, so rows created while you page do not shift the window and make you miss one.
status
string
Only domains in this state.pending_purchasepurchaseddns_pendingneeds_dnsagingreadyactivesuspendedretired
tag
stringmax 64 characters
Only domains carrying this tag. Repeat the parameter to require several — they are ANDed, so ?tag=outbound&tag=tenant:44 returns domains with both.
Returns
200
Always, including when the account has no domains.
Errors
400invalid_status
status is not one of the listed values.
400invalid_pagination
limit is out of range or cursor is not one we issued.
One endpoint with a mode, rather than two URLs. Buying and connecting produce the same kind of row and are genuinely different acts, and splitting them across two paths would let a client that meant to connect accidentally spend money at a registrar.
mode: "purchase" prices the whole batch against the registrar, takes the total off the domain balance, and only then queues the registrations. If the balance will not cover all of it, nothing is bought and you get a 402 carrying the shortfall — a partial charge against a partial result is the thing worth avoiding here. Registrations that then fail are refunded to the balance automatically.
mode: "connect" costs nothing, and takes a dns_method saying how the records are going to get written. nameservers (the default) creates the zone in our Cloudflare and hands back nameservers to delegate to. manual writes nothing anywhere: we compute the records, list them on GET /domains/{id}, and you add them at whatever DNS host you already use. Either way the domain sits in needs_dns until public DNS agrees, which is expected rather than a failure.
The third method, cloudflare_token, is not available here — the token has to be proved against the zone before it is worth storing, so it goes to POST /domains/{id}/cloudflare-token once the domain exists. Create the domain as manual and attach the token after, or just use that endpoint on an existing domain.
Premium names are rejected rather than bought. They run to hundreds of times list price, and buying one because it was next in a bulk list is not a mistake worth leaving available.
Connect mode returns connected, the dns_method it used and a note, instead of queued and charged_cents.
On manual, nothing we do will ever fix a wrong record. That includes DKIM rotation, which changes a value you then have to update — watch for a record whose status turns mismatch on GET /domains/{id}.
Send an Idempotency-Key header. This endpoint spends money, and a retry after a timeout is otherwise a second purchase.
Body
mode
stringrequired
purchase registers new names and charges for them. connect adopts names you already own.purchaseconnect
domains
string[]required1–50 items
The names. Pasted URLs, www. prefixes and trailing slashes are stripped rather than rejected, since every one of them unambiguously names the domain intended.
dns_method
stringdefaults to nameservers
Connect mode only. nameservers delegates the whole zone to us and we keep the records correct from then on. manual leaves DNS entirely in your hands — read the record set from GET /domains/{id} and add it yourself, including after a DKIM rotation. The default is the old behaviour, so existing callers are unaffected.nameserversmanual
years
integer1–10defaults to 1
Registration length. Purchase mode only. The markup is charged once, not once per year.
tags
string[]max 20 items
Labels written onto every domain in the batch and returned on every read. Uninterpreted — use tenant:<id> if you resell, and filter with ?tag= on the list endpoint.
Returns
202
At least one domain was queued. Poll each job_id.
409
Every name was rejected. The rejected array says why for each.
Errors
400invalid_domains
None of the strings look like domain names.
402plan_limit
The batch would exceed the plan’s domain allowance. Carries limit and used.
402insufficient_balance
The domain balance will not cover the batch. Carries requiredCents, balanceCents and shortfallCents. Nothing was bought.
502registrar_unavailable
The registrar did not answer, so availability is unknown rather than unavailable.
Request
POST https://www.emailpal.io/api/public/v1/domains
{
"queued": [
{
"domain": "outbound-atlas.com",
"domain_id": "dom_91af22",
"job_id": "job_4h29xk",
"price_cents": 1300
}
],
"rejected": [
{ "domain": "atlas-mail.co", "reason": "Already registered by somebody else." }
],
"charged_cents": 1300,
"balance_cents": 22751,
"poll": "Each queued item carries a job_id. Poll GET /jobs/{id} until it succeeds or fails."
}
POST/domains/searchread
Check availability and price
Send exact names, or a seed to generate variations from a brand. Reserves nothing and charges nothing, but it does hit the registrar on our account, which is why it costs a request against the rate limit rather than being open.
price_cents is what a purchase will actually take off the balance — registrar cost plus markup, rounded up to the next whole dollar plus another dollar (whole-dollar prices, same as Winnr). registrar_cents is the wholesale figure before markup and rounding.
price_cents is null when we will not sell the name — it is taken, premium, or the registrar declined to quote. premium tells the first two apart.
Body
domains
string[]max 100 items
Exact names to check. Send this or seed.
seed
string2–40 characters
A brand to generate variations from, e.g. northworks. Send this or domains.
tlds
string[]max 8 items
Restrict generated suggestions to these TLDs.
limit
integer1–100defaults to 40
How many suggestions to generate. Applies to seed only.
years
integer1–10defaults to 1
Price a multi-year registration, so the quote matches a purchase for the same term.
Returns
200
The registrar answered. Unavailable names are results, not errors.
Errors
400invalid_request
Neither domains nor seed was sent.
502registrar_unavailable
The registrar did not answer.
Request
POST https://www.emailpal.io/api/public/v1/domains/search
The dns block is the useful part. expected is the record we asked the DNS provider to create; observed is what a public resolver actually returns. They are stored separately on purpose, because conflating them produces a dashboard that says "configured" while mail quietly fails authentication.
pending_nameservers is populated on a connected domain that has not been delegated yet, and is what to show the customer. It is always null on the manual and cloudflare_token methods, because there is no zone of ours to delegate to — on those, the dns block is the whole story.
On a manual domain this endpoint is the work order: every record with a status other than verified is one somebody still has to add or correct at your DNS host. expected is complete and unabridged so it can be pasted character for character.
Returns
200
The domain exists on this account.
Errors
404not_found
No such domain on this account.
Request
GET https://www.emailpal.io/api/public/v1/domains/{id}
An action name rather than a verb per operation. Releasing is not a delete — the registration outlives it — and re-checking DNS mutates nothing, so a REST-shaped mapping would have described these worse than this does.
All four are queued rather than run inline. Re-checking DNS means resolving against several public resolvers, which is slow and fails in ways that need retrying; doing it in the request would mean a spinner that sometimes just gives up.
Releasing is irreversible and is not refunded. It also discards the domain age and sending history, which is most of what a warmed domain is worth.
Body
action
stringrequired
verify_dns re-checks against public resolvers now. rewrite_dns republishes every record we manage. rotate_dkim publishes a new key and retires the old one once mail signed with it has landed. release stops renewal and takes the domain off the account.verify_dnsrewrite_dnsrotate_dkimrelease
Returns
202
The job was queued.
Errors
404not_found
No such domain on this account.
409domain_in_use
release on a domain that still has mailboxes. Delete those first — releasing would bounce anything already sent from them. Carries mailbox_count.
Request
POST https://www.emailpal.io/api/public/v1/domains/{id}
The third connect method, and the one worth preferring if the zone is already at Cloudflare. The zone stays yours; we write and maintain the mail records inside it with a token you scope yourself. Nothing is delegated, and unlike manual you are not the one who has to keep up with a DKIM rotation.
Its own endpoint rather than a field on the create call, because a token has to be proved against the zone before it is worth storing — accepting it at create time would mean either keeping an unproven credential or failing a batch of forty domains on one bad paste.
The only synchronous action on a domain. Everything else queues a job, but the reason to check a token at all is to say now that its scope is wrong, while the caller still has it in hand.
Safe to repeat. A second call re-proves and replaces the stored token, which is also how you rotate one.
The token is never returned, logged, or readable back. It reaches the encryption key on our worker without being written down in between.
Revoking the token at Cloudflare stops us maintaining the records. They keep resolving, but the next DKIM rotation will fail and the domain will start reporting a mismatch.
Body
token
stringrequired20–200 characters
A Cloudflare API token with Zone:Read and DNS:Edit, scoped to this zone. Both are needed and both are checked: we look the zone up, then write and immediately delete a probe record, because a read-only token looks perfectly valid right up until the first record fails to write.
Returns
200
The token works. Setup is queued and the domain is now on this method.
Errors
404not_found
No such domain on this account.
400token_rejected
The token could not see a zone for this domain, or could not write to it. Cloudflare’s own wording is forwarded, since it names the missing permission better than a paraphrase would.
Request
POST https://www.emailpal.io/api/public/v1/domains/{id}/cloudflare-token
Body
{ "token": "v1.0-abc123..." }
Response
{
"id": "dom_91af22",
"name": "outbound-atlas.com",
"dns_method": "cloudflare_token",
"zone_id": "0d9f1e...",
"zone_name": "outbound-atlas.com",
"note": "Setup is queued. We write the records into your zone directly, so there are no nameservers to change — poll GET /domains/{id} until every record reads verified."
}
Mailboxes
Create them by the hundred, adjust them, and export SMTP and IMAP credentials when you are ready to send.
GET/mailboxesread
List mailboxes
warming.campaign_ready is the field to gate a campaign on, and warming.campaign_blocker says what is missing when it is false. The warming day alone is not enough: a mailbox at its ceiling with 60% placement is worse to send from than one halfway up the curve at 98%.
Query
limit
integer1–200defaults to 50
How many to return.
cursor
stringmax 200 characters
The next_cursor from the previous page. Keyset rather than an offset, so rows created while you page do not shift the window and make you miss one.
domain_id
string
Only mailboxes on this domain.
status
string
Only mailboxes in this state.provisioningwarmingactivepausedsuspendedfaileddeleted
Returns
200
Always.
Errors
400invalid_status
status is not one of the listed values.
400invalid_pagination
limit is out of range or the cursor is unknown.
500read_failed
The query failed.
Request
GET https://www.emailpal.io/api/public/v1/mailboxes
Two ways to say what you want. Send personas when the names matter — real-looking first and last names are part of why warming works — or send count and let us generate plausible ones against the domain’s local-part pattern.
The addresses are recorded immediately and provisioned by a job, so the response has the addresses in it before the mail server has them. overageBillable is true when the batch takes the account past its included allowance; those mailboxes are billed rather than refused.
Around five mailboxes to a domain is the balance point, and varying the number between domains is worth doing: a fleet holding precisely five addresses on every domain is a fact about the tool that built it. Spreading volume over more domains beats pushing harder on fewer.
Body
domain_id
stringrequired
The domain to create them on. Must be ready or active.
personas
object[]max 100 items
Objects of { first_name, last_name }. first_name is required and capped at 40 characters; last_name is optional. Send this or count.
count
integer1–100
Generate this many personas instead of naming them. Send this or personas.
pattern
stringdefaults to the domain's own pattern
How to build the local part from a name.firstfirst.lastfirstlastf.lastfirst_lastfirstl
cold_daily_limit
integer1–50defaults to 15
Cold campaign messages a day. Nothing here enforces it — we do not send campaigns — it is the figure the credential export hands to your sequencer. Ten to fifteen is the useful range; above twenty is permitted and unwise, because inbox placement falls away sharply and takes the reply rate with it.
daily_limit
integer1–20defaults to 20
Where our own warming tops out. Not the cold sending limit — that is cold_daily_limit, and the two are enforced by different systems. Twenty is both the default and the maximum: past that the volume is itself a pattern, since one address holding forty separate conversations a day is not a person doing their job. Warming keeps running during campaigns, so both numbers come out of the same address every day.
warming
booleandefaults to true
Start warming as soon as the mailbox exists. Withheld regardless of this value if the account has no active subscription and is not complimentary — the mailbox is still created, just not warming yet. Check warming_withheld in the response.
warming_profile
stringdefaults to standard
standard reaches the ceiling in about two weeks, accelerated in about one. immediate removes the ramp entirely and is only safe on a domain with real sending history behind it.standardacceleratedimmediate
Returns
202
The addresses were recorded and provisioning was queued.
Errors
400nothing_requested
Neither personas nor count was sent.
402plan_limit
The batch would pass the plan’s hard ceiling. Carries limit and used.
404domain_not_found
No such domain on this account.
409domain_not_ready
The domain is not ready or active yet — usually DNS has not verified. Carries status.
409address_taken
One of the addresses already exists.
500insert_failed
The addresses could not be recorded.
Request
POST https://www.emailpal.io/api/public/v1/mailboxes
{
"job_id": "job_5m71pq",
"poll": "/api/public/v1/jobs/job_5m71pq",
"mailboxes": [
{ "id": "mbx_2c81f0", "email": "ada.lovelace@outbound-atlas.com" },
{ "id": "mbx_2c81f1", "email": "grace.hopper@outbound-atlas.com" }
],
"overage_billable": false,
"warming_withheld": false,
"note": "Provisioning now. Credentials are available from the export endpoint once the job finishes."
}
GET/mailboxes/{id}read
One mailbox, with health
Everything the list returns plus the signature, placement rates, sync timestamps and — when provisioning failed — the reason. failure_reason is returned rather than hidden because a status: "failed" with no explanation is the single most common thing a customer opens a ticket about.
Does not include the password. Credentials come from the export endpoint, which needs admin and is audited.
Returns
200
The mailbox exists on this account.
Errors
404not_found
No such mailbox on this account.
Request
GET https://www.emailpal.io/api/public/v1/mailboxes/{id}
Fields rather than an action name, because these are settings and a client that wants to pause a mailbox and drop its limit should not need two requests that can half-fail.
warming: false and warming_paused: true are different things and the difference is money. Disabling stops the warming charge. Pausing stops the sending, keeps the mailbox’s place on the ramp, and keeps billing. Sending both is rejected rather than guessed at.
Body
cold_daily_limit
integer1–50
Cold campaign messages a day, as handed to your sequencer by the credential export. Takes effect on the next export rather than immediately, since the sequencer holds its own copy.
daily_limit
integer1–20
Where our own warming tops out, up to the platform maximum of twenty. Not the cold sending limit.
warming
boolean
Whether warming runs at all. false also stops the monthly warming charge for this mailbox. true is refused with subscription_required on an account with no active subscription that is not complimentary.
warming_paused
boolean
Pause or resume sending while leaving warming enabled. Still billed — the mailbox keeps its ramp progress.
warming_profile
string
Changing this keeps the days already done and moves the mailbox to that point on the new curve, so it neither restarts nor skips ahead of its own history.standardacceleratedimmediate
paused
boolean
Pause the mailbox itself. Reaches the mail server, so this queues a job.
display_name
string1–80 characters
The From name.
signature
stringmax 2,000 characters
Appended to outbound mail.
Returns
200
Settings were applied. Nothing needed the mail server.
202
paused was sent, so a job was queued. Poll it.
Errors
400invalid_request
No fields were sent, or both warming: false and warming_paused: true were.
404not_found
No such mailbox on this account.
402subscription_required
warming: true was sent and the account has no active subscription and is not complimentary. Nothing was changed. Carries needsSubscription: true.
409mailbox_suspended
The mailbox is suspended. That is an enforcement action and cannot be lifted through the API.
Irreversible, and it discards the warming history — a replacement address starts from zero and has to earn its placement evidence again. Pausing is almost always the better answer if the goal is only to stop sending.
Returns text/csv, not JSON. Contains plaintext passwords, which is why it needs admin on its own rather than inheriting from write, and why every call is written to the audit log.
Only mailboxes that have finished provisioning and are warming, active or paused are included. The count is returned in the X-EmailPal-Exported header, and anything skipped for unreadable credentials in X-EmailPal-Skipped.
The file is UTF-8 with a BOM and CRLF line endings, because that is what the spreadsheet tools these feed into expect.
Query
format
stringdefaults to "generic"
Column layout. smartlead and instantly match those tools’ importers exactly; generic carries everything including security modes.genericsmartleadinstantly
domain_id
string
Only mailboxes on this domain.
mailbox_ids
string
A comma-separated list of specific mailbox ids.
Returns
200
At least one mailbox had readable credentials.
Errors
400invalid_format
format is not one of the three.
404nothing_to_export
No mailbox matched, or none had credentials yet.
500read_failed
The query failed.
500credentials_unreadable
Every matching row failed to decrypt.
Request
GET https://www.emailpal.io/api/public/v1/mailboxes/export
Ramp progress, measured placement, and the switches that decide what warming costs.
GET/warmingread
Progress, placement and cost
The question this answers is whether to start a campaign. campaign_ready is our answer to the combined question and campaign_blocker says what is missing when it is false, so a client does not have to reimplement the rule from the parts.
Behind it: inbox_rate below 85% means the domain is not trusted yet whatever day the ramp says it is on, ramp_progress is how far today’s volume has climbed toward the ceiling rather than how much time has passed, and rescued_from_spam above zero is an early warning even while the inbox rate still looks acceptable.
target is messages, not conversations: a warming conversation is answered and the answer is answered, so a mailbox spends a little over two messages on each one it starts. That is why conversations_started sits well below sent on every row and is not a shortfall.
Read from a daily rollup rather than counting messages, so polling this is cheap.
billable_mailboxes is the warming quantity on the invoice and will not match mailboxes in either direction. It is lower when leased pre-warmed inboxes are in play, since their warming is inside the lease rate and costs nothing extra.
Query
mailbox_id
string
Just one mailbox.
days
integer1–30defaults to 7
Length of the measurement window.
Returns
200
Always. An account with no warming mailboxes gets zeroes and a note.
Up to five hundred mailboxes in one call. Here rather than as a loop over PATCH /mailboxes/{id} because switching warming on for a hundred new addresses is one decision, and a client making a hundred calls to express it will hit the rate limit doing so and then have a hundred partial outcomes to reconcile.
Four verbs, not a boolean, because disable stops the charge and pause does not. A paused mailbox keeps its place on the ramp and stays billed; the response repeats this in billing.note so a confirmation dialog can quote it.
Mailboxes that could not be changed come back in skipped with a reason each, rather than failing the whole call.
enable is refused outright, before anything changes, on an account with no active subscription that is not complimentary — see subscription_required below. disable, pause and resume are never blocked this way.
Body
action
stringrequired
disable stops the monthly charge. pause does not.enabledisablepauseresume
mailbox_ids
string[]required1–500 items
The mailboxes to change. Ones that are not on this account are skipped, not errors.
Returns
200
At least one mailbox changed.
409
Every mailbox was skipped. The body still lists why for each.
Errors
402subscription_required
action was enable and the account has no active subscription and is not complimentary. Nothing changed. Carries needsSubscription: true.
500read_failed
The mailboxes could not be read.
500update_failed
The change could not be applied.
Request
POST https://www.emailpal.io/api/public/v1/warming
{
"action": "disable",
"changed": [
{ "mailbox_id": "mbx_2c81f0", "email": "ada.lovelace@outbound-atlas.com" }
],
"skipped": [
{
"mailbox_id": "mbx_2c81f1",
"email": "grace.hopper@outbound-atlas.com",
"reason": "This mailbox is suspended, which is an enforcement action. Get in touch."
}
],
"billing": {
"warming_mailboxes": 95,
"estimated_monthly_cost_cents": 5700,
"note": "Counts mailboxes with warming enabled plus any inboxes you connected yourself, which are billed at the same rate. Leased pre-warmed inboxes are never counted."
}
}
Inbox
Read and send from any mailbox. Warming traffic is excluded by default and should stay that way.
GET/inboxread
List received mail
Newest first, across every mailbox, with a preview. Warming traffic is excluded unless you ask for it: it is the majority of what arrives and none of it is from a real prospect.
Bodies are not included. Fetching one means talking to the mail server, so it is a separate call.
Query
limit
integer1–200defaults to 50
How many to return.
cursor
stringmax 200 characters
The next_cursor from the previous page. Keyset rather than an offset, so rows created while you page do not shift the window and make you miss one.
mailbox_id
string
Only mail for this mailbox.
unread
booleandefaults to false
Only messages that have not been marked seen.
include_warming
booleandefaults to false
Include warming traffic. Rarely what you want.
q
string
Case-insensitive match against sender, subject and preview.
Returns
200
Always.
Errors
500read_failed
The query failed.
Request
GET https://www.emailpal.io/api/public/v1/inbox
Response
{
"data": [
{
"id": "msg_6b20de",
"mailbox_id": "mbx_2c81f0",
"mailbox": "ada.lovelace@outbound-atlas.com",
"folder": "INBOX",
"message_id": "<CAF=8xQ@mail.example.com>",
"thread_key": "thr_44ac91",
"from": { "address": "priya@acme.example", "name": "Priya Raman" },
"to": ["ada.lovelace@outbound-atlas.com"],
"subject": "Re: quick question about your rollout",
"preview": "Thanks for following up — happy to take a look next week.",
"received_at": "2026-07-25T13:41:02.000Z",
"seen": false,
"flagged": false,
"warming": false,
"has_attachments": false
}
],
"has_more": false,
"next_cursor": null
}
GET/inbox/{id}read
One message, with its body
Returns 202 with status: "fetching" when the body is not cached, because getting it means an IMAP round trip we will not hold a connection open for. Poll this same URL — not /jobs/{id} — until status is ready. It is usually a second or two.
The headers and preview are present in both cases, so a client can render the message frame immediately and fill the body in when it arrives.
Returns
200
status is ready and body is populated.
202
status is fetching and body is null. Poll this URL again.
Errors
404not_found
No such message on this account, or it was deleted.
Request
GET https://www.emailpal.io/api/public/v1/inbox/{id}
Response
{
"status": "ready",
"id": "msg_6b20de",
"mailbox_id": "mbx_2c81f0",
"folder": "INBOX",
"message_id": "<CAF=8xQ@mail.example.com>",
"thread_key": "thr_44ac91",
"in_reply_to": "<CAF=7wP@mail.outbound-atlas.com>",
"from": { "address": "priya@acme.example", "name": "Priya Raman" },
"to": ["ada.lovelace@outbound-atlas.com"],
"subject": "Re: quick question about your rollout",
"sent_at": "2026-07-25T13:40:55.000Z",
"received_at": "2026-07-25T13:41:02.000Z",
"seen": false,
"flagged": false,
"warming": false,
"has_attachments": false,
"body": {
"text": "Thanks for following up — happy to take a look next week.",
"html": "<p>Thanks for following up — happy to take a look next week.</p>",
"fetched_at": "2026-07-25T13:41:09.000Z"
}
}
PATCH/inbox/{id}write
Mark read or flagged
Send at least one of the two. The change is written locally and pushed to the mail server on the next sync.
Sends as the named mailbox. Pass reply_to_id to thread the message properly — we derive the In-Reply-To and References headers from the original, which is what stops a reply arriving as a new conversation.
Recipients are checked against the suppression list before the message is queued. A suppressed address is a 409 rather than a silent drop, because a client that thinks it sent something it did not is worse off than one that got an error.
If you are answering a message we never saw — one your sequencer sent over SMTP, for instance — pass in_reply_to and references instead. Send one or the other, not both: where we can derive the headers ourselves we do, because a derived thread cannot be pointed at a conversation the account does not own.
Accepts an Idempotency-Key header. The RFC Message-ID appears on the job result once the send completes, and the receiver’s verdict on it at GET /inbox/send/{id}.
Body
mailbox_id
stringrequired
Which mailbox sends it.
to
string[]required1–10 items
Recipients.
cc
string[]max 10 items
Carbon copies.
subject
stringrequired1–500 characters
Subject line.
body
stringrequired1–50,000 characters
Plain text body.
reply_to_id
string
The id of the message being replied to. Sets the threading headers.
in_reply_to
string3–998 characters
RFC Message-ID being answered, angle brackets optional. Only honoured without reply_to_id.
references
string[]max 50 items
The thread so far, oldest first. in_reply_to is appended if missing.
Returns
202
The message was recorded and queued for sending.
Errors
404not_found
No such mailbox on this account.
404reply_target_not_found
reply_to_id is not a message on this account.
409mailbox_suspended
The mailbox is suspended.
409mailbox_provisioning
The mailbox has no credentials yet.
409recipient_suppressed
A recipient is on the suppression list. Carries address.
500insert_failed
The message could not be recorded.
Request
POST https://www.emailpal.io/api/public/v1/inbox/send
Body
{
"mailbox_id": "mbx_2c81f0",
"to": ["priya@acme.example"],
"subject": "Re: quick question about your rollout",
"body": "Next week works. Would Tuesday at 10 suit?",
"reply_to_id": "msg_6b20de"
}
The job behind a send finishes when our MTA accepts the message, so a succeeded job means we handed it over and nothing more. What the receiving server decided arrives minutes to hours later, and shows up here.
status is our side of the send; delivery_status is theirs. A hard bounce also adds the address to the suppression list, so no account sends to it again.
Subscribe to message.delivery_updated rather than polling this. The verdict has no schedule, so polling means either asking too often or learning too late.
delivery_status is one of queued, sent, delivered, deferred, bounced, complained or failed. It only moves towards being more final, so a late delivered for one recipient cannot erase a bounce from another.
bounce_type distinguishes what to do next: hard means the address does not exist and has been suppressed, soft means try later, block means the receiver refused us rather than the address — keep the contact.
This covers mail sent through this API. Mail your sequencer sends over SMTP with exported credentials has no record here, because it never passed through us.
Returns
200
Always, for a message on this account.
Errors
404not_found
No such message on this account.
Request
GET https://www.emailpal.io/api/public/v1/inbox/send/{id}
Mail syncs on its own schedule; this is for when you need it sooner. Rate limited to one sync per mailbox per thirty seconds, so calling it in a loop is harmless and also pointless — the extra calls come back with queued: 0.
Omit mailbox_id to sync every eligible mailbox on the account, up to two hundred.
Body
mailbox_id
string
Sync one mailbox instead of all of them.
Returns
202
Sync jobs were queued, or were already running.
Errors
409nothing_to_sync
No mailbox was eligible — they must be provisioned and active, warming or paused.
Request
POST https://www.emailpal.io/api/public/v1/inbox/refresh
Body
{ "mailbox_id": "mbx_2c81f0" }
Response
{
"queued": 1,
"mailboxes": 1,
"note": "Syncing now. New mail usually appears within a few seconds."
}
Inventory
Domains and mailboxes the platform aged and warmed before you asked for them. The expensive input to cold email infrastructure is time, and neither of these can be produced on the day you need one — which is the whole reason to hold stock.
GET/inventory/domainsread
What aged domains are on the shelf
Counts and price bands, never names. Publishing the exact names of unsold stock invites somebody to register the good ones out from under us, so this answers how many, how old and how much — and the names appear on GET /domains once they are yours.
previously_used is the part of the shelf recovered from accounts that left, re-warmed and re-listed once it cleared the health bar. First-hand stock is handed out first, so that price only applies once the clean shelf is empty.
Availability moves. A count read a minute ago is not a reservation — the claim is what reserves, and it can come back short.
Returns
200
Always, including when the shelf is empty.
Errors
500read_failed
The inventory query failed.
Request
GET https://www.emailpal.io/api/public/v1/inventory/domains
Takes domains off the shelf and attaches them to your account, charged against the domain balance. Which domains you get is decided in the worker under a row lock, because the price depends on the age of the specific domain assigned and two callers claiming at the same moment must not be promised the same one.
Returns a job. Poll it: DNS is re-checked and the parking page comes down before a domain is marked ready.
Send an Idempotency-Key header. This spends the domain balance, and a retry after a timeout is otherwise a second claim.
The balance check here is against the cheapest tier. A batch that clears it can still come back partly filled if the shelf runs out mid-claim.
Body
count
integerrequired1–10
How many to claim.
prefer_older
booleandefaults to false
Ask for the older tier. Falls back to the standard tier rather than failing when none are that old.
Returns
202
The claim was queued. Poll the job_id.
Errors
402plan_limit
The claim would exceed the plan’s domain allowance.
402insufficient_balance
The domain balance will not cover the cheapest tier at this count. Nothing was claimed.
Request
POST https://www.emailpal.io/api/public/v1/inventory/domains
Body
{
"count": 3,
"prefer_older": true
}
Response
{
"job_id": "job_71bd0c",
"poll": "/api/public/v1/jobs/job_71bd0c",
"note": "Assigning now. DNS is re-checked and the parking page comes down before the domain is marked ready, which usually takes a couple of minutes."
}
GET/inventory/prewarmedread
What pre-warmed inboxes are available
An aged domain stops you looking brand new; it does not give you an address a receiver has already seen behaving well. These are those addresses — provisioned onto inventory domains months before anyone asks and warmed continuously since, which is why they are leased monthly rather than sold. The warming is the product.
This is the endpoint to build an overflow pool on: a tenant who needs to send this week cannot wait out a fourteen-day ramp.
Counts and a health band, never addresses. previously_leased is disclosed here rather than at the point of sale, because finding out afterwards that an address had a previous tenant is being misled even when the reputation is genuinely the address’s own.
Returns
200
Always, including when the pool is empty.
Errors
500read_failed
The pool query failed.
Request
GET https://www.emailpal.io/api/public/v1/inventory/prewarmed
Leases warmed addresses onto your account. They arrive campaign-ready rather than at day zero of a ramp, keep warming inside the monthly rate, and have their passwords rotated on handover so nothing we used to run the warming still opens them.
A minimum term applies, and it is enforced on delete as well as here — a term a script can walk around by calling the API instead of clicking the button is not a term.
Send an Idempotency-Key header. This starts a recurring charge with a minimum term.
Leases are billed at the lease rate rather than against the plan’s mailbox allowance.
Deleting a leased mailbox before the term ends is refused. After it ends, the address is handed back to the pool rather than destroyed.
Body
count
integerrequired1–25
How many to lease.
domain_id
string
Take them all on one domain you already hold, so the addresses read as one company rather than five. Omit to let us spread them.
Returns
202
The lease was queued. Poll the job_id.
Errors
402plan_limit
The account is suspended or otherwise stopped from growing.
Request
POST https://www.emailpal.io/api/public/v1/inventory/prewarmed
Body
{
"count": 10
}
Response
{
"job_id": "job_5c9e18",
"poll": "/api/public/v1/jobs/job_5c9e18",
"term_days": 30,
"monthly_cents_each": 300,
"note": "Each inbox is 30 days minimum term. The addresses keep warming inside the rate, and their passwords are rotated on handover so nothing we used to run the warming still opens them."
}
Jobs
Where every 202 ends up. Poll these rather than assuming an accepted request succeeded.
GET/jobsread
Recent jobs
Newest first. Filter by type to watch one kind of work, or by status to find what failed.
Query
limit
integer1–200defaults to 50
How many to return.
cursor
stringmax 200 characters
The next_cursor from the previous page. Keyset rather than an offset, so rows created while you page do not shift the window and make you miss one.
type
string
Exact job type, e.g. domain.purchase.
status
string
Only jobs in this state.queuedrunningsucceededfailedcancelleddead
The poll target for everything asynchronous. terminal is the field to loop on — it is true for succeeded, failed, dead and cancelled, so a client does not have to keep its own list of which statuses mean stop.
retry_after is set when a job failed but has attempts left, and is the time the queue will try again on its own. A dead job has run out of attempts and will not.
Returns
200
The job exists on this account.
Errors
404not_found
No such job on this account.
Request
GET https://www.emailpal.io/api/public/v1/jobs/{id}
Retry puts a failed, dead or cancelled job back on the queue with its attempt count reset. Cancel stops one that has not finished.
Retrying is safe: every job that touches money or a registrar carries an idempotency key, so a retried purchase cannot buy the same domain twice.
Body
action
stringrequired
What to do with it.retrycancel
Returns
200
Cancelled.
202
Requeued. Poll it again.
Errors
404not_found
No such job on this account.
409job_finished
Cancel on a job that already succeeded or was cancelled.
409not_retryable
Retry on a job that is not failed, dead or cancelled.
Request
POST https://www.emailpal.io/api/public/v1/jobs/{id}
Body
{ "action": "retry" }
Response
{ "id": "job_4h29xk", "status": "queued" }
Webhooks
Signed HTTP callbacks, so you stop polling. Everything asynchronous here otherwise has to be discovered by asking repeatedly, which puts a floor under how fast anything can be noticed — and the two events worth having quickly, an inbound reply and a mailbox becoming campaign-ready, are exactly the two that do not arrive on a schedule you could anticipate.
GET/webhooksread
List your webhook endpoints
Every endpoint on the account, with its recent health. Secrets are never included — they are shown once, when the endpoint is created or its secret is rotated.
available_events lists every event we can send, so a subscription UI can be built from the API rather than from a docs page somebody has to remember to update.
Returns
200
Always, including when there are no endpoints.
Errors
500read_failed
The query failed.
Request
GET https://www.emailpal.io/api/public/v1/webhooks
Verify it by computing HMAC-SHA256 over ${t}.${raw_body} with your secret and comparing the hex digest to v1, in constant time. Reject anything whose t is more than five minutes from your clock — the timestamp is inside the MAC precisely so a captured payload cannot be replayed later.
Compare against the raw body, before any JSON parsing. Re-serialising changes the bytes and the signature will not match.
Delivery is at-least-once. Dedupe on the id field, which is stable across retries.
Answer within ten seconds with any 2xx. Acknowledge first and process afterwards — we time out and retry, and a slow handler turns one event into six.
Retries run at 30s, 2m, 10m, 30m, 2h and 6h. After that the delivery is marked dead.
Twenty consecutive failures disables the endpoint. Re-enable it with PATCH once the endpoint is back.
Redirects are not followed, and the destination is re-checked against its resolved address on every send.
message.delivery_updated carries what the receiving server decided about a send: delivery_status, bounce_type, and the SMTP diagnostic verbatim. A hard bounce or a complaint also suppresses the address, so you do not need to classify bounce mail yourself.
message.received carries headers and a preview, never the body. Fetch the body from /inbox/{id} when you need it — pushing every reply in full would put your prospects’ mail in your logs.
Body
url
stringrequired12–2,048 characters
Where to POST. Must be https, must resolve to a public address, and must not carry credentials — the signature is how you authenticate us.
events
string[]
Which events to send. Omit or send an empty array for all of them, including ones added later — a pinned list silently misses anything we ship afterwards.job.succeededjob.faileddomain.readydomain.failedmailbox.provisionedmailbox.campaign_readymailbox.failedlist.progresslist.completedmessage.receivedmessage.delivery_updated
description
stringmax 200 characters
For your own reference. Shown in the dashboard.
Returns
201
The endpoint was registered. The secret is in this response only.
Errors
400invalid_webhook_url
Not https, resolves to a private address, or carries credentials.
409too_many_endpoints
The account already has ten endpoints.
Request
POST https://www.emailpal.io/api/public/v1/webhooks
{
"id": "whe_3f81aa",
"url": "https://hooks.example.com/emailpal",
"description": "production",
"events": ["message.received", "mailbox.campaign_ready"],
"subscribed_to_all": false,
"enabled": true,
"secret": "whsec_kP3n...",
"note": "Store this secret now — it is not shown again."
}
GET/webhooks/{id}read
One endpoint, with its recent deliveries
The last twenty delivery attempts, with the status code and error text your endpoint returned. This is the endpoint to open when something did not arrive — the answer is nearly always in the attempts rather than in the configuration.
Returns
200
The endpoint exists on this account.
Errors
404not_found
No such endpoint on this account.
Request
GET https://www.emailpal.io/api/public/v1/webhooks/{id}
Setting enabled: true on an endpoint we disabled automatically also clears its failure count — otherwise the first hiccup after a fix would switch it straight back off.
rotate_secret issues a new secret and returns it once. The old one stops working immediately: a leaked secret is not made safer by a grace period during which it still verifies.
Body
url
string12–2,048 characters
A new destination.
events
string[]
Replaces the subscription list. An empty array means all events.job.succeededjob.faileddomain.readydomain.failedmailbox.provisionedmailbox.campaign_readymailbox.failedlist.progresslist.completedmessage.receivedmessage.delivery_updated
description
stringmax 200 characters
For your own reference.
enabled
boolean
Switch delivery on or off. Turning it on clears an automatic disable.
rotate_secret
boolean
Issue a new signing secret, returned once in this response.
Check a list before you send to it, and keep addresses that must never be mailed out of your campaigns.
GET/suppressionsread
List suppressions, or check one address
Two modes. Pass address to ask about one address and get a direct yes or no — that is the call to make before a send, and it answers for domain-level suppressions too, which a naive lookup against your own copy of the list would miss.
Omit it to page through everything suppressed on the account.
scope is "domain" when the address is covered by a whole-domain suppression rather than its own row.
Query
limit
integer1–200defaults to 50
How many to return.
cursor
stringmax 200 characters
The next_cursor from the previous page. Keyset rather than an offset, so rows created while you page do not shift the window and make you miss one.
address
string
Check this one address. Pagination is ignored when it is present.
Returns
200
Always.
Errors
400invalid_address
address is not a valid email address.
500read_failed
The query failed.
Request
GET https://www.emailpal.io/api/public/v1/suppressions
Exactly one of address or domain. Suppressing a domain stops mail to every address on it, which is the right response to an abuse complaint from a company rather than a person.
Suppressing something already suppressed returns 200 rather than an error, so an integration that replays its own queue does not have to special-case it.
Body
address
stringmax 320 characters
The address to suppress. Send this or domain.
domain
string3–253 characters
The domain to suppress. Send this or address.
reason
string3–200 charactersdefaults to "Suppressed on request."
Free text, kept for the audit trail.
Returns
201
Newly suppressed.
200
Already suppressed. The body carries already_suppressed: true.
Errors
400invalid_request
Neither or both of address and domain were sent.
500insert_failed
The suppression could not be recorded.
Request
POST https://www.emailpal.io/api/public/v1/suppressions
Body
{
"address": "priya@acme.example",
"reason": "Asked to be removed by reply."
}
Response
{
"id": "sup_5d10ba",
"address": "priya@acme.example",
"domain": null,
"created_at": "2026-07-25T15:10:00.000Z",
"note": "Suppressed across every mailbox on this account."
}
POST/lists/verifywrite
Check a recipient list before sending to it
Send the raw file contents — CSV, one-per-line, or anything with addresses in it — and we extract the addresses and check every one of them. A list that would mostly bounce is worth knowing about before it costs you a domain rather than after.
This is the same engine as POST /verify, run over a whole file, and it returns the same status vocabulary — including catch_all, inbox_full and disabled, which need a live check of the mailbox and cannot be inferred from a domain’s records.
Costs one verification credit per extracted address, taken in full when the list is accepted. A list that does not fit in what is left of today’s allowance is refused outright rather than half-answered, and the error names the shortfall so you can split the file.
Asynchronous, and results arrive as they are decided rather than all at the end. Two poll targets, both usable while the list is still running: GET /lists/{id}/results for the per-address answers, and GET /lists/{id} for the counts and the whole-list verdict. Neither is the job — everything worth reading lives on the upload.
Large lists take minutes to hours, not seconds. Probing one receiving domain faster than a real sender would is what gets a verifier blocked there, so the pace is deliberate — and because results stream, you can act on the first few thousand addresses while the rest are still being checked.
A list bigger than the capacity currently free is not truncated or half-answered. It waits, and finishes; the only bound is a 24-hour deadline, past which anything still unchecked comes back unknown and is refunded.
Credits are refunded for every address that finishes unknown, and never for one that is merely still queued — those are work we still owe you. The charge is for an answer, so a mailbox we could not get a definitive verdict from is not billed.
Subscribe to list.progress and list.completed if you would rather not poll. list.progress fires at most once a minute per list and carries the results URL.
The per-address results are kept for 90 days and then deleted; the counts and the verdict on GET /lists/{id} are kept for as long as the upload. Pull down anything you need to keep before then.
Body
content
stringrequired3–12,000,000 characters
The raw file contents.
filename
stringmax 200 characters
Shown in the dashboard so a human can tell uploads apart.
Returns
202
Addresses were extracted, credits were taken, and verification was queued.
Errors
400no_addresses
Nothing in the content parsed as an email address.
402verification_capacity_required
The account’s plan includes no daily verification allowance and no blocks have been bought. Nothing was queued and no credits were taken.
429verification_limit_reached
The list needs more credits than are left today. Carries addresses, used and limit. Nothing was queued and no credits were taken — send a smaller slice or wait for midnight UTC.
500insert_failed
The upload could not be recorded.
Request
POST https://www.emailpal.io/api/public/v1/lists/verify
The addresses themselves, one row each, in the order they were answered. Readable while the list is still running — that is the point of it. A list larger than the verification capacity free right now is answered over hours, and waiting for status to leave verifying would mean sitting on nothing while thousands of usable answers piled up behind it.
Paged oldest-first, unlike the other list endpoints. Keep the next_cursor from your last response and every poll returns only what has been decided since, so draining a list of any size costs one pass over it.
has_more: false means you are caught up with what has been decided, not that the list is done. Branch on list.finished for that, and keep polling with the same cursor until it is true.
One row per distinct address. A file listing the same address three times produces one result and two duplicates — the count of those is duplicate on GET /lists/{id}.
Statuses are the same vocabulary POST /verify answers in, plus suppressed and duplicate, which only a list can produce.
Results are kept for 90 days, and deleted immediately with the upload if you delete it sooner. This is a feed to drain rather than a store to come back to — keep your own copy of anything you need long-term. GET /lists/{id} still answers with the counts and the verdict after that.
Query
limit
integer1–200defaults to 50
How many to return.
cursor
stringmax 200 characters
The next_cursor from the previous page. Keyset rather than an offset, so rows created while you page do not shift the window and make you miss one.
status
string
Return only this status, e.g. invalid. Usually the whole reason for the call — fetching every safe row to filter it client-side is the expensive way to find the ones to drop.
Returns
200
The upload exists on this account, including while it is still verifying.
Errors
400invalid_pagination
limit is outside 1–200, or cursor is not one we handed you.
404not_found
No such upload on this account.
500read_failed
The query failed.
Request
GET https://www.emailpal.io/api/public/v1/lists/{id}/results
The verdict on a submitted list, and how far through it is
Poll until status leaves pending and verifying. sendable is the field to branch on: it is true for passed and flagged, and false for blocked and failed.
flagged means send it but know what is in it. blocked means the list would damage the reputation of every domain on the account, and blocked_reason says which measure crossed the line.
Every count except risky is one status per address, in the same vocabulary POST /verify uses, and risky is the derived figure the block threshold is applied to: catch_all + role_account + disposable.
The counts fill in as the list is checked, so they are worth reading before it finishes — but while status is verifying they describe only the addresses answered so far and will sum to less than total. progress.remaining is the gap. They sum to total once it is done.
For the addresses rather than the totals, use GET /lists/{id}/results.
status is one of pending, verifying, passed, flagged, blocked or failed.
depth is probed when the mailbox-level check ran, and classified when only the free syntax and MX gate did — on a classified upload, catch_all, inbox_full and disabled are zero because nothing asked, which is not the same as none being found. Uploads checked before mailbox verification existed read classified forever.
unknown means we got no definitive answer, usually greylisting. Treat it as unchecked rather than as bad; deleting those addresses is how a clean list gets smaller for no reason.
progress.answered counts distinct addresses with a result, so on a file containing duplicates it settles below total by exactly the duplicate count. progress.remaining is what is still to be checked — on a running list that is a queue, not a failure.
Returns
200
The upload exists on this account, including while it is still verifying.
Errors
404not_found
No such upload on this account.
Request
GET https://www.emailpal.io/api/public/v1/lists/{id}
Instant, not queued — this is the one exception to the async-for-anything-that-touches-mail rule, because the reason to call it is to decide something before the next line of code runs, the same way you would call Reoon or NeverBounce.
Metered out of the same daily allowance as POST /lists/verify, at the same one-credit-per-address rate, because it is the same probe against the same egress pool. The plan includes an allowance and more is bought in blocks of 500/day from the dashboard's Email Verification card. credits.limit and credits.used in the response are that allowance, checked and incremented before the address is ever probed — a failed probe still spends the credit, the same rule the hourly rate limit uses, because a client retrying a slow domain for free is the case metering exists for. The one exception is a 503: if we had no egress address free to probe from, nothing was attempted and the credit goes back.
The verdict vocabulary is the one Reoon and NeverBounce use, so switching over is a find-and-replace: safe, invalid, disposable, role_account, catch_all, disabled, inbox_full or unknown. sub_status narrows unknown and invalid further — no_mx_record, unable_to_connect and antispam_system (greylisted; we do not retry inline) are the ones worth branching on, since each suggests a different next step.
No retry on a greylisted mailbox — unknown/antispam_system comes back immediately rather than waiting out the receiving server’s delay, which is the right trade for a synchronous call and the reason this can be instant at all.
Bulk checking a file you already have is POST /lists/verify: same engine, same statuses, same one credit per address, but asynchronous and with a whole-list verdict on top of the per-address counts.
Body
email
stringrequiredmax 320 characters
The address to check.
Returns
200
Always — an undeliverable address is a result, not an error.
Errors
402verification_capacity_required
The account’s plan includes no daily verification allowance and no blocks have been bought.
429verification_limit_reached
Today’s allowance is spent. Carries limit and used. Resets at midnight UTC — separate from, and on top of, the hourly rate_limited error every endpoint can return.
503capacity_exhausted
Every egress address is at its hourly limit, so the probe could not be sent. The credit was not counted — wait out the Retry-After header and send it again. Rare, and it means our capacity rather than anything about the address.
502internal_error
The verification worker did not answer. Safe to retry — the credit is spent only when a verdict was returned.