REST API

EmailPal API

Buy domains, provision mailboxes, run warming, read and send mail, check a list before you send to it, then run the campaign and work the replies: sequences, leads, lead statuses, pipelines, opportunities and tasks. The dashboard and this API sit on the same operations layer.

Not everything in the dashboard is here. There are no endpoints for deleting leads, opportunities or tasks, for managing lead lists or custom lead statuses (both are read-only), or for bulk lead actions. Lead search and reveal, the Prospects workspace behind the unlimited free leads on every paid plan, are dashboard-only: the one endpoint that touches that data is reporting a record. A campaign’s schedules are edited through PATCH /campaigns/{id} rather than a separate endpoint, and tasks are returned in one page without a cursor.

Base URL
https://www.emailpal.io/api/public/v1

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 verified
curl -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"

Authentication

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.

Headers
Authorization: Bearer ep_live_7d21ff9c4a...
Content-Type: application/json

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, mail, campaigns, leads and CRM records. Includes launching and resuming campaigns, which puts mail on the wire. 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.

Targeting a workspace

A key acts on the workspace it was created in. A key created on the account that pays for a billing pool can also act on any workspace billed to it: send that workspace’s id in X-Workspace-Id, or in a workspace_id query parameter where a header is awkward. The header wins when both are sent. GET /workspaces lists the ids a key can use.

Headers
Authorization: Bearer ep_live_7d21ff9c4a...
X-Workspace-Id: 2c90ab14-5e3f-4d8a-9b61-0f7c2e4a8d11
  • A key on a workspace that is billed to another account reaches only that workspace. It cannot name the payer or a sibling.
  • A workspace the key cannot reach is answered with 404 workspace_not_found, the same answer as for one that does not exist, so a key cannot discover which workspaces exist in other pools.
  • Scopes are the key’s and do not change with the workspace. The rate limit is the key’s too: one key driving five workspaces has one hourly allowance, not five.
  • Writes are audited on the workspace that was changed, with the key’s own account recorded beside them.
  • A few list endpoints accept X-Workspace-Id: all and answer for every workspace the key reaches, with a workspace_id on each item. They say so on the endpoint; everywhere else all is refused with 400 workspace_all_unsupported.

GET /workspaces lists the workspaces a key reaches, and POST /workspaces adds one to the pool. The MCP server takes the same id as an optional workspace_id argument on every tool.

Rate limits

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.

If you are provisioning in bulk, prefer the endpoints that take arrays — creating a hundred mailboxes or switching warming on five hundred — over looping. That is what they are for.

Response headers
X-RateLimit-Limit: 30000
X-RateLimit-Reset: 2026-07-25T16:00:00.000Z
Retry-After: 224

Errors

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
  }
}
400invalid_request
The body failed validation. The message names the fields.
400workspace_all_unsupported
X-Workspace-Id: all was sent to an endpoint that acts on one workspace at a time.
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.
404workspace_not_found
The workspace named in X-Workspace-Id (or workspace_id) is not one this key reaches. The same answer whether it does not exist or belongs to another pool.
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.

Pagination

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.

Envelope
{
  "data": [ /* … */ ],
  "has_more": true,
  "next_cursor": "2026-06-30T08:15:00.000Z"
}

Asynchronous work

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.

A job in flight
{
  "id": "job_4h29xk",
  "type": "domain.purchase",
  "status": "running",
  "terminal": false,
  "attempts": 1,
  "max_attempts": 2,
  "progress": 50,
  "progress_message": "Registering with the registrar",
  "error": null,
  "retry_after": null
}

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.

What costs money

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.

Buying domains

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.

Warming

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.

Mailboxes past the plan allowance

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.

MCP server

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.

It gives an assistant 106 tools, each one call to this API: domains, mailboxes, warming and the inbox, and also campaigns, their sequences, leads, the CRM (lead statuses, opportunities, tasks) and campaign replies. Launching or resuming a campaign sends real email, so those two tools are marked destructive and run the same pre-flight as the dashboard. Set EMAILPAL_MCP_READ_ONLY=true and every tool that changes anything disappears from the list, leaving the ones that only read.

Cursor or Claude Desktop
{
  "mcpServers": {
    "emailpal": {
      "command": "npx",
      "args": ["-y", "@emailpal/mcp"],
      "env": { "EMAILPAL_API_KEY": "ep_live_…" }
    }
  }
}

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 the number of mailboxes with warming enabled, counted the way the invoice counts them. Warming only runs on mailboxes EmailPal hosts, and leased pre-warmed inboxes are excluded because their warming is inside the lease rate, so it will not equal usage.mailboxes.used.

usage.dedicated_ips is the number of dedicated IPs the account is billed for, counted the way the invoice counts them: from the moment one is requested, even while it waits for a ramp slot, until it is released. It is 0 on nearly every account.

enforcement is the account’s sending standing: none, watch, no_new_mailboxes, throttled or suspended. While it is not none, enforcement_reason says why in a sentence, enforcement_since says when the account entered that level, and enforcement_scope says how far a throttle reaches: account (every mailbox) or domains (only the mailboxes on restricted_domains, each with the refusal count and rate it was judged on, and every other domain sends normally). All three are null, and restricted_domains is empty, while enforcement is none. These are the same facts the account.restriction_changed webhook carries, so you can listen for the change and poll to confirm.

200
Always, for a valid key on a live account.
404account_missing
The account could not be read.
Request
GET https://www.emailpal.io/api/public/v1/account
Response
{
  "id": "acc_7f3d21",
  "name": "Northworks",
  "status": "active",
  "enforcement": "throttled",
  "enforcement_scope": "domains",
  "enforcement_reason": "Receivers are refusing mail from 2 of your domains, so they are limited to a quarter of normal.",
  "enforcement_since": "2026-10-16T08:00:00.000Z",
  "restricted_domains": [
    {
      "domain_id": "dom_91af22",
      "domain": "outbound-atlas.com",
      "since": "2026-10-16T08:00:00.000Z",
      "rejected": 9,
      "completed": 120,
      "rate": 0.075,
      "reason": "Gmail refusing 9 of 120 (7.5%)"
    }
  ],
  "created_at": "2026-02-14T09:12:44.000Z",
  "plan": {
    "code": "growth",
    "name": "Growth",
    "monthly_price_cents": 18900,
    "included_mailboxes": 200,
    "included_domains": 40,
    "mailbox_overage_cents": 100,
    "warming_price_cents": 60,
    "dedicated_ip_cents": 2000,
    "prewarmed_inbox_cents": 300,
    "domain_markup_cents": 100,
    "daily_send_per_mailbox": 15,
    "api_requests_per_hour": 30000
  },
  "usage": {
    "mailboxes": { "used": 148, "limit": 200 },
    "domains": { "used": 26, "limit": 40 },
    "daily_send": { "used": 1180, "limit": 2220 },
    "warming_mailboxes": 96,
    "warming_monthly_cost_cents": 5760,
    "dedicated_ips": 0
  },
  "domain_balance_cents": 24150,
  "top_up_url": "/api/public/v1/account/topup"
}
POST/account/topupadmin

Add money to the domain balance

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.

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.
201
A payment page was created. No money has moved yet.
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
Body
{
  "amount_cents": 25000,
  "return_url": "https://your-app.example/billing"
}
Response
{
  "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."
}

Workspaces

The workspaces sharing one bill. A key on the paying account reaches all of them through `X-Workspace-Id`.

GET/workspacesread

The workspaces this key can act on

Every workspace the key can name in X-Workspace-Id, the paying account first. For a key on the paying account that is the whole billing pool; for a key on a workspace billed to another account it is just that workspace.

Each carries what a client needs to pick one: its domain and mailbox counts and how many Unibox threads have unread mail. current_workspace_id is the workspace this request acted on (the key’s own unless you sent the header), and key_workspace_id is the one the key belongs to.

200
Always, for a valid key.
Request
GET https://www.emailpal.io/api/public/v1/workspaces
Response
{
  "data": [
    {
      "id": "acc_7f3d21",
      "name": "Northworks",
      "slug": "northworks",
      "is_payer": true,
      "status": "active",
      "domains": 26,
      "mailboxes": 148,
      "unread_threads": 4,
      "created_at": "2026-02-14T09:12:44.000Z"
    },
    {
      "id": "acc_2c90ab",
      "name": "Client: Halden Tools",
      "slug": "client-halden-tools",
      "is_payer": false,
      "status": "active",
      "domains": 6,
      "mailboxes": 18,
      "unread_threads": 0,
      "created_at": "2026-09-30T15:02:11.000Z"
    }
  ],
  "current_workspace_id": "acc_7f3d21",
  "key_workspace_id": "acc_7f3d21"
}
POST/workspacesadmin

Add a workspace to the billing pool

Creates a workspace billed to the paying account: its own domains, mailboxes, inbox and keys, on the payer’s plan and limits. Needs an admin key created on the paying account; a workspace’s own key cannot grow the pool it is billed to.

The same checks as the dashboard’s Add workspace: the payer must have a plan, no workspace in the pool may be restricted or behind on payment, and a pool holds at most 25 workspaces. The payer’s owners and admins become the new workspace’s owners and admins, with the same roles, so the people who manage the bill can open it in the dashboard.

Use the returned id in X-Workspace-Id to start provisioning in it straight away.

name

stringrequired2–120 characters

Shown in the workspace switcher. Usually the client or brand the workspace is for.
201
The workspace exists and can be targeted.
403not_payer
The key belongs to a workspace billed to another account.
403subscribe_required
The paying account has no plan yet.
403pool_restricted
A workspace in the pool is restricted or behind on payment.
409workspace_limit
The pool already has 25 workspaces.
409no_payer_admins
The paying account has no owner or admin to hand the workspace to.
Request
POST https://www.emailpal.io/api/public/v1/workspaces
Body
{ "name": "Client: Halden Tools" }
Response
{
  "id": "acc_2c90ab",
  "name": "Client: Halden Tools",
  "slug": "client-halden-tools",
  "is_payer": false,
  "status": "active",
  "domains": 0,
  "mailboxes": 0,
  "unread_threads": 0,
  "created_at": "2026-10-21T16:40:02.000Z"
}
GET/workspaces/{id}read

One workspace

The same fields as an entry in GET /workspaces, for one workspace this key reaches.

200
The workspace is reachable with this key.
404workspace_not_found
The id is not a workspace this key reaches: missing, in another pool, or a sibling of a workspace key.
Request
GET https://www.emailpal.io/api/public/v1/workspaces/{id}
Response
{
  "id": "acc_7f3d21",
  "name": "Northworks",
  "slug": "northworks",
  "is_payer": true,
  "status": "active",
  "domains": 26,
  "mailboxes": 148,
  "unread_threads": 4,
  "created_at": "2026-02-14T09:12:44.000Z"
}
PATCH/workspaces/{id}admin

Rename a workspace

Changes the name shown in the workspace switcher and in the billing page’s per-workspace breakdown. The slug does not change with it.

name

stringrequired2–120 characters

The new name.
200
Renamed. Returns the workspace.
404workspace_not_found
The id is not a workspace this key reaches: missing, in another pool, or a sibling of a workspace key.
Request
PATCH https://www.emailpal.io/api/public/v1/workspaces/{id}
Body
{ "name": "Halden Tools" }
Response
{
  "id": "acc_7f3d21",
  "name": "Northworks",
  "slug": "northworks",
  "is_payer": true,
  "status": "active",
  "domains": 26,
  "mailboxes": 148,
  "unread_threads": 4,
  "created_at": "2026-02-14T09:12:44.000Z"
}

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.

sending says where the domain’s mail goes: lane is shared (the shared pool) or dedicated (the account’s own IP), state is none, queued (selected, waiting to join the IP) or admitted (on it), and dedicated_ip_id is the IP’s id on /dedicated-ips while the domain is queued or admitted. Every domain reads shared / none unless the account has a dedicated IP.

restriction says whether sending from the domain is held, limited or restarting: restricted, stage (normal, resting, ramping, replace or limited), source (breaker, recovery or throttle), reason (a stable code), message (the sentence the dashboard shows), rest_until (when a resting domain restarts) and ramp_cap (cold emails per mailbox today: 0 while resting or at replace, the ramp’s cap while ramping). A domain that is delivering normally reads restricted: false, stage: "normal" and nulls. The domain.restriction_changed webhook carries the same fields.

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_dnsagingreadyactivesuspendedreleasingmovingmoved_awayretired
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.
200
Always, including when the account has no domains.
400invalid_status
status is not one of the listed values.
400invalid_pagination
limit is out of range or cursor is not one we issued.
500read_failed
The query failed.
Request
GET https://www.emailpal.io/api/public/v1/domains
Response
{
  "data": [
    {
      "id": "dom_91af22",
      "name": "outbound-atlas.com",
      "status": "active",
      "sending": { "lane": "shared", "dedicated_ip_id": null, "state": "none" },
      "restriction": {
        "restricted": false,
        "stage": "normal",
        "source": null,
        "reason": null,
        "message": null,
        "rest_until": null,
        "ramp_cap": null
      },
      "customer_owned": false,
      "dns_method": "nameservers",
      "nameservers_verified": true,
      "registered_at": "2026-03-02T11:00:00.000Z",
      "aging_started_at": "2026-03-02T11:00:00.000Z",
      "expires_at": "2027-03-02T11:00:00.000Z",
      "auto_renew": true,
      "reputation_score": 94,
      "mailbox_count": 6,
      "daily_capacity": 240,
      "registrar": "dynadot",
      "tags": ["outbound", "tenant:44"],
      "created_at": "2026-03-02T10:58:11.000Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
POST/domainswrite

Buy domains, or connect ones you own

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.

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.
202
At least one domain was queued. Poll each job_id.
409
Every name was rejected. The rejected array says why for each.
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
Body
{
  "mode": "purchase",
  "domains": ["outbound-atlas.com", "atlas-mail.co"],
  "years": 1
}
Response
{
  "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.

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.
offset

integer0–100,000defaults to 0

Ranked suggestions to skip, so a repeat call with offset = previous offset + limit returns the next ideas for the same seed. Applies to seed only.
years

integer1–10defaults to 1

Price a multi-year registration, so the quote matches a purchase for the same term.
200
The registrar answered. Unavailable names are results, not 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
Body
{
  "seed": "northworks",
  "tlds": ["com", "co"],
  "limit": 10
}
Response
{
  "results": [
    {
      "domain": "northworks-mail.com",
      "available": true,
      "price_cents": 1300,
      "registrar_cents": 1099,
      "currency": "USD",
      "premium": false
    },
    {
      "domain": "northworks.com",
      "available": false,
      "price_cents": null,
      "registrar_cents": null,
      "currency": null,
      "premium": false
    }
  ],
  "markup_cents": 100,
  "years": 1,
  "checked": 10
}
GET/domains/{id}read

One domain, with its DNS state

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.

restriction is the same block GET /domains returns: whether sending from this domain is held, limited or restarting, with a stable reason code, a message sentence, rest_until and ramp_cap. A resting domain restarts on its own at rest_until; nothing needs doing.

200
The domain exists on this account.
404not_found
No such domain on this account.
Request
GET https://www.emailpal.io/api/public/v1/domains/{id}
Response
{
  "id": "dom_91af22",
  "name": "outbound-atlas.com",
  "status": "active",
  "sending": { "lane": "dedicated", "dedicated_ip_id": "dip_4be1c9", "state": "admitted" },
  "restriction": {
    "restricted": true,
    "stage": "resting",
    "source": "breaker",
    "reason": "provider_refusals",
    "message": "Gmail refused this domain, so sending from it is paused while it rests.",
    "rest_until": "2026-10-23T08:00:00.000Z",
    "ramp_cap": 0
  },
  "customer_owned": false,
  "dns_method": "nameservers",
  "nameservers_verified": true,
  "nameservers_checked_at": "2026-07-25T04:00:00.000Z",
  "registered_at": "2026-03-02T11:00:00.000Z",
  "expires_at": "2027-03-02T11:00:00.000Z",
  "auto_renew": true,
  "reputation_score": 94,
  "mailbox_count": 6,
  "daily_capacity": 240,
  "registrar": "dynadot",
  "dns_provider": "cloudflare",
  "local_part_pattern": "first.last",
  "parked": false,
  "redirect_url": "https://acme-redirects.com/go",
  "redirect_status": "live",
  "redirect_checked_at": "2026-07-25T04:00:00.000Z",
  "created_at": "2026-03-02T10:58:11.000Z",
  "dns": {
    "healthy": true,
    "records": [
      {
        "type": "TXT",
        "name": "outbound-atlas.com",
        "purpose": "spf",
        "expected": "v=spf1 include:s1.outbound-atlas.com -all",
        "observed": "v=spf1 include:s1.outbound-atlas.com -all",
        "status": "verified",
        "verified_at": "2026-07-25T04:00:00.000Z"
      }
    ]
  },
  "pending_nameservers": null
}
PATCH/domains/{id}write

Change a domain’s tags

Replaces the domain’s labels. Separate from the action call below, which talks to the registrar and DNS: relabelling queues nothing and leaves the database, so it answers immediately rather than with a job id for work that does not exist. Duplicates are removed.

tags

string[]requiredmax 20 items

The full set of tags, each up to 64 characters. Replaces the existing set.
200
Applied.
404not_found
No such domain on this account.
Request
PATCH https://www.emailpal.io/api/public/v1/domains/{id}
Body
{ "tags": ["q3-outbound", "us"] }
Response
{ "id": "dom_7a42c1", "tags": ["q3-outbound", "us"] }
POST/domains/{id}write

Verify DNS, rewrite DNS, rotate DKIM, set the website redirect, or release

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 of them 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.

A redirect is written as two proxied records and a Worker route, and the Worker reads the target from a key-value map at the edge. That map is eventually consistent, so a fresh redirect — and a cleared one — can take about a minute to take effect everywhere. redirect_status is the field to poll: pending means accepted but unconfirmed, live means a request to the domain came back with the target, and failed means it was tried and did not.

Setting a redirect on a domain that already serves a website fails rather than overwriting it. Taking the apex over would take that site down, so the job reports which record is in the way and the caller has to decide.

Not available on a pre-warmed pool domain. Those host addresses leased across several tenants, so there is no single owner to redirect for — set_redirect returns 409 prewarmed_domain and the landing page is left alone.

Example: { "action": "set_redirect", "redirect_url": "https://acme-redirects.com/go" }.

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.

move_workspace returns move_id as well as job_id. While it runs the domain’s status is moving and sending, warm-up and inbox sync carry on; when the job succeeds the domain is on the other workspace with its previous status back, and domain.moved goes to both workspaces. What moves: the domain, its mailboxes, their Unibox threads and stored messages, scheduled replies, tracking domain and suppressions (copied, so both workspaces keep them). What stays: campaigns and their send history, contacts (a reply gets a contact in the new workspace), invoices, and sending reputation, which follows the domain because its IPs, DNS and warm-up do not change.

Every check that would refuse a move is listed at once: the 409 carries the first one’s code and refusals, an array of { code, message } for all of them.

Example: { "action": "move_workspace", "target_workspace_id": "6f1c…", "detach_campaigns": true }.

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. set_redirect points the domain’s website somewhere — see redirect_url — and release stops renewal and takes the domain off the account. move_workspace moves the domain, its mailboxes and their Unibox history to another workspace on the same plan — see target_workspace_id.verify_dnsrewrite_dnsrotate_dkimset_redirectreleasemove_workspace
target_workspace_id

string

Required for move_workspace, read for nothing else. The workspace the domain moves to. It must be on the same billing plan as the domain’s workspace, and this needs a key that can act on both: a plan-wide key, with the domain’s workspace chosen by X-Workspace-Id as usual.
detach_campaigns

boolean

Only read for move_workspace; defaults to false. Campaigns stay in their workspace, so a domain whose mailboxes are still in a campaign, or have scheduled replies waiting to go out, is refused. true takes the mailboxes out of every campaign and cancels those scheduled replies as part of the move.
redirect_url

string

Only read for set_redirect. Where visitors to the apex and www are sent, path and query preserved, answered with a 302. Must be https, must not point back at the domain. Pass null or an empty string to clear it, which puts the landing page back. This is deliberately not a record editor. The apex of a sending domain carries MX, SPF, DKIM and DMARC, and a hand-edited SPF authenticates nothing while looking exactly like one that works — so the only thing writable here is the two proxied records plus a Worker route, and the certificate is issued for you. Only available on dns_method: "nameservers". On manual and cloudflare_token the domain’s DNS belongs to someone else’s zone, where the token we hold cannot bind a Worker route, and the job fails with an explanation rather than writing anything.
202
The job was queued.
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.
404workspace_not_found
move_workspace to a workspace this key cannot act on, or that is not on the same plan.
409same_workspace
move_workspace to the workspace the domain is already in.
409domain_releasing
move_workspace on a domain being released.
409domain_moving
move_workspace on a domain that is already moving.
409domain_not_ready
move_workspace on a domain still being bought.
409prewarmed_domain
move_workspace on a pre-warmed pool domain. Its mailboxes are leased, not owned.
409leased_mailboxes
move_workspace on a domain with a leased mailbox.
409dedicated_lane
move_workspace on a domain in a dedicated IP lane. Take it out of the lane first.
409domain_recovery
move_workspace on a domain resting or restarting after a provider refused it.
409domain_throttled
move_workspace on a domain inside an account throttle.
409source_restricted
move_workspace from a workspace that is suspended, closed or throttled.
409target_restricted
move_workspace to a workspace that is suspended, past due, throttled or not allowed new mailboxes.
409in_campaigns
move_workspace without detach_campaigns while a mailbox is in a campaign.
409scheduled_replies
move_workspace without detach_campaigns while a mailbox has scheduled replies waiting.
Request
POST https://www.emailpal.io/api/public/v1/domains/{id}
Body
{ "action": "verify_dns" }
Response
{
  "job_id": "job_8kd12m",
  "status": "queued",
  "poll": "/api/public/v1/jobs/job_8kd12m"
}
POST/domains/{id}/cloudflare-tokenwrite

Let us write DNS in your own Cloudflare account

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.

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.
200
The token works. Setup is queued and the domain is now on this method.
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 informational: it turns true after 14 days of warming (immediate skips the wait). warming.campaign_blocker is a caution that says how far along the ramp is while it is false. Neither stops you using a mailbox: any available mailbox can be put in a campaign and send whenever you choose, however you have configured warming.

sending is the mailbox’s domain’s: a mailbox sends where its domain does, shared or on the account’s dedicated IP (see GET /dedicated-ips). It is per domain because splitting one domain across IPs splits its reputation.

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.
domain

string

Only mailboxes whose address is on this domain name, e.g. acme-mail.com. Exact and case-insensitive: acme-mail.com does not match mail.acme-mail.com. Matches the address itself, so it also finds connected Google mailboxes. Combines with domain_id and the other filters.
status

string

Only mailboxes in this state.provisioningwarmingactivepausedsuspendedfaileddeleted
leased

boolean

true for pre-warmed inboxes currently on the account, false for mailboxes the account created. Omit for both.
200
Always.
400invalid_status
status is not one of the listed values.
400invalid_domain
domain is not a domain name.
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
Response
{
  "data": [
    {
      "id": "mbx_2c81f0",
      "email": "ada.lovelace@outbound-atlas.com",
      "domain_id": "dom_91af22",
      "domain": "outbound-atlas.com",
      "sending": { "lane": "shared", "dedicated_ip_id": null, "state": "none" },
      "display_name": "Ada Lovelace",
      "first_name": "Ada",
      "last_name": "Lovelace",
      "status": "warming",
      "cold_daily_limit": 15,
      "daily_limit": 20,
      "warming": {
      "enabled": true,
      "state": "ramping",
      "profile": "standard",
      "day": 9,
      "campaign_ready": false,
      "campaign_blocker": "Day 9 of 14. Still warming: sending cold now may hurt deliverability."
    },
      "health_score": 86,
      "inbox_rate": 0.94,
      "smtp": { "host": "smtp.authorityking.io", "port": 587 },
      "imap": { "host": "imap.authorityking.io", "port": 993 },
      "created_at": "2026-06-30T08:15:00.000Z"
    }
  ],
  "has_more": true,
  "next_cursor": "2026-06-30T08:15:00.000Z"
}
POST/mailboxeswrite

Create 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.

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

integer0–100defaults to 15

Advisory cold volume per day. A recommendation, not a limit: it is the figure the credential export hands over for use in EmailPal’s sequencer or any other tool, which decides what it actually sends (up to 100 a day per mailbox). The dashboard no longer offers it as a setting. About fifteen is the useful range; above that inbox placement falls away sharply and takes the reply rate with it. Zero stops cold export for the mailbox.
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 is the typical ramp: it reaches the ceiling in about two weeks and suits a fresh domain. accelerated does it in about one week and is recommended for aged domains. immediate removes the ramp entirely and is only safe on a domain with real sending history behind it.standardacceleratedimmediate
202
The addresses were recorded and provisioning was queued.
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
Body
{
  "domain_id": "dom_91af22",
  "count": 5,
  "pattern": "first.last",
  "cold_daily_limit": 15,
  "daily_limit": 20,
  "warming_profile": "standard"
}
Response
{
  "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.

sending_pause reports the 5% rejection pause. While paused is true, new SMTP mail from the mailbox is refused with a permanent failure and mail that was still queued is cancelled, so refused_count and cancelled_count are how many emails to resend after the customer resumes sending in the dashboard. source says what paused it: bounce_hold (its own rejection rate, and the answer when the cause was not recorded) or domain_breaker (its domain is resting or needs replacing, so only the domain’s restart lifts it); reason is a stable code (bounce_rate, domain_refusals) and message the sentence the dashboard shows. All three are null while not paused. The mailbox.restriction_changed webhook carries the same words.

Does not include the password. Credentials come from the export endpoint, which needs admin and is audited.

200
The mailbox exists on this account.
404not_found
No such mailbox on this account.
Request
GET https://www.emailpal.io/api/public/v1/mailboxes/{id}
Response
{
  "id": "mbx_2c81f0",
  "email": "ada.lovelace@outbound-atlas.com",
  "domain_id": "dom_91af22",
  "domain": "outbound-atlas.com",
  "sending": { "lane": "shared", "dedicated_ip_id": null, "state": "none" },
  "display_name": "Ada Lovelace",
  "first_name": "Ada",
  "last_name": "Lovelace",
  "signature": null,
  "status": "warming",
  "cold_daily_limit": 15,
  "daily_limit": 20,
  "warming": {
    "enabled": true,
    "state": "ramping",
    "profile": "standard",
    "day": 9,
    "started_at": "2026-07-16T06:00:00.000Z",
    "campaign_ready": false,
    "campaign_ready_at": null,
    "campaign_blocker": "Day 9 of 14. Still warming: sending cold now may hurt deliverability."
  },
  "sending_pause": {
    "paused": false,
    "paused_at": null,
    "cancelled_count": 0,
    "refused_count": 0,
    "source": null,
    "reason": null,
    "message": null
  },
  "health_score": 86,
  "inbox_rate": 0.94,
  "spam_rate": 0.02,
  "smtp": { "host": "smtp.authorityking.io", "port": 587 },
  "imap": { "host": "imap.authorityking.io", "port": 993 },
  "last_send_at": "2026-07-25T14:20:00.000Z",
  "last_sync_at": "2026-07-25T14:51:00.000Z",
  "provisioned_at": "2026-06-30T08:17:41.000Z",
  "failure_reason": null,
  "created_at": "2026-06-30T08:15:00.000Z"
}
PATCH/mailboxes/{id}write

Change limits, warming, the From name, or pause it

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.

first_name and last_name change the From name and the exported CSV. They do not rename the address — sarah.chen@ stays sarah.chen@. Refused on role local parts (hi@, info@) because a different From name does not make receivers treat those as a person.

cold_daily_limit

integer0–100

Advisory cold volume per day, as recommended by the credential export. EmailPal’s sequencer reads it directly; any other tool you export to holds its own copy. Not a limit. Takes effect on the next export rather than immediately for such a tool.
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.
first_name

stringmax 40 characters

Persona first name. Updates display_name unless you also send that. Does not change the local part. Refused on role addresses.
last_name

stringmax 40 characters

Persona last name. Same rules as first_name.
display_name

string1–80 characters

The From name. Independent of first/last if you send it yourself.
signature

stringmax 2,000 characters

Appended to outbound mail.
200
Settings were applied. Nothing needed the mail server.
202
paused was sent, so a job was queued. Poll it.
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.
400missing_first_name
first_name was cleared and the mailbox would have no first name left.
409role_address
first_name or last_name was sent on a role address (hi@, info@, support@). A different From name does not change how receivers treat the local part.
500update_failed
The change could not be applied.
Request
PATCH https://www.emailpal.io/api/public/v1/mailboxes/{id}
Body
{
  "cold_daily_limit": 12,
  "warming_paused": true
}
Response
{
  "id": "mbx_2c81f0",
  "applied": ["cold_daily_limit", "warming_paused"],
  "job_id": null
}
DELETE/mailboxes/{id}write

Delete a mailbox

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.

202
Deletion was queued.
404not_found
No such mailbox on this account.
Request
DELETE https://www.emailpal.io/api/public/v1/mailboxes/{id}
Response
{
  "job_id": "job_9xq04b",
  "status": "queued",
  "poll": "/api/public/v1/jobs/job_9xq04b"
}
GET/mailboxes/exportadmin

Credentials as CSV

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. Connected Google mailboxes are never included, because you already hold their app passwords. The count is returned in the X-EmailPal-Exported header, anything skipped for unreadable credentials in X-EmailPal-Skipped, and the number of connected Google mailboxes left out in X-EmailPal-Excluded.

The file is UTF-8 with a BOM and CRLF line endings, because that is what the spreadsheet tools these feed into expect.

format

stringdefaults to "generic"

Column layout, one per sending tool — smartlead, instantly, woodpecker, salesforge, snov and the rest. Each matches that tool’s bulk-import template; generic carries everything including security modes.genericsmartleadinstantlyaicloseremailbisonleadenginemailleadmailshakemanyreachmasterinboxpipelimeplusvibereachinboxreply.iosaleshandysalesforgesendkitsmartreachsnovwarmywarmysenderwoodpecker
domain_id

string

Only mailboxes on this domain.
mailbox_ids

string

A comma-separated list of specific mailbox ids.
200
At least one mailbox had readable credentials.
400invalid_format
format is not one of the supported layouts.
404nothing_to_export
No mailbox matched, none had credentials yet, or every match is a connected Google mailbox (never exported).
500read_failed
The query failed.
500credentials_unreadable
Every matching row failed to decrypt.
Request
GET https://www.emailpal.io/api/public/v1/mailboxes/export
Response
email,display_name,first_name,last_name,smtp_username,smtp_password,smtp_host,smtp_port,smtp_security,imap_username,imap_password,imap_host,imap_port,imap_security,daily_limit
ada.lovelace@outbound-atlas.com,Ada Lovelace,Ada,Lovelace,ada.lovelace@outbound-atlas.com,x7Kp...,smtp.authorityking.io,587,STARTTLS,ada.lovelace@outbound-atlas.com,x7Kp...,imap.authorityking.io,993,SSL,15

Warming

Ramp progress, measured placement, and the switches that decide what warming costs.

GET/warmingread

Progress, placement and cost

The question this answers is how far along each mailbox is, so you can judge the risk of sending cold. campaign_ready is informational: it is true after 14 days of warming (immediate skips the wait). campaign_blocker is a caution, not a gate: it shows how far through the ramp a mailbox is while campaign_ready is false. Nothing refuses to use or send from a mailbox because of its warming state; you configure warming and decide when to send.

inbox_rate is the measured placement number, for display — it does not gate campaign_ready or anything else. 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.

mailbox_id

string

Just one mailbox.
days

integer1–30defaults to 7

Length of the measurement window.
200
Always. An account with no warming mailboxes gets zeroes and a note.
500read_failed
The query failed.
Request
GET https://www.emailpal.io/api/public/v1/warming
Response
{
  "summary": {
    "mailboxes": 96,
    "ramped": 41,
    "billable_mailboxes": 96,
    "estimated_monthly_cost_cents": 5760,
    "campaign_ready": 38,
    "average_health": 88,
    "average_inbox_rate": 0.9412
  },
  "mailboxes": [
    {
      "mailbox_id": "mbx_2c81f0",
      "email": "ada.lovelace@outbound-atlas.com",
      "status": "warming",
      "state": "ramping",
      "warming_profile": "standard",
      "day": 9,
      "started_at": "2026-07-16T06:00:00.000Z",
      "ramp_progress": 0.55,
      "campaign_ready": false,
      "campaign_ready_at": null,
      "campaign_blocker": "Day 9 of 14. Still warming: sending cold now may hurt deliverability.",
      "health_score": 86,
      "inbox_rate": 0.94,
      "spam_rate": 0.02,
      "daily_limit": 20,
      "window": {
        "days": 7,
        "sent": 132,
        "conversations_started": 58,
        "received": 128,
        "replied": 71,
        "landed_in_inbox": 121,
        "landed_in_spam": 7,
        "landed_nowhere": 0,
        "rescued_from_spam": 7
      },
      "daily": [
        { "day": "2026-07-25", "target": 22, "conversations_started": 9, "sent": 22, "inbox": 21, "spam": 1, "missing": 0 }
      ]
    }
  ]
}
POST/warmingwrite

Enable, disable, pause or resume in bulk

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.

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.
200
At least one mailbox changed.
409
Every mailbox was skipped. The body still lists why for each.
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
Body
{
  "action": "disable",
  "mailbox_ids": ["mbx_2c81f0", "mbx_2c81f1"]
}
Response
{
  "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. Leased pre-warmed inboxes are never counted."
  }
}

Dedicated IPs

An IP address that sends only your mail. It warms up on your own campaign sends, takes your domains on as its ceiling grows, and ends with every domain you chose living on it. Billing starts the moment you request one. Growth and Scale plans, or an account an operator has enabled; one IP per account for now.

GET/dedicated-ipsread

The account’s dedicated IP

A list so that more than one can come later without a new shape; today it holds at most one entry, and is empty when the account has not asked for one. See GET /dedicated-ips/{id} for every field.

Answers 404 dedicated_ip_unavailable while the feature is not switched on for the platform, unless the account already holds one, in which case it is returned so the charge is never invisible.

200
Always, including when the account has no dedicated IP.
404dedicated_ip_unavailable
Dedicated IPs are not available yet.
Request
GET https://www.emailpal.io/api/public/v1/dedicated-ips
Response
{
  "data": [
    { "id": "dip_4be1c9", "status": "ramping", "ip": "203.0.113.41", "…": "the object from GET /dedicated-ips/{id}" }
  ],
  "has_more": false,
  "next_cursor": null
}
POST/dedicated-ipswrite

Request a dedicated IP (two steps; the second charges)

Two calls, the same body. The first has no confirmation_token and charges nothing: it checks the plan, the card and whether an address can be reserved, and answers confirmation_required with the price and a token. The second sends the token back and charges the card, reserves the IP and queues the domains you named. A token is good for 15 minutes and only for the same domain_ids, auto_add and price it was issued for; anything else is confirmation_invalid and you ask again.

Two things in the quote are not obvious, and are the reason it is a quote. Billing starts at the second call, not at the first send: the IP is reserved and counted straight away, even if it then then waits for its warm-up to start (normally within minutes, in a ramp slot of its own; only if its block already has two dedicated IPs warming does it wait for one of them to finish, up to about two weeks). And for about 16 days the IP carries only part of your volume: it warms up on your own campaign sends (a daily ceiling that grows over send days, then clean days), and the rest of your mail keeps going out on established shared IPs, so nothing is delayed. If the warm-up fails it is still billed until released.

The price is in quote.monthly_cents (0 for a complimentary account). The first charge is prorated for the rest of the billing period; after that it is monthly. Only domains that already deliver are admitted to the IP; the others stay queued with what they wait for.

Send an Idempotency-Key header on the second call. It starts a recurring charge, and a retry after a timeout is otherwise a second attempt (which would be refused as already_has_lane, but only after the first had gone through).

An account with no card, or one the plan does not cover, is refused at the first call, so nobody is shown a price for something that fails at the end.

domain_ids

string[]max 100 items

Domains to put on the IP, in the order they should be admitted. Omit to choose later with PUT /dedicated-ips/{id}/domains. Bound into the token.
auto_add

stringdefaults to "ask"

What happens when another of your domains becomes established: ask notifies you, auto admits it, off does nothing. Bound into the token.askautooff
confirmation_token

string

From the quote. Sending it is the "yes, charge me".
200
No token: a quote and a confirmation_token. Nothing was charged.
201
With a token: the IP is reserved and billing has started.
402subscription_required
No active subscription or card for add-ons. Nothing was charged.
403not_entitled
The plan does not include dedicated IPs and the account has not been enabled. Carries upgradeRequired.
403account_suspended
Sending is suspended on the account.
404dedicated_ip_unavailable
Dedicated IPs are not available yet.
409account_closed
The account is closed.
409already_has_lane
The account already has a dedicated IP. One per account for now.
409no_ip_available
There is no free address to reserve right now. Nothing was charged; try again shortly.
409confirmation_invalid
The token is not this request’s quote (different domains, auto-add or price).
409confirmation_expired
The quote is more than 15 minutes old. Nothing was charged.
Request
POST https://www.emailpal.io/api/public/v1/dedicated-ips
Body
{
  "domain_ids": ["dom_91af22", "dom_7d03b8"],
  "auto_add": "ask",
  "confirmation_token": "dip1.1792000000000.9f2c…"
}
Response
{
  "status": "confirmation_required",
  "charged": false,
  "quote": {
    "monthly_cents": 2000,
    "currency": "usd",
    "comped": false,
    "billing_starts": "at_request",
    "ramp_days": 16,
    "will_wait_for_slot": true,
    "estimated_daily_volume": 780,
    "recommended_daily_volume": 600,
    "below_recommended_volume": false,
    "notes": ["Billing starts the moment you request the IP, even if it has to wait for a free ramp slot.", "…"]
  },
  "domain_ids": ["dom_91af22", "dom_7d03b8"],
  "auto_add": "ask",
  "confirmation_token": "dip1.1792000000000.9f2c…",
  "expires_at": "2026-10-14T15:17:11.000Z",
  "next": "Nothing has been charged. Show the quote…"
}
GET/dedicated-ips/{id}read

One dedicated IP, with its ramp and domains

The id is the IP’s id and stays the same if an operator replaces a failed address. ip and rdns are the address and its reverse-DNS name (null until one is reserved).

status: requested (reserved, waiting for a ramp slot), ramping, graduated (warm-up done, domains still joining), complete (every chosen domain is on it), failed (see failure_reason and failure_message) or released. ramp.phase is finer: waiting_for_slot, ramping, clean_days, ended or none. ramp.day is the day of the ceiling curve, ramp.ceiling today’s message ceiling while the send days run (null after), ramp.sent_today what the IP sent, and ramp.spilled_today what the lane’s admitted domains sent on shared IPs instead. The last is read from rollups the mail tier pushes periodically, so it lags by a few minutes; it is null when it cannot be told yet.

Each domain carries its own state: queued (waiting for what waiting_on says: established, spf, dns for a manual-DNS domain whose record you must add, or ceiling) or admitted (sending dedicated-first on the IP).

status is one of requested, ramping, graduated, complete, failed, released; ramp.phase one of waiting_for_slot, ramping, clean_days, ended, none.

A failed ramp is still billed until you release the IP (DELETE). If a second ramp fails an operator replaces the address at no extra charge.

200
The IP is this account’s.
404not_found
No such dedicated IP on this account.
Request
GET https://www.emailpal.io/api/public/v1/dedicated-ips/{id}
Response
{
  "id": "dip_4be1c9",
  "status": "ramping",
  "ip": "203.0.113.41",
  "rdns": "mta41.example-sender.net",
  "block": "203.0.113.0/24",
  "ip_state": "burn_in",
  "auto_add": "ask",
  "failure_reason": null,
  "failure_message": null,
  "ramp": {
    "phase": "ramping",
    "attempts": 1,
    "day": 4,
    "total_days": 14,
    "send_days_done": 3,
    "ceiling": 75,
    "sent_today": 61,
    "spilled_today": 240,
    "window_complete_at": null,
    "graduates_at": null,
    "ended_at": null
  },
  "billing": {
    "monthly_cents": 2000,
    "starts": "at_request",
    "started_at": "2026-10-14T15:02:11.000Z",
    "note": "Billing starts the moment you request the IP, even if it has to wait for a free ramp slot."
  },
  "requested_at": "2026-10-14T15:02:11.000Z",
  "ip_reserved_at": "2026-10-14T15:02:11.000Z",
  "ramp_started_at": "2026-10-16T08:00:00.000Z",
  "graduated_at": null,
  "completed_at": null,
  "failed_at": null,
  "domains": [
    {
      "domain_id": "dom_91af22",
      "domain": "outbound-atlas.com",
      "state": "admitted",
      "priority": 0,
      "waiting_on": null,
      "added_by": "customer",
      "admitted_at": "2026-10-16T08:00:00.000Z",
      "dns_method": "nameservers",
      "admissible": true,
      "blockers": []
    },
    {
      "domain_id": "dom_7d03b8",
      "domain": "tryatlas.io",
      "state": "queued",
      "priority": 1,
      "waiting_on": "ceiling",
      "added_by": "customer",
      "admitted_at": null,
      "dns_method": "manual",
      "admissible": true,
      "blockers": []
    }
  ]
}
PATCH/dedicated-ips/{id}write

Change what happens to new domains

Sets auto_add: what the IP does when another of your domains becomes established. ask raises a notification and a banner, auto admits it, off leaves it alone. Costs nothing.

auto_add

stringrequired

The new mode.askautooff
200
The setting was changed (or already was). The updated IP is returned.
404not_found
No such dedicated IP on this account.
404dedicated_ip_unavailable
Dedicated IPs are not available yet.
Request
PATCH https://www.emailpal.io/api/public/v1/dedicated-ips/{id}
Body
{
  "auto_add": "auto"
}
DELETE/dedicated-ips/{id}write

Release the dedicated IP

Gives the IP up. Its domains go back to shared IPs on the next sync, about a minute, and billing for it ends from your next invoice. Asking for another later starts a new warm-up. This works even if the feature has been switched off platform-wide, so that a charge can never be trapped.

Not undoable: the warm-up you already paid for goes with it.

200
The IP was released.
404not_found
No such dedicated IP on this account.
409cannot_release
The IP is not in a state that can be released.
Request
DELETE https://www.emailpal.io/api/public/v1/dedicated-ips/{id}
Response
{
  "id": "dip_4be1c9",
  "status": "released",
  "ip": "203.0.113.41",
  "note": "The IP is released and its domains return to shared IPs within about a minute. Billing for it ends from your next invoice."
}
PUT/dedicated-ips/{id}/domainswrite

Choose which domains live on the IP

Takes the whole list in the order the domains should be admitted and makes the IP match it: domains missing from the list come out (admitted ones go back to shared IPs on the next sync), new ones are queued. A queued domain joins the IP by itself once it is established, its SPF covers the IP’s block and there is room under today’s ceiling, so one that is not ready yet is accepted and shows what it is waiting_on. A domain that cannot join (not yours, cannot send, already in a lane) is returned in skipped with the reason. An empty list removes every domain. Costs nothing.

domain_ids

string[]requiredmax 100 items

Every domain that should be on the IP, in admission order.
reason

stringmax 200 characters

Kept with each removal in the IP’s history.
200
The IP now matches the list, apart from anything in skipped.
404not_found
No such dedicated IP on this account.
404dedicated_ip_unavailable
Dedicated IPs are not available yet.
Request
PUT https://www.emailpal.io/api/public/v1/dedicated-ips/{id}/domains
Body
{
  "domain_ids": ["dom_91af22", "dom_7d03b8", "dom_c5e210"]
}
Response
{
  "dedicated_ip": { "id": "dip_4be1c9", "status": "ramping", "…": "…" },
  "added": ["dom_c5e210"],
  "removed": [],
  "reordered": false,
  "skipped": []
}

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. Bounce notices from the mail server (mailer-daemon) are never included — they are not replies.

Bodies are not included. Fetching one means talking to the mail server, so it is a separate call.

Send X-Workspace-Id: all to read every workspace the key can reach as one list. Each item carries its workspace_id, so you know which workspace to name when you act on it.

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.
200
Always.
500read_failed
The query failed.
Request
GET https://www.emailpal.io/api/public/v1/inbox
Response
{
  "data": [
    {
      "id": "msg_6b20de",
      "workspace_id": "3f2a9c1e-7b4d-4e8a-9c0f-1d2e3f4a5b6c",
      "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.

200
status is ready and body is populated.
202
status is fetching and body is null. Poll this URL again.
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.

seen

boolean

Whether the message is marked read.
flagged

boolean

Whether the message is flagged.
200
Applied.
400invalid_request
Neither seen nor flagged was sent.
404not_found
No such message on this account.
Request
PATCH https://www.emailpal.io/api/public/v1/inbox/{id}
Body
{ "seen": true }
Response
{ "id": "msg_6b20de", "seen": true }
DELETE/inbox/{id}write

Delete a message

Queued, because it has to reach the mail server. The message disappears from listings immediately.

202
Deletion was queued.
404not_found
No such message on this account.
Request
DELETE https://www.emailpal.io/api/public/v1/inbox/{id}
Response
{ "id": "msg_6b20de", "job_id": "job_1t88za" }
POST/inbox/sendwrite

Send or reply from a mailbox

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 another tool 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}.

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.
202
The message was recorded and queued for sending.
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"
}
Response
{
  "id": "out_3v90kc",
  "from": "ada.lovelace@outbound-atlas.com",
  "job_id": "job_2p61hd",
  "poll": "/api/public/v1/jobs/job_2p61hd"
}
GET/inbox/send/{id}read

Delivery status of a send

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.

reason is null unless the message never reached the recipient and nothing was learned about the address, in which case it is not_reached (the receiving server refused or delayed our sending network, or the message waited about 24 hours and expired) or consumer_microsoft (the same, for a personal Microsoft mailbox: outlook.com, hotmail.com, live.com, msn.com, which block most cold senders; business Microsoft 365 addresses are not affected). Campaigns never send to personal Microsoft mailboxes at all (their leads are skipped with skip_reason personal_microsoft), so this reason only comes from a direct send or a reply. Neither is a bounce of your list: delivery_status is bounced with bounce_type block or soft, the address is not suppressed, and nothing counts against your mailbox, account or campaign bounce rate. Treat an unknown reason as a string.

This covers mail sent through this API. Mail another tool sends over SMTP with exported credentials has no record here, because it never passed through us. Campaign sends from EmailPal’s own sequencer are tracked per campaign and appear in /campaigns/{id}/analytics and /replies.

200
Always, for a message on this account.
404not_found
No such message on this account.
Request
GET https://www.emailpal.io/api/public/v1/inbox/send/{id}
Response
{
  "id": "out_3v90kc",
  "mailbox_id": "mbx_2c81f0",
  "to": ["priya@acme.example"],
  "subject": "Re: quick question about your rollout",
  "status": "sent",
  "delivery_status": "bounced",
  "bounce_type": "hard",
  "reason": null,
  "smtp_code": 550,
  "smtp_diagnostic": "550 5.1.1 <priya@acme.example>: Recipient address rejected: User unknown",
  "delivery_events": 1,
  "rfc_message_id": "<a3f1c8e2-...@outbound-atlas.com>",
  "sent_at": "2026-07-25T14:02:11.000Z",
  "delivery_at": "2026-07-25T14:02:14.000Z"
}
POST/inbox/refreshwrite

Force a sync now

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.

mailbox_id

string

Sync one mailbox instead of all of them.
202
Sync jobs were queued, or were already running.
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."
}

Campaigns

Build, launch and measure email sequences. Creating and editing are safe to script: a campaign is always a draft until `launch`, and launching runs the same pre-flight as the dashboard button, so the API cannot start a campaign the dashboard would have refused.

GET/campaignsread

List campaigns

Newest first, deleted campaigns excluded. Counters are the running totals the dashboard shows. For a time-bounded view of one campaign use /campaigns/{id}/analytics.

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 campaigns in this state.draftactivepausedcompletederror
200
Always.
404not_enabled
Campaigns, leads and the CRM are rolled out per account. An account that does not have them gets 404 not_enabled from every endpoint in this section, the same as the pages in the dashboard.
Request
GET https://www.emailpal.io/api/public/v1/campaigns
Response
{
  "data": [
    {
      "id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11",
      "name": "Q3 outbound - CTOs",
      "status": "active",
      "status_reason": null,
      "tags": ["q3"],
      "launched_at": "2026-10-01T14:00:00.000Z",
      "paused_at": null,
      "issue_started_at": null,
      "leads_total": 840,
      "leads_completed": 120,
      "sent_count": 1312,
      "reply_count": 41,
      "bounce_count": 9,
      "click_count": 0,
      "created_at": "2026-09-28T10:12:00.000Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
POST/campaignswrite

Create a campaign

Creates a draft. Nothing is sent by creating one. You can send the whole sequence in this request (steps, each with its variants) and choose the sending mailboxes, or build it up with the steps endpoints afterwards. A new campaign gets a weekday office-hours schedule in timezone.

Supports Idempotency-Key: a retried create returns the original campaign rather than a second one.

Body HTML is sanitised on the way in, the same as the editor’s autosave. Read body_html back to see exactly what will be sent.

name

string1–120 characters

Defaults to "Untitled campaign".
timezone

stringmax 64 characters

IANA zone for the default schedule, such as America/New_York.
tags

string[]max 20 items

Labels for filtering.
mailbox_ids

string[]max 500 items

Mailboxes to send from. They must belong to your account.
steps

object[]max 10 items

The sequence, in order. Each step is { delay_value?, delay_unit?, variants? } with variants as { subject?, body_html? }, the first being variant A. delay_unit is minutes, hours or days.
201
The draft was created. The body is the full campaign.
400invalid_request
The body failed validation, or the sequence is over the step or variant limit.
400mailbox_unavailable
A mailbox id is not a sending mailbox on this account.
404not_enabled
Campaigns, leads and the CRM are rolled out per account. An account that does not have them gets 404 not_enabled from every endpoint in this section, the same as the pages in the dashboard.
Request
POST https://www.emailpal.io/api/public/v1/campaigns
Body
{
  "name": "Q3 outbound - CTOs",
  "timezone": "America/New_York",
  "mailbox_ids": ["8c1d7e2a-0f33-4d0c-a1f9-5f6d2b8e4a90"],
  "steps": [
    {
      "delay_value": 0,
      "variants": [{ "subject": "Quick question, {{first_name}}", "body_html": "<p>Hi {{first_name}},</p><p>...</p>" }]
    },
    { "delay_value": 3, "delay_unit": "days", "variants": [{ "subject": "Re: quick question", "body_html": "<p>Bumping this.</p>" }] }
  ]
}
Response
{
  "id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11",
  "name": "Q3 outbound - CTOs",
  "status": "draft",
  "status_reason": null,
  "tags": ["q3"],
  "launched_at": null,
  "paused_at": null,
  "issue_started_at": null,
  "leads_total": 0,
  "leads_completed": 0,
  "sent_count": 0,
  "settings": { "daily_limit": 200, "stop_on_reply": true, "track_clicks": false, "esp_routing": [] },
  "schedules": [
    {
      "id": "0f2b8d6e-1b7f-4a5c-8f0a-6c0a9a3b2d10",
      "name": "Weekdays",
      "mode": "office",
      "timezone": "America/New_York",
      "windows": [{ "days": [1, 2, 3, 4, 5], "start": "09:00", "end": "17:00" }]
    }
  ],
  "mailbox_ids": ["8c1d7e2a-0f33-4d0c-a1f9-5f6d2b8e4a90"],
  "steps": [
    {
      "id": "c6a1f0d2-77a4-4a21-bb1e-3f7d9e0a4c55",
      "position": 1,
      "delay_value": 0,
      "delay_unit": "days",
      "locked": false,
      "variants": [
        {
          "id": "e9b04c37-5d1a-4f6b-8c23-0a7e1d5b9f42",
          "label": "A",
          "subject": "Quick question, {{first_name}}",
          "body_html": "<p>Hi {{first_name}},</p><p>...</p>",
          "enabled": true,
          "is_winner": false
        }
      ]
    }
  ]
}
GET/campaigns/{id}read

One campaign, in full

The header, every setting, the schedules, the selected mailbox ids and the whole sequence in one response.

200
The campaign exists on this account.
404not_found
No such campaign on this account, or it was deleted.
Request
GET https://www.emailpal.io/api/public/v1/campaigns/{id}
Response
{
  "id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11",
  "name": "Q3 outbound - CTOs",
  "status": "draft",
  "status_reason": null,
  "tags": ["q3"],
  "launched_at": null,
  "paused_at": null,
  "issue_started_at": null,
  "leads_total": 0,
  "leads_completed": 0,
  "sent_count": 0,
  "settings": { "daily_limit": 200, "stop_on_reply": true, "track_clicks": false, "esp_routing": [] },
  "schedules": [
    {
      "id": "0f2b8d6e-1b7f-4a5c-8f0a-6c0a9a3b2d10",
      "name": "Weekdays",
      "mode": "office",
      "timezone": "America/New_York",
      "windows": [{ "days": [1, 2, 3, 4, 5], "start": "09:00", "end": "17:00" }]
    }
  ],
  "mailbox_ids": ["8c1d7e2a-0f33-4d0c-a1f9-5f6d2b8e4a90"],
  "steps": [
    {
      "id": "c6a1f0d2-77a4-4a21-bb1e-3f7d9e0a4c55",
      "position": 1,
      "delay_value": 0,
      "delay_unit": "days",
      "locked": false,
      "variants": [
        {
          "id": "e9b04c37-5d1a-4f6b-8c23-0a7e1d5b9f42",
          "label": "A",
          "subject": "Quick question, {{first_name}}",
          "body_html": "<p>Hi {{first_name}},</p><p>...</p>",
          "enabled": true,
          "is_winner": false
        }
      ]
    }
  ]
}
PATCH/campaigns/{id}write

Change a campaign’s settings, mailboxes or schedules

Partial: only the fields you send change. mailbox_ids and schedules replace those sets wholesale; send the full list you want. Allowed on a running campaign, as it is in the dashboard: the engine reads settings on its next cycle, so fixing a daily limit does not need a pause. Returns the campaign as it is now.

name

string1–120 characters

Shown in the dashboard and in reports.
tags

string[]max 20 items

Up to 20 labels of up to 32 characters, for filtering.
mailbox_tags

string[]max 20 items

Send only from mailboxes carrying one of these tags, in addition to the selected mailboxes.
owner_id

string

The team member who owns the campaign. null clears it.
daily_limit

integer1–10,000

Most emails the campaign sends per day across all its mailboxes. Each mailbox’s own cold limit still applies underneath, so this is a ceiling and not a promise.
daily_limit_variance

integer0–3,000defaults to 0

Varies the day’s volume by up to this many emails either side of daily_limit, so the campaign does not send the same number every day. Each day draws its own target (25 ± 6 sends 19 to 31, 25 on average); a quiet day is not made up later. At most 30 % of daily_limit, rounded down (400 invalid_daily_limit_variance otherwise). Lowering daily_limit on its own lowers a variance that would no longer fit. 0 is a fixed daily limit.
send_pattern

stringdefaults to "even"

even spreads the day’s emails evenly across the sending window. natural follows a workday in the campaign’s time zone: busier mid-morning and mid-afternoon, quieter over lunch and late in the day, a little different each day. Either way the day’s emails go out before the window closes and the gap settings still apply.evennatural
max_new_leads_per_day

integer0–10,000

Cap on leads receiving their first email each day. null for no cap.
priority_weight

number

How this campaign shares a mailbox with others: half, normal or double.0.512
prioritize_new_leads

boolean

Send first emails before follow-ups when the day’s volume runs short.
min_gap_minutes

integer0–1,440

Least time between two sends from one mailbox.
random_gap_minutes

integer0–1,440

Up to this much random time added to the gap, so sends do not land on a metronome.
stop_on_reply

boolean

End a lead’s sequence when they reply.
stop_on_auto_reply

boolean

Also end it on an out-of-office or other automatic reply.
stop_company_on_reply

boolean

When one person replies, stop for everyone at their company.
track_clicks

boolean

Wrap links so clicks are counted. Off is better for deliverability.
text_only

boolean

Send plain text only.
first_email_text_only

boolean

Plain text for the first email, the rest as written.
provider_matching

boolean

Prefer sending from a mailbox on the same provider as the recipient.
esp_routing

object[]max 8 items

Rules of { recipient_esp, mailbox_tag }: leads hosted by google, microsoft, yahoo or other are sent from mailboxes carrying that tag.
unsubscribe_header

boolean

Add the one-click List-Unsubscribe header.
allow_risky

boolean

Also send to catch-all and risky addresses. Does not include unknown ones (see allow_unknown).
allow_unknown

boolean

Also send to addresses whose verification came back unknown: the receiving server would not confirm them, and most of them turn out to be bad. Off by default, so those leads are skipped. Turning it on (from false to true) needs acknowledge_unknown_risk: true (400 unknown_risk_ack_required without it), records who acknowledged and when, keeps bounce_protect on, and puts leads already skipped as unknown back in the queue. Not copied when a campaign is duplicated.
acknowledge_unknown_risk

boolean

Not a setting and never stored: send true with allow_unknown: true to confirm you accept the risk. The campaign pauses automatically only if 5% of its emails hard bounce (after at least 200 sends), and only while bounce_protect is on.
bounce_protect

boolean

Pause the campaign automatically at 5% hard bounces, after at least 200 sends. Cannot be turned off while allow_unknown is on (400 bounce_protection_required).
limit_per_company

integer1–1,000

Most leads contacted at one company. null for no cap.
cc

string[]max 10 items

Addresses copied on every send.
bcc

string[]max 10 items

Addresses blind-copied on every send.
start_date

string

Do not send before this date (YYYY-MM-DD). null clears it.
end_date

string

Stop sending after this date (YYYY-MM-DD). null clears it.
mailbox_ids

string[]max 500 items

Replaces the mailboxes the campaign sends from.
schedules

object[]1–5 items

Replaces the campaign’s sending schedules. Each is { id?, name, mode, timezone, windows }, where mode is any, office or custom and windows is a list of { days, start, end }: ISO weekdays (1 is Monday, 7 is Sunday) and HH:MM times in the schedule’s own timezone. Leave id off for a new schedule.
200
Applied. The body is the full campaign.
400invalid_request
A value failed validation, the stop date is before the start date, or a schedule is not valid.
400invalid_daily_limit_variance
daily_limit_variance is more than 30 % of the daily limit (rounded down). Nothing is changed.
400mailbox_unavailable
A mailbox id is not a sending mailbox on this account.
400unknown_risk_ack_required
allow_unknown was changed from false to true without acknowledge_unknown_risk: true. Nothing is changed.
400bounce_protection_required
bounce_protect: false was sent while allow_unknown is on, or in the same request that turns it on. Nothing is changed.
404not_found
No such campaign on this account.
Request
PATCH https://www.emailpal.io/api/public/v1/campaigns/{id}
Body
{
  "daily_limit": 150,
  "stop_on_auto_reply": true,
  "end_date": "2026-12-31"
}
Response
{
  "id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11",
  "name": "Q3 outbound - CTOs",
  "status": "draft",
  "status_reason": null,
  "tags": ["q3"],
  "launched_at": null,
  "paused_at": null,
  "issue_started_at": null,
  "leads_total": 0,
  "leads_completed": 0,
  "sent_count": 0,
  "settings": { "daily_limit": 200, "stop_on_reply": true, "track_clicks": false, "esp_routing": [] },
  "schedules": [
    {
      "id": "0f2b8d6e-1b7f-4a5c-8f0a-6c0a9a3b2d10",
      "name": "Weekdays",
      "mode": "office",
      "timezone": "America/New_York",
      "windows": [{ "days": [1, 2, 3, 4, 5], "start": "09:00", "end": "17:00" }]
    }
  ],
  "mailbox_ids": ["8c1d7e2a-0f33-4d0c-a1f9-5f6d2b8e4a90"],
  "steps": [
    {
      "id": "c6a1f0d2-77a4-4a21-bb1e-3f7d9e0a4c55",
      "position": 1,
      "delay_value": 0,
      "delay_unit": "days",
      "locked": false,
      "variants": [
        {
          "id": "e9b04c37-5d1a-4f6b-8c23-0a7e1d5b9f42",
          "label": "A",
          "subject": "Quick question, {{first_name}}",
          "body_html": "<p>Hi {{first_name}},</p><p>...</p>",
          "enabled": true,
          "is_winner": false
        }
      ]
    }
  ]
}
DELETE/campaigns/{id}write

Delete a campaign

A soft delete. The campaign stops sending and leaves every list, but its history stays for analytics and for the opportunities that reference it. Its leads are released so they can join another campaign.

200
The campaign was deleted.
404not_found
No such campaign on this account.
Request
DELETE https://www.emailpal.io/api/public/v1/campaigns/{id}
Response
{ "id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11", "deleted": true }
POST/campaigns/{id}/duplicatewrite

Duplicate a campaign

Copies settings, schedules, mailboxes and the sequence into a new draft. Leads are not copied: a contact can be in only one active sequence, so copying them would put the same people in two campaigns. Add leads to the copy with POST /campaigns/{id}/leads.

201
The copy was created. The body is the full campaign.
404not_found
No such campaign on this account.
Request
POST https://www.emailpal.io/api/public/v1/campaigns/{id}/duplicate
Response
{
  "id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11",
  "name": "Q3 outbound - CTOs",
  "status": "draft",
  "status_reason": null,
  "tags": ["q3"],
  "launched_at": null,
  "paused_at": null,
  "issue_started_at": null,
  "leads_total": 0,
  "leads_completed": 0,
  "sent_count": 0,
  "settings": { "daily_limit": 200, "stop_on_reply": true, "track_clicks": false, "esp_routing": [] },
  "schedules": [
    {
      "id": "0f2b8d6e-1b7f-4a5c-8f0a-6c0a9a3b2d10",
      "name": "Weekdays",
      "mode": "office",
      "timezone": "America/New_York",
      "windows": [{ "days": [1, 2, 3, 4, 5], "start": "09:00", "end": "17:00" }]
    }
  ],
  "mailbox_ids": ["8c1d7e2a-0f33-4d0c-a1f9-5f6d2b8e4a90"],
  "steps": [
    {
      "id": "c6a1f0d2-77a4-4a21-bb1e-3f7d9e0a4c55",
      "position": 1,
      "delay_value": 0,
      "delay_unit": "days",
      "locked": false,
      "variants": [
        {
          "id": "e9b04c37-5d1a-4f6b-8c23-0a7e1d5b9f42",
          "label": "A",
          "subject": "Quick question, {{first_name}}",
          "body_html": "<p>Hi {{first_name}},</p><p>...</p>",
          "enabled": true,
          "is_winner": false
        }
      ]
    }
  ]
}
GET/campaigns/{id}/preflightread

Check whether a campaign is ready to launch

The dashboard’s "Ready to launch" checklist and sending forecast, without launching. can_launch is the flag POST /campaigns/{id}/launch enforces. Items have a severity of blocker (must be fixed) or a warning (worth reading), and each says what is wrong and why it matters.

verification says how many enrolled leads were never verified (unverified), whether they are being held back (hold_unverified), any running verification request, and a quote for verifying them: estimated_days at your daily allowance, available_today, an upgrade note when it takes more than a day, and the billing_url. When unverified is above zero, launch needs a verification choice.

200
The campaign exists on this account.
404not_found
No such campaign on this account.
Request
GET https://www.emailpal.io/api/public/v1/campaigns/{id}/preflight
Response
{
  "can_launch": false,
  "blocker_count": 1,
  "warning_count": 0,
  "items": [
    {
      "key": "mailboxes",
      "severity": "blocker",
      "title": "No sending mailbox",
      "detail": "Choose at least one mailbox to send from."
    }
  ],
  "forecast": {},
  "health": {},
  "preflight_ran": true,
  "verification": {
    "unverified": 1200,
    "hold_unverified": false,
    "existing_request": null,
    "quote": {
      "pending": 1200,
      "cap": 500,
      "used_today": 0,
      "available_today": 500,
      "estimated_days": 3,
      "multi_day": true,
      "entitled": true,
      "note": "This will take about 3 days at your 500/day allowance. Increase your daily verifications in Billing.",
      "billing_url": "https://app.example.com/billing"
    }
  }
}
POST/campaigns/{id}/launchwrite

Launch a draft campaign

Starts a draft. Runs the full pre-flight first: the structural checks (a step with copy, a schedule, leads, mailboxes), the deliverability forecast, and the engine’s abuse checks. If anything blocks, nothing starts and the response is 409 launch_blocked with the blocking items. There is no flag to skip it.

If the campaign has leads that were never verified you must choose what happens to them by sending verification: "verify" or "skip". Without it the response is 409 verification_choice_required and nothing starts; read the quote in the error, then call again with your choice. This applies to every caller, the same as the dashboard’s Launch dialog, so a script that launched campaigns with unverified leads must now pass it. With verify the campaign starts, those leads are held back and a verification request checks them within your daily allowance; the response carries the request and estimated_days. If that request cannot be started the campaign is not launched.

Launching enqueues nothing; the engine picks active campaigns up on its next cycle, so mail goes out within minutes, inside the campaign’s schedule and limits. Supports Idempotency-Key.

This is the one call in the section that puts mail on the wire. A key that only creates and edits cannot cause that by accident; give a key write scope only if you mean it to launch.

verification

string

What to do about leads that were never verified. verify launches, holds those leads back and starts a verification request for the campaign: they are checked within your daily verification allowance (possibly over several days, see estimated_days) and each one is sent to as soon as it is verified. skip launches and sends to them unverified. Required when the campaign has any unverified leads, otherwise optional.verifyskip
200
The campaign is now active.
409launch_blocked
The pre-flight found something that has to be fixed first. Carries items, the blocking entries of the checklist.
409verification_choice_required
The campaign has leads that were never verified and the body did not say what to do about them. Nothing starts. Carries verification with unverified (how many), choices (["verify", "skip"]) and quote (pending, cap, used_today, available_today, estimated_days, multi_day, note, billing_url). Send the request again with verification set.
402verification_capacity_required
verification: "verify" was sent but the account has no daily verification allowance (subscription_required when there is no payment method). Nothing starts; carries billing_url.
409invalid_state
Only a draft can be launched. Use resume for a paused or errored campaign.
404not_found
No such campaign on this account.
Request
POST https://www.emailpal.io/api/public/v1/campaigns/{id}/launch
Body
{ "verification": "verify" }
Response
{
  "id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11",
  "status": "active",
  "verification": {
    "choice": "verify",
    "hold_unverified": true,
    "request": { "id": "3b1f6c1e-0d3a-4b43-9d0e-0f6f3b8f3d10", "status": "active", "total_count": 1200, "verified_count": 0 },
    "estimated_days": 3,
    "note": "This will take about 3 days at your 500/day allowance. Increase your daily verifications in Billing."
  }
}
POST/campaigns/{id}/pausewrite

Pause an active campaign

Stops sending. Leads keep their place in the sequence and nothing is lost; the engine simply skips a paused campaign. Fires campaign.paused with reason: "manual".

200
The campaign is now paused.
409invalid_state
Only an active campaign can be paused.
404not_found
No such campaign on this account.
Request
POST https://www.emailpal.io/api/public/v1/campaigns/{id}/pause
Response
{ "id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11", "status": "paused" }
POST/campaigns/{id}/resumewrite

Resume a paused campaign

Starts a paused (or errored) campaign again. Resuming is a launch as far as safety is concerned, so it runs the same pre-flight and can answer 409 launch_blocked. A campaign the platform paused for a bounce spike will be blocked again until what caused it is fixed.

Resuming also resumes the campaign’s verification request if it was paused with the campaign. No verification choice is required; verification: "skip" stops holding unverified leads and cancels the request, "verify" starts one.

verification

string

Optional on resume. skip sends to unverified leads now and cancels the campaign’s verification request; verify holds them and starts one.verifyskip
200
The campaign is now active.
409launch_blocked
The pre-flight found something that has to be fixed first. Carries items, the blocking entries of the checklist.
409invalid_state
Only a paused or errored campaign can be resumed.
404not_found
No such campaign on this account.
Request
POST https://www.emailpal.io/api/public/v1/campaigns/{id}/resume
Response
{ "id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11", "status": "active" }
GET/campaigns/{id}/analyticsread

Campaign analytics

Totals, a daily series, a per-step and per-variant breakdown, opportunities and conversions, and the recent activity and bounces, for the last days days.

days

integerdefaults to 28

The window, in days.7142890
200
The campaign exists on this account.
400invalid_query
days is not one of the allowed windows.
404not_found
No such campaign on this account.
Request
GET https://www.emailpal.io/api/public/v1/campaigns/{id}/analytics
Response
{
  "days": 28,
  "totals": { "started": 840, "sent": 1312, "clicks": 0, "replies": 41, "auto_replies": 6, "bounces_hard": 7, "bounces_soft": 2, "unsubs": 3 },
  "opportunities": { "count": 5, "value_cents": 2500000 },
  "conversions": { "count": 1, "value_cents": 500000 },
  "trend": [{ "label": "Oct 1", "sent": 60, "clicks": 0, "replies": 2 }],
  "steps": [{ "step": 1, "variant": "A", "sent": 840, "clicks": 0, "replies": 30, "bounces": 7, "unsubs": 2 }],
  "activity": [],
  "bounces": [],
  "has_data": true
}
GET/campaigns/{id}/leadsread

List a campaign’s leads

Each lead’s place in this campaign’s sequence, newest first. The id is the lead’s enrolment (what /leads/actions takes); contact_id is the lead in your CRM (what /leads/{id} takes).

skip_reason says why a lead was skipped (status skipped), and is null otherwise. personal_microsoft: campaigns never send to personal Microsoft mailboxes (outlook, hotmail, live and msn domains, including regional ones such as hotmail.co.uk), on any account and any step. Such a lead can be added to a campaign, but it is skipped when its turn comes and is never emailed; business Microsoft 365 addresses are not affected. Other values exist (for example company_limit), so treat an unknown skip_reason as a string.

stop_reason says why a lead is paused or stopped, and is null otherwise. personal_microsoft: the same rule, for a lead that had already been emailed; its status is stopped and no further step is sent. Two other values mean the email was not reached: the lead is paused one step back, nothing counts as a bounce, and resuming it sends the missed step again. not_reached: the receiving server refused or delayed our sending network, or the email waited about 24 hours and expired. consumer_microsoft: legacy, the same as not_reached for a personal Microsoft mailbox, seen on leads from before campaigns stopped sending to them. Other values exist (for example company_replied), so treat an unknown stop_reason as a string.

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 leads in this state of the sequence.pendingactivepausedcompletedrepliedbouncedunsubscribedskippederroredexpiredstopped
verification

string

Only leads whose address has this verification result. invalid is what blocks a launch (invalid, undeliverable or disposable), risky is catch-all or risky, unknown is a check the receiving server would not confirm (skipped by campaigns unless they allow unknown), unverified is never checked (it no longer includes unknown), valid is deliverable. Each lead also carries its raw verification_status.invalidriskyunknownunverifiedvalid
q

stringmax 200 characters

Match against email and name.
200
The campaign exists on this account.
404not_found
No such campaign on this account.
Request
GET https://www.emailpal.io/api/public/v1/campaigns/{id}/leads
Response
{
  "data": [
    {
      "id": "b3a1d2c4-7e58-4a09-9f1b-2c6d0e8a1f33",
      "contact_id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44",
      "email": "priya@acme.example",
      "name": "Priya Raman",
      "company": "Acme",
      "status": "active",
      "current_step": 2,
      "last_step_sent": 1,
      "next_send_at": "2026-10-08T14:20:00.000Z",
      "last_sent_at": "2026-10-05T14:20:00.000Z",
      "mailbox_email": "ada.lovelace@outbound-atlas.com",
      "skip_reason": null,
      "stop_reason": null,
      "verification_status": "valid"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
POST/campaigns/{id}/leadswrite

Add leads to a campaign

Either every member of a lead list (list_id) or leads by value (leads, up to 1,000 per request). Exactly one of the two. Adding someone sends nothing; a lead is reached when the campaign is running and its turn comes.

Anyone blocklisted, unsubscribed, previously bounced, failed by verification, or already in the campaign is skipped and counted under skipped rather than failing the request, so re-sending a batch is safe. Personal Microsoft addresses (outlook, hotmail, live, msn) are added but never emailed: each shows as skipped with skip_reason personal_microsoft once its turn comes. Leads by value are created if the address is new and left untouched if it already exists, so a retry never overwrites what someone has since edited.

Supports Idempotency-Key.

list_id

string

A lead list from GET /lead-lists. Lists over 20,000 people must be imported from a CSV.
leads

object[]1–1,000 items

Each is { email, first_name?, last_name?, job_title?, company_name?, phone?, website?, location?, linkedin_url?, personalization?, custom? }. custom is a map of your own field keys to strings, usable as {{key}} in copy.
200
Processed. added can be 0 if everyone was skipped.
400invalid_request
Neither or both of list_id and leads were sent, or a lead failed validation.
400no_valid_leads
None of the leads had a usable email address.
400list_too_large
The list has more than 20,000 people.
404not_found
No such campaign, or no such list, on this account.
Request
POST https://www.emailpal.io/api/public/v1/campaigns/{id}/leads
Body
{
  "leads": [
    { "email": "priya@acme.example", "first_name": "Priya", "company_name": "Acme", "custom": { "team_size": "40" } }
  ]
}
Response
{
  "added": 1,
  "skipped": { "blocklisted": 0, "unsubscribed": 0, "bounced": 0, "invalid": 0, "already_in_campaign": 0 },
  "total": 841,
  "created": 1,
  "invalid": 0,
  "duplicates_in_request": 0
}
POST/campaigns/{id}/leads/actionswrite

Pause, resume or remove leads

Up to 500 leads at once. lead_ids are the enrolment ids from GET /campaigns/{id}/leads. Removing takes a lead out of the sequence and frees the contact to join another campaign; the contact and its history stay. changed counts the leads that actually moved, so ids already in the requested state, or not in this campaign, do not count.

action

stringrequired

What to do.pauseresumeremove
lead_ids

string[]required1–500 items

Enrolment ids in this campaign.
200
Applied.
404not_found
No such campaign on this account.
Request
POST https://www.emailpal.io/api/public/v1/campaigns/{id}/leads/actions
Body
{ "action": "remove", "lead_ids": ["b3a1d2c4-7e58-4a09-9f1b-2c6d0e8a1f33"] }
Response
{ "changed": 1 }

Sequences

The steps of a campaign and the variants of each step. A step is a wait followed by an email; a variant is one version of that email, which is how you A/B test. Up to 10 steps and 6 variants (A to F) each. A step some lead has already sent is locked: it can be edited, but not deleted or moved, because leads point at their position.

GET/campaigns/{id}/stepsread

List a campaign’s steps

The sequence in order, each step with its variants.

200
The campaign exists on this account.
404not_found
No such campaign on this account.
Request
GET https://www.emailpal.io/api/public/v1/campaigns/{id}/steps
Response
{ "data": [{
  "id": "c6a1f0d2-77a4-4a21-bb1e-3f7d9e0a4c55",
  "position": 1,
  "delay_value": 0,
  "delay_unit": "days",
  "locked": false,
  "variants": [
    { "id": "e9b04c37-5d1a-4f6b-8c23-0a7e1d5b9f42", "label": "A", "subject": "Quick question", "body_html": "<p>Hi</p>", "enabled": true, "is_winner": false }
  ]
}] }
POST/campaigns/{id}/stepswrite

Add a step

Adds a step at the end. With no body it is a two-day wait and an empty variant A, like the dashboard’s "Add step". Send variants to supply the copy up front, A first.

delay_value

integer0–365

How long to wait after the previous email.
delay_unit

string

Unit of the wait.minuteshoursdays
variants

object[]1–6 items

Each is { subject?, body_html? }. The first becomes variant A.
201
The step was added.
400invalid_request
The sequence already has 10 steps, or a value failed validation.
404not_found
No such campaign on this account.
Request
POST https://www.emailpal.io/api/public/v1/campaigns/{id}/steps
Body
{ "delay_value": 3, "delay_unit": "days", "variants": [{ "subject": "Following up", "body_html": "<p>Any thoughts?</p>" }] }
Response
{
  "id": "c6a1f0d2-77a4-4a21-bb1e-3f7d9e0a4c55",
  "position": 1,
  "delay_value": 0,
  "delay_unit": "days",
  "locked": false,
  "variants": [
    { "id": "e9b04c37-5d1a-4f6b-8c23-0a7e1d5b9f42", "label": "A", "subject": "Quick question", "body_html": "<p>Hi</p>", "enabled": true, "is_winner": false }
  ]
}
POST/campaigns/{id}/suggest-sequencewrite

Draft a sequence with an AI-run experiment

Has the AI writer draft a sequence into a DRAFT campaign, with one single-change experiment on the first email: a standard version and one (or, with enough volume, two) other versions that differ in exactly one part, such as only the ask. Each version comes back with a short why. The writer uses only the facts you send (and the account’s saved offer profile) and never invents proof; it may use what the platform has learned to choose which change to test, and a cross-workspace number is shown only when the account is included in pooled learning (the default under the Terms of Use, unless turned off) and the evidence is strong enough.

Nothing is sent and the experiment does not start: approval_required is always true. Read the copy, then call POST /campaigns/{id}/launch; launching starts the experiment. Editing a version before launch is allowed, but the experiment then does not start, because an edited version is no longer the checked single-change difference. Editing, disabling or deleting a version of a running experiment ends it. A campaign that already has copy is refused unless replace is true, which overwrites that copy. Takes up to a minute or two.

offer

stringmax 2,000 characters

What you sell, in your words. Required unless the account has an offer profile description.
company_name

stringmax 200 characters

Optional.
audience

stringmax 300 characters

Who the emails go to: role and/or industry.
problem

stringmax 500 characters

The problem your offer solves for them. The first email leads with it instead of with the product.
proof_points

string[]max 8 items

Real, checkable proof: a customer, a number, a result. One fact each. The writer uses nothing else as proof.
resource

stringmax 300 characters

A resource you really have and will send, such as a guide.
preferred_ask

stringmax 200 characters

The ask you prefer, such as "a 15 minute call".
ask_style

string

The ask the first email ends on: reply (a question answered by reply, no link; the default), resource (offers your resource) or call.
notes

stringmax 1,000 characters

Tone, or things to avoid.
has_lead_personalization

boolean

Open each email on the lead’s own personalization line (what you noticed about them). Omit it to decide from the campaign’s leads: on when at least half of them have one.
steps

integer1–5

Emails in the sequence. Default 3.
follow_up_days

object[]max 4 items

An array of whole numbers (1 to 30): days to wait before each follow-up. Default [3, 4, 5, 7].
experiment

boolean

False writes the sequence with no second version. Default true.
adaptive

boolean

Shift new leads toward the better version once enough replies are in. Default true.
leads_per_day

integermin 1

New leads per day, to size the test honestly. Defaults to the campaign’s daily new-lead cap.
replace

boolean

Overwrite copy already in the campaign. Default false.
200
The draft was written. The campaign is still a draft.
400offer_required
No offer was sent and the account has no offer profile description.
404not_found
No such campaign on this account.
409not_draft
The campaign has been launched.
409sequence_not_empty
The campaign already has copy and replace was not true.
502writer_failed
The writer could not produce a usable draft. Try again or give it more detail.
503writer_unavailable
The AI writer is not configured on this server.
Request
POST https://www.emailpal.io/api/public/v1/campaigns/{id}/suggest-sequence
Body
{ "offer": "Outbound reply-rate audits for B2B SaaS", "proof_points": ["Cut one customer's bounce rate from 6% to 1.2%"], "steps": 3 }
Response
{
  "campaign_id": "7b0c5a1e-2d4f-4a8e-9c31-5e6f7a8b9c0d",
  "status": "draft",
  "approval_required": true,
  "steps": [],
  "experiment": {
    "status": "planned",
    "tested_slot": "cta",
    "allocation_mode": "bandit",
    "arms": [
      { "arm": "control", "is_control": true, "variant_label": "A", "move": "cta_soft_question", "target_share": 0.5, "why": "The standard version of this part." },
      { "arm": "c1", "is_control": false, "variant_label": "B", "move": "cta_offer_resource", "target_share": 0.5, "why": "Offers something useful instead of asking for time." }
    ]
  },
  "issues": [],
  "message": "Draft written. Read it, then launch the campaign to start the experiment. Nothing has been sent."
}
GET/campaigns/{id}/experimentsread

List a campaign’s experiments

Drafted experiments that start when the campaign launches (plans) and running or finished ones (experiments), each with its versions, the share of new leads each currently gets, and why it exists. A version a guardrail paused has paused_at and paused_reason.

200
The campaign exists on this account.
404not_found
No such campaign on this account.
Request
GET https://www.emailpal.io/api/public/v1/campaigns/{id}/experiments
Response
{ "plans": [], "experiments": [] }
POST/campaigns/{id}/experiments/stopwrite

Turn experiments off

Stops running experiments, discards drafted ones and switches the other versions off so only the standard version sends. Versions are disabled, not deleted, so one can be switched back on as an ordinary A/B test. Send experiment_id to stop just that experiment.

experiment_id

string

Stop only this experiment. Omit to stop everything on the campaign.
200
Applied. Stopping nothing is not an error.
404not_found
No such campaign on this account.
Request
POST https://www.emailpal.io/api/public/v1/campaigns/{id}/experiments/stop
Body
{}
Response
{ "stopped_experiments": 1, "discarded_plans": 0, "disabled_variants": 1 }
POST/campaigns/{id}/steps/reorderwrite

Reorder the steps

Send every step id in the order you want. Steps a lead has already sent are pinned: moving one would send somebody the wrong follow-up. Returns the steps in their new order.

step_ids

string[]required1–30 items

All of the campaign’s step ids, in the new order.
200
Applied.
400stale_steps
The list is missing a step or contains one that is not in the campaign. Re-read the steps and retry.
409step_locked
The new order would move a step that has already been sent.
404not_found
No such campaign on this account.
Request
POST https://www.emailpal.io/api/public/v1/campaigns/{id}/steps/reorder
Body
{ "step_ids": ["c6a1f0d2-77a4-4a21-bb1e-3f7d9e0a4c55", "d1e2f3a4-0b5c-4d6e-8f70-1a2b3c4d5e6f"] }
Response
{ "data": [{
  "id": "c6a1f0d2-77a4-4a21-bb1e-3f7d9e0a4c55",
  "position": 1,
  "delay_value": 0,
  "delay_unit": "days",
  "locked": false,
  "variants": [
    { "id": "e9b04c37-5d1a-4f6b-8c23-0a7e1d5b9f42", "label": "A", "subject": "Quick question", "body_html": "<p>Hi</p>", "enabled": true, "is_winner": false }
  ]
}] }
PATCH/campaigns/{id}/steps/{stepId}write

Change a step’s wait

The wait counts from the lead’s real previous send. Copy is edited on the variants.

delay_value

integer0–365

How long to wait.
delay_unit

string

Unit of the wait.minuteshoursdays
200
Applied.
404not_found
No such campaign or step on this account.
Request
PATCH https://www.emailpal.io/api/public/v1/campaigns/{id}/steps/{stepId}
Body
{ "delay_value": 5 }
Response
{ "id": "c6a1f0d2-77a4-4a21-bb1e-3f7d9e0a4c55", "saved": true }
DELETE/campaigns/{id}/steps/{stepId}write

Delete a step

Later steps move up. Refused once any lead has sent the step, and for the last remaining step.

200
The step was deleted.
409step_locked
A lead has already sent this step.
400invalid_request
It is the only step.
404not_found
No such campaign or step on this account.
Request
DELETE https://www.emailpal.io/api/public/v1/campaigns/{id}/steps/{stepId}
Response
{ "id": "c6a1f0d2-77a4-4a21-bb1e-3f7d9e0a4c55", "deleted": true }
POST/campaigns/{id}/steps/{stepId}/variantswrite

Add a variant to a step

Adds the next letter (B, C, up to F). With no body the variant starts as a copy of the step’s first one, because an A/B test usually changes one line and a blank variant is one forgotten call away from sending an empty email. Send subject and/or body_html to start from your own copy.

subject

stringmax 300 characters

Subject line. Supports {{merge}} fields.
body_html

stringmax 200,000 characters

The email body as HTML. Sanitised on save.
201
The variant was added.
400too_many_variants
The step already has 6 variants.
404not_found
No such campaign or step on this account.
Request
POST https://www.emailpal.io/api/public/v1/campaigns/{id}/steps/{stepId}/variants
Body
{ "subject": "A different angle" }
Response
{ "id": "f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b", "label": "B", "subject": "A different angle", "body_html": "<p>Hi</p>", "enabled": true, "is_winner": false }
PATCH/campaigns/{id}/variants/{variantId}write

Edit a variant

Changes the subject, body, whether the variant is in rotation, and whether it is the winner. The body is sanitised exactly as the editor’s autosave does it, and the stored version is returned as body_html so you can see what will actually be sent. Marking a winner clears the flag on the step’s other variants. At least one variant of a step has to stay enabled.

Editing the copy of a step that is already sending is allowed. Leads that have already received it are unaffected; the next send uses the new copy.

subject

stringmax 300 characters

Subject line.
body_html

stringmax 200,000 characters

The email body as HTML.
enabled

boolean

Whether the variant is in rotation.
is_winner

boolean

Mark as the winning variant.
200
Applied.
400invalid_request
Nothing to change, or it would leave the step with no enabled variant.
404not_found
No such campaign or variant on this account.
Request
PATCH https://www.emailpal.io/api/public/v1/campaigns/{id}/variants/{variantId}
Body
{ "subject": "Quick question about {{company}}" }
Response
{ "id": "e9b04c37-5d1a-4f6b-8c23-0a7e1d5b9f42", "saved": true, "body_html": "<p>Hi</p>" }
DELETE/campaigns/{id}/variants/{variantId}write

Delete a variant

The step’s last variant cannot be deleted.

200
The variant was deleted.
400invalid_request
It is the step’s only variant.
404not_found
No such campaign or variant on this account.
Request
DELETE https://www.emailpal.io/api/public/v1/campaigns/{id}/variants/{variantId}
Response
{ "id": "f0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b", "deleted": true }

Leads

The people you are emailing. A lead is one row per email address on the account; the same lead can appear in lists and, one at a time, in campaigns. Status is the CRM’s view of where they stand (interested, meeting booked, not interested) and is set by you, by the API, or automatically when a reply is classified.

GET/leadsread

List leads

Newest first. Filters combine with AND.

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.
search

stringmax 200 characters

Match against email, name and company.
status

string

A lead status key from GET /lead-statuses.
list_id

string

Only members of this lead list.
campaign_id

string

Only leads in this campaign.
verification

string

Verification result. unverified selects leads that were never checked and no longer includes unknown ones; use unknown for those.validriskyinvalidunknowncatch_allunverified
owner_id

string

Only leads owned by this team member.
200
Always.
404not_enabled
Campaigns, leads and the CRM are rolled out per account. An account that does not have them gets 404 not_enabled from every endpoint in this section, the same as the pages in the dashboard.
Request
GET https://www.emailpal.io/api/public/v1/leads
Response
{
  "data": [
    {
      "id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44",
      "email": "priya@acme.example",
      "first_name": "Priya",
      "last_name": "Raman",
      "job_title": "CTO",
      "company_name": "Acme",
      "phone": null,
      "website": "https://acme.example",
      "location": null,
      "linkedin_url": null,
      "personalization": null,
      "email_domain": "acme.example",
      "custom": { "team_size": "40" },
      "lead_status": "interested",
      "owner_id": null,
      "verification_status": "valid",
      "verified_at": "2026-09-28T10:00:00.000Z",
      "source": "api",
      "emails_sent_count": 2,
      "opens_count": 0,
      "clicks_count": 0,
      "replies_count": 1,
      "last_contacted_at": "2026-10-05T14:20:00.000Z",
      "last_replied_at": "2026-10-05T16:02:00.000Z",
      "bounced_at": null,
      "unsubscribed_at": null,
      "created_at": "2026-09-28T09:58:00.000Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
POST/leadswrite

Create a lead

Creates one lead. An email that is already a lead is a 409, not a silent overwrite: a script that believes it is creating and is actually editing is how contact data gets clobbered. Use PATCH /leads/{id} to change an existing one, and POST /campaigns/{id}/leads to add many leads to a campaign at once.

Supports Idempotency-Key.

email

stringrequiredmax 254 characters

Lowercased on save.
first_name

string

Given name.
last_name

string

Family name.
job_title

string

Role.
company_name

string

Company.
phone

string

Phone number.
website

string

Company website.
location

string

City or region.
linkedin_url

string

LinkedIn profile URL.
personalization

string

A line for {{personalization}} in copy.
custom

object

A map of your own field keys to string values.
owner_id

string

Team member who owns the lead.
list_id

string

Also add the lead to this list.
status

string

Initial status key. Must exist; see GET /lead-statuses.
201
The lead was created. The body is the lead.
400unknown_status
status is not a status on this account.
400invalid_request
The email or another field failed validation, or list_id is not yours.
409already_exists
That email is already a lead.
Request
POST https://www.emailpal.io/api/public/v1/leads
Body
{ "email": "priya@acme.example", "first_name": "Priya", "company_name": "Acme" }
Response
{ "id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44", "email": "priya@acme.example", "first_name": "Priya", "lead_status": "lead", "source": "api" }
GET/leads/{id}read

One lead, with its timeline

The lead plus the latest 50 timeline entries (emails, replies, status changes, notes), its open tasks, its opportunities, and the lists and campaigns it belongs to.

200
The lead exists on this account.
404not_found
No such lead on this account.
Request
GET https://www.emailpal.io/api/public/v1/leads/{id}
Response
{
  "lead": { "id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44", "email": "priya@acme.example", "lead_status": "interested" },
  "activities": [{ "id": "…", "kind": "note", "body": "Wants a demo in November", "created_at": "2026-10-05T16:30:00.000Z", "opportunity_id": null, "thread_id": null, "meta": {} }],
  "tasks": [],
  "opportunities": [],
  "lists": [{ "id": "…", "name": "Conference attendees" }],
  "campaigns": [{ "id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11", "name": "Q3 outbound - CTOs", "status": "active" }]
}
PATCH/leads/{id}write

Edit a lead

Partial: only the fields you send change, and an empty string clears one. The email cannot be changed. Sending status goes through the same path as the dashboard (timeline entry, automatic opportunity, blocklist rules, and the lead.status_changed webhook), which is why it is not the same as writing a column. Returns the lead.

first_name

string

Given name.
last_name

string

Family name.
job_title

string

Role.
company_name

string

Company.
phone

string

Phone number.
website

string

Company website.
location

string

City or region.
linkedin_url

string

LinkedIn profile URL.
personalization

string

A line for {{personalization}} in copy.
custom

object

Replaces the custom field map.
owner_id

string

Team member who owns the lead. null clears it.
status

string

A status key from GET /lead-statuses.
200
Applied. The body is the lead.
400unknown_status
status is not a status on this account.
404not_found
No such lead on this account.
Request
PATCH https://www.emailpal.io/api/public/v1/leads/{id}
Body
{ "job_title": "VP Engineering", "status": "interested" }
Response
{ "id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44", "email": "priya@acme.example", "job_title": "VP Engineering", "lead_status": "interested" }
POST/leads/{id}/statuswrite

Set a lead’s status

The narrow form of PATCH /leads/{id} for the common case. Setting the status a lead already has changes nothing and emits no webhook, so it is safe to call from a handler that may run twice; changed says which happened. A status whose kind is positive opens an opportunity, won and lost close one, and the account’s blocklist rules can act on it. Setting unsubscribed unsubscribes the lead, exactly like POST /leads/{id}/unsubscribe.

status

stringrequired

A status key from GET /lead-statuses.
200
Always, whether or not the status changed.
400unknown_status
That status does not exist on this account.
404not_found
No such lead on this account.
Request
POST https://www.emailpal.io/api/public/v1/leads/{id}/status
Body
{ "status": "meeting_booked" }
Response
{ "id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44", "status": "meeting_booked", "changed": true }
POST/leads/{id}/unsubscribewrite

Unsubscribe a lead

Permanent. The address is suppressed, every campaign the lead is in stops for them now, and any campaign they are added to later enrols them with status unsubscribed, so they are never mailed again and the leads table shows why. Their status becomes unsubscribed and lead.status_changed fires once. Calling it again changes nothing. Leads who reply asking to be removed ("STOP", "take me off your list") are unsubscribed automatically when the request is unambiguous.

200
The lead is unsubscribed, whether or not it already was.
404not_found
No such lead on this account.
Request
POST https://www.emailpal.io/api/public/v1/leads/{id}/unsubscribe
Response
{ "id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44", "email": "jane@acme.com", "unsubscribed": true, "status": "unsubscribed" }
POST/unsubscribeswrite

Unsubscribe an address

For keeping EmailPal in step with an opt-out list you hold elsewhere. Works whether or not the address is a lead yet: a lead is unsubscribed as in POST /leads/{id}/unsubscribe; an address that is not a lead is suppressed, so if it is ever added to a campaign it arrives as unsubscribed. Permanent, and safe to send the same address every night.

email

stringrequired

The address to unsubscribe.
201
Recorded, whether or not it already was.
Request
POST https://www.emailpal.io/api/public/v1/unsubscribes
Body
{ "email": "jane@acme.com" }
Response
{ "email": "jane@acme.com", "unsubscribed": true, "lead_id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44" }
GET/unsubscribesread

List or check unsubscribes

This account’s unsubscribes, newest first: link and one_click from the unsubscribe link, reply for a reply that asked to be removed, manual for the dashboard and the API. With email, answers for one address instead, counting opt-outs recorded by any sender on the platform.

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.
email

string

Check one address instead of listing.
200
Always.
400invalid_address
email is not a well-formed address.
Request
GET https://www.emailpal.io/api/public/v1/unsubscribes
Response
{
  "data": [
    { "email": "jane@acme.com", "method": "reply", "unsubscribed_at": "2026-10-23T15:51:02.114Z" }
  ]
}
POST/leads/{id}/noteswrite

Add a note to a lead

Appears on the lead’s timeline. Plain text, up to 5,000 characters. A note made with an API key has no author.

body

stringrequired1–5,000 characters

The note.
201
The note was saved.
404not_found
No such lead on this account.
Request
POST https://www.emailpal.io/api/public/v1/leads/{id}/notes
Body
{ "body": "Wants a demo in November" }
Response
{ "lead_id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44", "saved": true }
GET/lead-statusesread

List lead statuses

The statuses on this account, in display order. key is what you send when setting a status; kind (neutral, positive, negative, won, lost) is what the CRM uses to decide whether a status opens, wins or loses a deal.

200
Always.
Request
GET https://www.emailpal.io/api/public/v1/lead-statuses
Response
{
  "data": [
    { "id": "…", "key": "lead", "label": "Lead", "color": "gray", "kind": "neutral", "position": 0, "is_builtin": true },
    { "id": "…", "key": "interested", "label": "Interested", "color": "green", "kind": "positive", "position": 1, "is_builtin": true }
  ]
}
GET/lead-listsread

List lead lists

The groups of leads you import into and add campaigns from, by name. Pass an id as list_id to POST /campaigns/{id}/leads to enrol every member. Not /lists, which is the email verification lists.

200
Always.
Request
GET https://www.emailpal.io/api/public/v1/lead-lists
Response
{ "data": [{ "id": "…", "name": "Conference attendees", "member_count": 312, "created_at": "2026-09-20T08:00:00.000Z" }] }

CRM

Pipelines, opportunities and follow-up tasks. When a lead replies positively an opportunity opens by itself; these endpoints let you read the board, create deals by hand, move them across stages and keep your own tasks next to them. Values are in cents.

GET/pipelinesread

List pipelines and their stages

Every pipeline with its stages and what is in each one right now: deal count, total value and probability-weighted value, in cents. Stage ids are what opportunities are created in and moved to. A stage’s kind (open, won, lost) decides the status of the deals in it.

200
Always.
Request
GET https://www.emailpal.io/api/public/v1/pipelines
Response
{
  "data": [
    {
      "id": "…",
      "name": "Sales",
      "is_default": true,
      "stages": [
        { "id": "…", "name": "Interested", "position": 0, "probability_pct": 20, "kind": "open", "color": "blue", "totals": { "stage_id": "…", "deal_count": 3, "value_cents": 1500000, "weighted_cents": 300000 } }
      ]
    }
  ]
}
GET/opportunitiesread

List opportunities

Newest first.

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 deals in this state.openwonlost
stage_id

string

Only deals in this stage.
campaign_id

string

Only deals that came from this campaign.
q

stringmax 200 characters

Match against name and company.
200
Always.
404not_enabled
Campaigns, leads and the CRM are rolled out per account. An account that does not have them gets 404 not_enabled from every endpoint in this section, the same as the pages in the dashboard.
Request
GET https://www.emailpal.io/api/public/v1/opportunities
Response
{
  "data": [
    {
      "id": "…",
      "name": "Acme - platform rollout",
      "stage_id": "…",
      "contact_id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44",
      "campaign_id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11",
      "company_name": "Acme",
      "company_domain": "acme.example",
      "value_cents": 500000,
      "recurrence": "yearly",
      "priority": "medium",
      "est_close_date": "2026-11-30",
      "origin": "auto",
      "status": "open",
      "lost_reason": null,
      "sort_key": 3000,
      "notes": null,
      "created_at": "2026-10-05T16:03:00.000Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
POST/opportunitieswrite

Create an opportunity

Creates a deal at the bottom of the stage you name. Its status follows the stage: created in a "won" stage it is won. Opportunities the engine opens when a lead replies positively are origin: "auto"; this one is manual and never changes the lead’s status on its own.

Supports Idempotency-Key.

name

stringrequired1–200 characters

What the deal is.
stage_id

stringrequired

A stage id from GET /pipelines.
value_cents

integermin 0defaults to 0

Deal value in cents.
contact_id

string

The lead this deal is with.
campaign_id

string

The campaign it came from.
company_name

stringmax 200 characters

Company.
company_domain

stringmax 200 characters

Company domain.
recurrence

stringdefaults to "one_time"

How the value recurs.one_timemonthlyyearly
priority

stringdefaults to "medium"

Priority.lowmediumhigh
est_close_date

string

Expected close date, YYYY-MM-DD.
notes

stringmax 5,000 characters

Free text.
owner_id

string

Team member who owns the deal.
201
The opportunity was created. The body is the opportunity.
400invalid_request
The stage, lead or campaign does not exist on this account, or a value failed validation.
Request
POST https://www.emailpal.io/api/public/v1/opportunities
Body
{ "name": "Acme - platform rollout", "stage_id": "…", "value_cents": 500000, "contact_id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44" }
Response
{ "id": "…", "name": "Acme - platform rollout", "status": "open", "origin": "manual", "value_cents": 500000 }
GET/opportunities/{id}read

One opportunity

The same shape as a list entry.

200
The opportunity exists on this account.
404not_found
No such opportunity on this account.
Request
GET https://www.emailpal.io/api/public/v1/opportunities/{id}
Response
{ "id": "…", "name": "Acme - platform rollout", "status": "open", "stage_id": "…", "value_cents": 500000 }
PATCH/opportunities/{id}write

Edit an opportunity

Partial. Send status to close or reopen a deal, with lost_reason when losing it. To change the stage on the board use POST /opportunities/{id}/move, which also orders the card within the column.

name

stringmax 200 characters

What the deal is.
stage_id

string

Move to this stage, at the bottom.
status

string

Close or reopen the deal.openwonlost
lost_reason

stringmax 200 characters

Why it was lost.
value_cents

integermin 0

Deal value in cents.
contact_id

string

The lead this deal is with.
campaign_id

string

The campaign it came from.
company_name

stringmax 200 characters

Company.
company_domain

stringmax 200 characters

Company domain.
recurrence

string

How the value recurs.one_timemonthlyyearly
priority

string

Priority.lowmediumhigh
est_close_date

string

Expected close date, YYYY-MM-DD.
notes

stringmax 5,000 characters

Free text.
owner_id

string

Team member who owns the deal.
200
Applied. The body is the opportunity.
400invalid_request
A referenced stage, lead or campaign does not exist on this account.
404not_found
No such opportunity on this account.
Request
PATCH https://www.emailpal.io/api/public/v1/opportunities/{id}
Body
{ "status": "lost", "lost_reason": "Went with an incumbent" }
Response
{ "id": "…", "status": "lost", "lost_reason": "Went with an incumbent" }
POST/opportunities/{id}/movewrite

Move an opportunity to a stage

Like dragging a card. before_id / after_id name the card it should land next to in the target column; omit both to drop it at the bottom. Moving into a "won" or "lost" stage closes the deal, and moving out of one reopens it. Returns the opportunity.

stage_id

stringrequired

The destination stage.
before_id

string

The card that will sit immediately above this one.
after_id

string

The card that will sit immediately below this one.
200
Moved. The body is the opportunity.
400invalid_request
The stage does not exist, or the neighbouring card is not in it.
404not_found
No such opportunity on this account.
Request
POST https://www.emailpal.io/api/public/v1/opportunities/{id}/move
Body
{ "stage_id": "…" }
Response
{ "id": "…", "stage_id": "…", "status": "won", "sort_key": 4000 }
GET/tasksread

List tasks

Follow-up tasks. There is no cursor: you get up to limit of them (soonest due first for open and overdue, most recently completed first for done), and has_more says there were more. Narrow with scope rather than paging.

limit

integer1–200defaults to 50

How many to return.
scope

stringdefaults to "open"

Which tasks.openoverduedone
200
Always.
Request
GET https://www.emailpal.io/api/public/v1/tasks
Response
{
  "data": [
    { "id": "…", "title": "Send pricing to Priya", "due_at": "2026-10-07T15:00:00.000Z", "done_at": null, "contact_id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44", "contact_label": "Priya Raman", "opportunity_id": null, "created_at": "2026-10-05T16:31:00.000Z" }
  ],
  "has_more": false,
  "next_cursor": null
}
POST/taskswrite

Create a task

Optionally attached to a lead and/or an opportunity. A task made with an API key has no author, and is unassigned unless you name an assignee_id. Supports Idempotency-Key.

title

stringrequired1–300 characters

What needs doing.
due_at

string

ISO 8601 timestamp with an offset, e.g. 2026-10-07T15:00:00Z.
contact_id

string

The lead it is about.
opportunity_id

string

The opportunity it is about.
assignee_id

string

Team member responsible.
201
The task was created.
400invalid_request
The lead or opportunity does not exist on this account, or a value failed validation.
Request
POST https://www.emailpal.io/api/public/v1/tasks
Body
{ "title": "Send pricing to Priya", "due_at": "2026-10-07T15:00:00Z", "contact_id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44" }
Response
{ "id": "…", "title": "Send pricing to Priya", "due_at": "2026-10-07T15:00:00Z" }
PATCH/tasks/{id}write

Edit or complete a task

done: true completes it and done: false reopens it. due_at: null clears the due date.

title

string1–300 characters

What needs doing.
due_at

string

ISO 8601 timestamp with an offset, or null.
done

boolean

Complete or reopen.
assignee_id

string

Team member responsible. null unassigns.
200
Applied.
404not_found
No such task on this account.
Request
PATCH https://www.emailpal.io/api/public/v1/tasks/{id}
Body
{ "done": true }
Response
{ "id": "…", "saved": true }

Replies

Conversations where a prospect answered a campaign email. This is the slice of the inbox you act on: warming traffic never appears here, which is the difference from `GET /inbox`. A reply is a thread, and labelling a thread is how you tell the CRM what the answer meant.

GET/repliesread

List campaign replies

Most recent first. The id is a thread id: pass it to POST /replies/{id}/label. To read the message bodies use GET /inbox and GET /inbox/{id}, or the reply.received webhook, which carries the message id.

Send X-Workspace-Id: all to list replies from every workspace the key can reach that has campaigns on, newest first across all of them. Each item carries its workspace_id.

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.
campaign_id

string

Only replies to this campaign.
status

string

Only threads labelled with this lead status key.
unread

boolean

Only threads with unread messages.
done

boolean

Only threads marked done (true) or not done (false).
200
Always.
404not_enabled
Campaigns, leads and the CRM are rolled out per account. An account that does not have them gets 404 not_enabled from every endpoint in this section, the same as the pages in the dashboard.
Request
GET https://www.emailpal.io/api/public/v1/replies
Response
{
  "data": [
    {
      "id": "…",
      "workspace_id": "3f2a9c1e-7b4d-4e8a-9c0f-1d2e3f4a5b6c",
      "subject": "Re: quick question, Priya",
      "snippet": "Thanks for following up, happy to take a look next week.",
      "counterpart_email": "priya@acme.example",
      "counterpart_name": "Priya Raman",
      "status": "lead",
      "campaign_id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11",
      "contact_id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44",
      "mailbox_id": "8c1d7e2a-0f33-4d0c-a1f9-5f6d2b8e4a90",
      "message_count": 3,
      "unread_count": 1,
      "is_done": false,
      "last_message_at": "2026-10-05T16:02:00.000Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
POST/replies/{id}/labelwrite

Label a reply

Labels a thread with a lead status (interested, not_interested, …; see GET /lead-statuses). It is what clicking the label in the dashboard does: the lead’s status follows, a positive label opens an opportunity, the account’s blocklist rules apply, and lead.status_changed fires.

status

stringrequired

A status key from GET /lead-statuses.
200
Labelled.
400unknown_status
That status does not exist on this account.
404not_found
No such thread on this account.
Request
POST https://www.emailpal.io/api/public/v1/replies/{id}/label
Body
{ "status": "interested" }
Response
{ "id": "…", "status": "interested", "contact_id": "a7d9e1f0-3c24-4b68-8e5a-9d0c1b2a3f44", "campaign_id": "5d3c0b1e-6a52-4f0e-9d53-1d0b9f2a7c11" }

Prospect lists

The lists people are saved into from lead search. Each list reports how much of it is verified and which verification request is working through the rest. Prospect lists and verification are rolled out per account with campaigns and the CRM. An account that does not have them gets 404 `not_enabled` from every endpoint in this section.

GET/prospect-listsread

List prospect lists

Newest first, with each list’s size and a verification block: verified (people with any result, including invalid), unverified, and the open request (running or paused) covering the whole list, or null. Not /lead-lists (the CRM import lists) and not /lists (email verification uploads).

200
Always.
Request
GET https://www.emailpal.io/api/public/v1/prospect-lists
Response
{
  "data": [
    {
      "id": "3f1a9d2e-5b7c-4e08-8a41-2d6c9e0b7f13",
      "name": "Series A SaaS CTOs",
      "item_count": 3000,
      "created_at": "2026-10-09T10:00:00.000Z",
      "verification": { "verified": 450, "unverified": 2550, "request": {
        "id": "0c4b5f0e-2c1d-4f43-9a52-7b1d5a0e9a10",
        "status": "active",
        "pause_reason": null,
        "scope_kind": "prospect_list",
        "prospect_list_id": "3f1a9d2e-5b7c-4e08-8a41-2d6c9e0b7f13",
        "contact_list_id": null,
        "campaign_id": null,
        "priority": "list",
        "origin": "agent",
        "actor_type": "agent",
        "actor_id": "9a2e61c4-0f3b-4d77-b1a8-5c0e4d3f2a19",
        "total_count": 3000,
        "verified_count": 450,
        "max_credits_per_day": null,
        "last_activity_at": "2026-10-11T19:02:00.000Z",
        "paused_at": null,
        "completed_at": null,
        "cancelled_at": null,
        "created_at": "2026-10-10T14:00:00.000Z",
        "updated_at": "2026-10-11T19:02:00.000Z"
      } }
    }
  ]
}
POST/prospect-listswrite

Create a prospect list

An empty, named list. Names are unique per account and an account holds up to 200 lists. Needs a plan. Supports Idempotency-Key.

name

stringrequired1–80 characters

The list’s name.
201
The list was created.
402subscription_required
The account has no plan.
409duplicate_name
A list with that name exists.
409limit_reached
The account already has 200 lists.
Request
POST https://www.emailpal.io/api/public/v1/prospect-lists
Body
{ "name": "Series A SaaS CTOs" }
Response
{ "id": "3f1a9d2e-5b7c-4e08-8a41-2d6c9e0b7f13", "name": "Series A SaaS CTOs", "item_count": 0, "created_at": "2026-10-09T10:00:00.000Z", "verification": { "verified": 0, "unverified": 0, "request": null } }
GET/prospect-lists/{id}read

Get a prospect list with its verification summary

The list with verification.verified, verification.unverified and the open request covering it. Reading a list counts as activity on that request, which defers the pause for being idle (14 days).

200
The list exists on this account.
404not_found
No such list on this account.
Request
GET https://www.emailpal.io/api/public/v1/prospect-lists/{id}
Response
{ "id": "3f1a9d2e-5b7c-4e08-8a41-2d6c9e0b7f13", "name": "Series A SaaS CTOs", "item_count": 3000, "created_at": "2026-10-09T10:00:00.000Z", "verification": { "verified": 450, "unverified": 2550, "request": null } }
POST/prospect-lists/{id}/itemswrite

Add people to a prospect list

The dashboard’s "Add to list": each person is re-checked against the removal list, the suppression lists and the free-provider, .gov, .mil and .edu rules, and only those that pass are saved (as CRM contacts too). Contacts cannot be invented: record_id is the sealed id a lead search returned for the person, and a stale or forged one is rejected as stale_record. People added are unverified until a verification request covers them. Needs a plan. Supports Idempotency-Key.

rejected entries are { index, reason, message }, never the values.

contacts

object[]required1–200 items

Each { record_id, email, name?, first_name?, last_name?, job_title?, company_name?, phone?, location?, linkedin_url? }.
200
Always when the request is valid, even if every person was rejected (accepted: 0).
402subscription_required
The account has no plan.
404not_found
No such list on this account.
503unavailable
The removal checks could not run; nothing was saved. Retry shortly.
Request
POST https://www.emailpal.io/api/public/v1/prospect-lists/{id}/items
Body
{ "contacts": [{ "record_id": "lr2_…", "email": "priya@acme.example", "name": "Priya Raman" }] }
Response
{ "accepted": 1, "created": 1, "added": 1, "already_in_list": 0, "rejected": [] }
GET/prospect-lists/{id}/exportread

Export a prospect list as CSV

The list as CSV, newest first, with verification_status and verified_at per person (empty when not verified). Anyone who asked to be removed is left out. Cut at limit rows; x-export-truncated: 1 says the file is shorter than the list. Needs a plan. Audited.

limit

integer1–20,000defaults to 20000

Most rows to return.
200
Returns text/csv. x-emailpal-exported is the row count.
402subscription_required
The account has no plan.
404not_found
No such list on this account.
Request
GET https://www.emailpal.io/api/public/v1/prospect-lists/{id}/export

Verification requests

A verification request is a durable "check these leads" job. It uses the account’s daily verification allowance (one credit per address) a day’s slice at a time until everyone has a result, so a list bigger than one day’s allowance simply takes more days. Credits are taken per slice, never reserved up front: starting, pausing and cancelling are free. Quote first to see how many days it will take. Prospect lists and verification are rolled out per account with campaigns and the CRM. An account that does not have them gets 404 `not_enabled` from every endpoint in this section.

POST/verification-requests/quoteread

Quote a verification

How long verifying a scope would take: pending people without a result, the daily allowance (cap), used_today, available_today and estimated_days (null when there is no allowance). Read-only: it takes no credits and creates nothing, so a read key may call it (it is a POST because the scope can be thousands of ids). existing_request is the request already covering this scope, if any. note is a ready-made line about the allowance when it will take more than a day. Send exactly one of prospect_list_id, contact_list_id or contact_ids, or campaign_id on its own.

prospect_list_id

string

A prospect list from GET /prospect-lists. Everyone in it without a result, live: people added later are covered.
contact_list_id

string

A CRM lead list from GET /lead-lists.
contact_ids

string[]1–10,000 items

A hand-picked set of lead ids. For a whole list send the list id instead.
campaign_id

string

On its own: verify the campaign’s enrolled leads. Next to a list or contact_ids: link the request to the campaign, so it outranks other requests and pauses and resumes with the campaign.
max_credits_per_day

integermin 1

Most credits this one request may use per day, to leave allowance for other work. Default: no cap beyond the account’s.
200
The scope exists on this account.
400invalid_request
No scope, or more than one.
404not_found
No such list or campaign on this account.
Request
POST https://www.emailpal.io/api/public/v1/verification-requests/quote
Body
{ "prospect_list_id": "3f1a9d2e-5b7c-4e08-8a41-2d6c9e0b7f13" }
Response
{
  "pending": 3000,
  "cap": 500,
  "used_today": 120,
  "available_today": 380,
  "estimated_days": 7,
  "entitled": true,
  "billing_url": "https://www.emailpal.io/billing",
  "existing_request": null,
  "note": "This will take about 7 days at your 500/day allowance. Increase your daily verifications in Billing."
}
POST/verification-requestswrite

Start verifying leads

Starts a request, or returns the one already running or paused for the same list or campaign (created: false; a request paused by the user or for idleness is woken). Run the quote first and get the user’s approval when estimated_days is more than 1. Nothing is charged by this call; the worker takes credits from each day’s allowance as it dispatches slices. The request records the key as its actor (actor_type: agent) and origin: agent when it came through MCP, otherwise api. Supports Idempotency-Key. Send exactly one of prospect_list_id, contact_list_id or contact_ids, or campaign_id on its own.

prospect_list_id

string

A prospect list from GET /prospect-lists. Everyone in it without a result, live: people added later are covered.
contact_list_id

string

A CRM lead list from GET /lead-lists.
contact_ids

string[]1–10,000 items

A hand-picked set of lead ids. For a whole list send the list id instead.
campaign_id

string

On its own: verify the campaign’s enrolled leads. Next to a list or contact_ids: link the request to the campaign, so it outranks other requests and pauses and resumes with the campaign.
max_credits_per_day

integermin 1

Most credits this one request may use per day, to leave allowance for other work. Default: no cap beyond the account’s.
201
A request was created.
200
An existing request (same idempotency key, or same open scope) was returned.
400invalid_request
No scope, or more than one; or a bad max_credits_per_day.
402subscription_required
The account has no payment method or plan.
402verification_capacity_required
The account has no daily verification allowance. Carries billing_url.
404not_found
No such list or campaign on this account.
409nothing_to_verify
Everyone in the scope already has a verification result.
Request
POST https://www.emailpal.io/api/public/v1/verification-requests
Body
{ "prospect_list_id": "3f1a9d2e-5b7c-4e08-8a41-2d6c9e0b7f13", "max_credits_per_day": 300 }
Response
{
  "request": {
    "id": "0c4b5f0e-2c1d-4f43-9a52-7b1d5a0e9a10",
    "status": "active",
    "pause_reason": null,
    "scope_kind": "prospect_list",
    "prospect_list_id": "3f1a9d2e-5b7c-4e08-8a41-2d6c9e0b7f13",
    "contact_list_id": null,
    "campaign_id": null,
    "priority": "list",
    "origin": "agent",
    "actor_type": "agent",
    "actor_id": "9a2e61c4-0f3b-4d77-b1a8-5c0e4d3f2a19",
    "total_count": 3000,
    "verified_count": 450,
    "max_credits_per_day": null,
    "last_activity_at": "2026-10-11T19:02:00.000Z",
    "paused_at": null,
    "completed_at": null,
    "cancelled_at": null,
    "created_at": "2026-10-10T14:00:00.000Z",
    "updated_at": "2026-10-11T19:02:00.000Z"
  },
  "created": true,
  "quote": {
    "pending": 3000,
    "cap": 500,
    "used_today": 120,
    "available_today": 380,
    "estimated_days": 7,
    "entitled": true,
    "billing_url": "https://www.emailpal.io/billing",
    "existing_request": null,
    "note": "This will take about 7 days at your 500/day allowance. Increase your daily verifications in Billing."
  }
}
GET/verification-requestsread

List verification requests

Newest first. verified_count of total_count is the progress.

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 requests in this state.activepausedcompletedcancelled
prospect_list_id

string

Only requests covering this prospect list.
contact_list_id

string

Only requests covering this CRM lead list.
campaign_id

string

Only requests linked to this campaign.
200
Always.
Request
GET https://www.emailpal.io/api/public/v1/verification-requests
Response
{
  "data": [{
    "id": "0c4b5f0e-2c1d-4f43-9a52-7b1d5a0e9a10",
    "status": "active",
    "pause_reason": null,
    "scope_kind": "prospect_list",
    "prospect_list_id": "3f1a9d2e-5b7c-4e08-8a41-2d6c9e0b7f13",
    "contact_list_id": null,
    "campaign_id": null,
    "priority": "list",
    "origin": "agent",
    "actor_type": "agent",
    "actor_id": "9a2e61c4-0f3b-4d77-b1a8-5c0e4d3f2a19",
    "total_count": 3000,
    "verified_count": 450,
    "max_credits_per_day": null,
    "last_activity_at": "2026-10-11T19:02:00.000Z",
    "paused_at": null,
    "completed_at": null,
    "cancelled_at": null,
    "created_at": "2026-10-10T14:00:00.000Z",
    "updated_at": "2026-10-11T19:02:00.000Z"
  }],
  "has_more": false,
  "next_cursor": null
}
GET/verification-requests/{id}read

Get a verification request

The request plus live progress: pending still to check, in_flight sent and waiting for answers, spent_today (credits taken today, UTC) and, while it is open, a quote for the remainder. pause_reason is user, campaign_inactive, idle (14 days without activity), no_entitlement or list_deleted.

200
The request exists on this account.
404not_found
No such request on this account.
Request
GET https://www.emailpal.io/api/public/v1/verification-requests/{id}
Response
{ ...the request fields above, "pending": 2550, "in_flight": 0, "spent_today": 120, "quote": { … } }
POST/verification-requests/{id}/pausewrite

Pause a verification request

Stops it using any more of the allowance. Results already applied stay. Only a person or an API key resumes a request paused this way. Pausing a paused request succeeds unchanged.

200
The request is now paused (or already was). Returns the request.
404not_found
No such request on this account.
409already_finished
The request is completed or cancelled.
Request
POST https://www.emailpal.io/api/public/v1/verification-requests/{id}/pause
POST/verification-requests/{id}/resumewrite

Resume a verification request

Restarts a paused request; credits are taken again from the next slice.

200
The request is active (or already was). Returns the request.
402verification_capacity_required
The account has no daily verification allowance.
404not_found
No such request on this account.
409already_finished
The request is completed or cancelled.
409campaign_inactive
The request belongs to a campaign that is not running; resume the campaign instead.
Request
POST https://www.emailpal.io/api/public/v1/verification-requests/{id}/resume
POST/verification-requests/{id}/cancelwrite

Cancel a verification request

Cancels it for good. Nothing is owed or refunded: credits are only taken for slices already dispatched, and a slice in flight still completes and is applied. People already verified keep their result. Start a new request to continue later. Releases a campaign’s hold on unverified leads.

200
The request is cancelled. Returns the request.
404not_found
No such request on this account.
409already_finished
The request is already completed or cancelled.
Request
POST https://www.emailpal.io/api/public/v1/verification-requests/{id}/cancel

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 in your account.

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.

200
Always, including when the shelf is empty.
500read_failed
The inventory query failed.
Request
GET https://www.emailpal.io/api/public/v1/inventory/domains
Response
{
  "available": 214,
  "preferred": 96,
  "previously_used": 18,
  "oldest_days": 340,
  "from_price_cents": 2400,
  "previously_used_from_price_cents": 1800,
  "min_age_days": 90,
  "preferred_age_days": 180
}
POST/inventory/domainswrite

Claim aged 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.

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.
202
The claim was queued. Poll the job_id.
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 newly bought pool domains before anyone asks and warmed continuously since, which is why they are leased monthly rather than sold. The warming is the product, not domain age.

This is the endpoint to build an overflow pool on: a tenant who needs to send this week can skip the fourteen-day ramp. Inboxes are only listed once they have completed 14 days of warming.

Each inbox is listed with email, first_name and last_name so you can see the address before you pay. Pick with mailbox_ids on POST. Passwords are not included. 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 placement history is genuinely the address’s own.

Availability moves. A listing read a minute ago is not a reservation — the claim is what reserves, and it can come back short.

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.
200
Always, including when the pool is empty.
500read_failed
The pool query failed.
Request
GET https://www.emailpal.io/api/public/v1/inventory/prewarmed
Response
{
  "available": 48,
  "previously_leased": 6,
  "best_health": 94,
  "monthly_cents": 300,
  "min_health": 0,
  "min_warming_days": 14,
  "inboxes": [
    {
      "id": "mbx_2c81f0",
      "email": "sarah.chen@northworks.com",
      "first_name": "Sarah",
      "last_name": "Chen",
      "display_name": "Sarah Chen",
      "domain_id": "dom_91af22",
      "domain": "northworks.com",
      "health_score": 94,
      "inbox_rate": 0.96,
      "warming_day": 42,
      "previously_leased": false
    }
  ],
  "has_more": true,
  "next_cursor": "94:mbx_2c81f0"
}
POST/inventory/prewarmedwrite

Lease pre-warmed inboxes

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.

Browse GET first, then pass mailbox_ids for the addresses you want. A count without ids takes the healthiest still on the shelf.

There is no minimum term: a lease is month to month and you can release it at any time. Poll the job: result.leased lists email, first_name and last_name for what you actually got.

Send an Idempotency-Key header. This starts a recurring monthly charge.

Leases are billed at the lease rate rather than against the plan’s mailbox allowance.

Deleting a leased mailbox hands the address back to the pool rather than destroying it, and stops billing for it.

Poll the job. result.leased is the disclosure of what you got — email, first_name, last_name.

count

integer1–25

How many to lease. Picks the healthiest available. Ignored when mailbox_ids is set.
mailbox_ids

string[]1–25 items

Specific inboxes from GET. Wins over count.
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.
202
The lease was queued. Poll the job_id.
402plan_limit
The account is suspended or otherwise stopped from growing.
Request
POST https://www.emailpal.io/api/public/v1/inventory/prewarmed
Body
{
  "mailbox_ids": ["mbx_2c81f0", "mbx_2c81f1"]
}
Response
{
  "job_id": "job_5c9e18",
  "poll": "/api/public/v1/jobs/job_5c9e18",
  "monthly_cents_each": 300,
  "note": "There is no minimum term: release an inbox any time and billing stops. 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.

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
200
Always.
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/jobs
Response
{
  "data": [
    {
      "id": "job_4h29xk",
      "type": "domain.purchase",
      "status": "succeeded",
      "terminal": true,
      "attempts": 1,
      "max_attempts": 2,
      "progress": 100,
      "progress_message": null,
      "error": null,
      "retry_after": null,
      "started_at": "2026-07-25T14:02:11.000Z",
      "finished_at": "2026-07-25T14:02:29.000Z",
      "created_at": "2026-07-25T14:02:09.000Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
GET/jobs/{id}read

One job

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.

200
The job exists on this account.
404not_found
No such job on this account.
Request
GET https://www.emailpal.io/api/public/v1/jobs/{id}
Response
{
  "id": "job_4h29xk",
  "type": "domain.purchase",
  "status": "running",
  "terminal": false,
  "attempts": 1,
  "max_attempts": 2,
  "progress": 50,
  "progress_message": "Registering with the registrar",
  "error": null,
  "retry_after": null,
  "started_at": "2026-07-25T14:02:11.000Z",
  "finished_at": null,
  "created_at": "2026-07-25T14:02:09.000Z"
}
POST/jobs/{id}write

Retry or cancel a job

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.

action

stringrequired

What to do with it.retrycancel
200
Cancelled.
202
Requeued. Poll it again.
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.

200
Always, including when there are no endpoints.
500read_failed
The query failed.
Request
GET https://www.emailpal.io/api/public/v1/webhooks
Response
{
  "data": [
    {
      "id": "whe_3f81aa",
      "url": "https://hooks.example.com/emailpal",
      "description": "production",
      "events": ["message.received", "mailbox.campaign_ready"],
      "subscribed_to_all": false,
      "enabled": true,
      "disabled_at": null,
      "disabled_reason": null,
      "health": {
        "last_success_at": "2026-07-27T09:14:02.000Z",
        "last_failure_at": null,
        "consecutive_failures": 0
      },
      "created_at": "2026-07-01T12:00:00.000Z"
    }
  ],
  "available_events": ["job.succeeded", "job.failed", "domain.ready", "domain.failed", "mailbox.provisioned", "mailbox.campaign_ready", "mailbox.failed", "list.progress", "list.completed", "message.received", "message.delivery_updated", "reply.received", "lead.status_changed", "campaign.paused", "account.restriction_changed", "domain.restriction_changed", "mailbox.restriction_changed", "domain.moved"]
}
POST/webhookswrite

Register a webhook endpoint

Returns the signing secret once. Store it before you close the response — no later read gives it back, and the only way to recover is to rotate.

Every delivery carries an EmailPal-Signature header:

`` EmailPal-Signature: t=1774483200,v1=5257a869e7... ``

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, reason (not_reached, consumer_microsoft or null, see GET /inbox/send/{id}) 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.

reply.received fires when a prospect answers a campaign email, and is tied to the campaign: data is { message_id, campaign_id, campaign_lead_id, contact_id, from, subject, step, variant, auto_reply, stopped_sequence, received_at }. step and variant identify the email that was answered, auto_reply flags out-of-office and other machine replies (they are reported, not hidden), and stopped_sequence says whether the reply ended that lead’s sequence under the campaign’s stop rules. Fetch the text from /inbox/{message_id}. This is the event for a CRM sync or a notifier; message.received is the broader one for every non-warming message.

lead.status_changed fires whenever a lead’s status changes, whoever changed it: data is { contact_id, email, previous_status, status, source, campaign_id, thread_id, changed_at }. source is unibox, crm, api, campaign_reply, campaign_bounce, automation or ai_label (the reply classifier labelled the conversation). Setting the status a lead already has fires nothing.

campaign.paused fires when a campaign stops sending: data is { campaign_id, name, reason, message, auto, paused_at }. reason is manual (a person or an API key), or one of bounce_rate, account_restricted, other for a pause the platform made (older campaigns may still show the retired complaint_rate, unsubscribe_spike or duplicate_copy). auto is true for those, and an automatic pause is the one worth waking someone for. message is the same plain-language explanation the dashboard shows.

account.restriction_changed fires when the account’s sending standing changes, in either direction, and while a throttle stays on but the domains it covers change: data is { enforcement, previous, scope, restricted, reason, message, domains, since }. enforcement and previous are none, watch, no_new_mailboxes, throttled or suspended; restricted is true for the last three. scope is account or domains, and domains is [{ domain_id, domain, rejected, completed, rate }], the only domains limited when the scope is domains (every other domain sends normally). rate is a fraction, so 0.0734 is 7.34%. reason is a code (domain_refusals, dead_addresses, receiver_refusals, account_review, recovered) and message is the sentence the dashboard and the email use. GET /account shows the same state.

domain.restriction_changed fires when a domain is held, restarts, is limited or is released: data is { domain_id, domain, restricted, stage, source, provider, reason, message, rest_until, ramp_cap, mailboxes }. stage is resting (held, ramp_cap 0), ramping (restarted at a daily cap that rises each day), replace (refused again after two restarts), limited (inside the account’s domain throttle) or normal (back to normal, restricted false). source is breaker, content, throttle or recovery; provider is the receiver that refused it, or null; reason is a code (provider_refusals, prohibited_content, throttle_refusals, throttle_released, rest_over, ramp_complete). rest_until is when a resting domain restarts on its own, and mailboxes is [{ id, email }] for the mailboxes the change held, lifted or limited. Each one is followed by a mailbox.restriction_changed per mailbox. GET /domains/{id} shows the same state.

mailbox.restriction_changed fires when one mailbox’s sending hold begins or ends: data is { mailbox_id, email, domain_id, restricted, source, reason, message, paused_at, cancelled_count, refused_count }. source is bounce_hold (its own rejection rate reached 5%), domain_breaker, content_breaker or resume; reason is a code (bounce_rate, domain_refusals, prohibited_content, recovery_release, customer_resumed). cancelled_count and refused_count are the emails to resend once it is lifted, the same counts as sending_pause on GET /mailboxes/{id}. A domain hold sends one of these per mailbox, so a domain with twenty-five mailboxes produces twenty-six events in a few seconds; dedupe on id as always.

The three restriction events are in the default set. An endpoint with no events filter receives them without being changed; pin events if you want only the ones you handle.

domain.moved fires once a domain has moved to another workspace on the same plan, and goes to both workspaces, once each: data is { move_id, domain_id, domain, from_workspace_id, to_workspace_id, mailboxes, threads, campaigns_detached, scheduled_replies_cancelled, bodies_unavailable, moved_at }. mailboxes and threads are how many moved; bodies_unavailable is how many stored messages could not be carried over and now show without their body. A failed move sends no domain.moved, only job.failed, and leaves the domain where it was.

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_updatedreply.receivedlead.status_changedcampaign.pausedaccount.restriction_changeddomain.restriction_changedmailbox.restriction_changeddomain.moved
description

stringmax 200 characters

For your own reference. Shown in the dashboard.
201
The endpoint was registered. The secret is in this response only.
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
Body
{
  "url": "https://hooks.example.com/emailpal",
  "events": ["message.received", "mailbox.campaign_ready"],
  "description": "production"
}
Response
{
  "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.

200
The endpoint exists on this account.
404not_found
No such endpoint on this account.
Request
GET https://www.emailpal.io/api/public/v1/webhooks/{id}
Response
{
  "id": "whe_3f81aa",
  "url": "https://hooks.example.com/emailpal",
  "enabled": true,
  "recent_deliveries": [
    {
      "id": "whd_88c101",
      "event_id": "a2f4c8e0-1d3b-5a7e-9f21-6b04c8d21e77",
      "event_type": "message.received",
      "status": "delivered",
      "attempts": 1,
      "response_status": 200,
      "error": null,
      "next_attempt_at": null,
      "delivered_at": "2026-07-27T09:14:02.000Z",
      "created_at": "2026-07-27T09:14:01.000Z"
    }
  ]
}
PATCH/webhooks/{id}write

Change an endpoint, or rotate its secret

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.

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_updatedreply.receivedlead.status_changedcampaign.pausedaccount.restriction_changeddomain.restriction_changedmailbox.restriction_changeddomain.moved
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.
200
The change was applied.
400invalid_webhook_url
The new URL was rejected.
404not_found
No such endpoint on this account.
Request
PATCH https://www.emailpal.io/api/public/v1/webhooks/{id}
Body
{
  "rotate_secret": true
}
DELETE/webhooks/{id}write

Delete an endpoint

Queued deliveries go with it. Keeping them would leave an outbox with nowhere to send to, retried until it died.

200
The endpoint was deleted.
404not_found
No such endpoint on this account.
Request
DELETE https://www.emailpal.io/api/public/v1/webhooks/{id}

Notifications

The bell and the notification email of the person the key belongs to: what they were told, and what they want to be told and how.

GET/notificationsread

Notifications for the person behind the key

The notices in the bell of the person who created the key (or approved the OAuth grant it came from), newest first, with their unread count. Notifications belong to a person, so a key made without one gets no_key_user.

A row that collected repeats while unread (12 new replies) has count above 1 and an updated_at that moved forward. topic is the preference topic the notice falls under. Send X-Workspace-Id: all for every workspace the key reaches that the person belongs to.

Topics the person turned off for the bell are not listed, even though they may still be emailed.

link is a path in the dashboard; prefix it with https://www.emailpal.io.

unread

booleandefaults to false

Only unread notices.
topic

string

Only notices of one topic.positive_replyrepliesnew_inbox_mailsendingbillingcampaignspaldata
limit

integer1–50defaults to 30

How many to return.
cursor

string

The next_cursor from the previous page (an updated_at timestamp).
200
Always, for a key with a person behind it.
409no_key_user
The key was not created by a person (no creator and no OAuth grant), so it has no notifications.
403not_a_member
The key’s person is no longer a member of the workspace.
Request
GET https://www.emailpal.io/api/public/v1/notifications
Response
{
  "data": [
    {
      "id": "8a1f0c2e-5d43-4f7a-9b61-2e0f3c4d5a6b",
      "workspace_id": "acc_7f3d21",
      "kind": "positive_reply",
      "topic": "positive_reply",
      "title": "Positive reply from Dana at Halden Tools",
      "body": "Sounds interesting, can we talk Thursday?",
      "link": "/unibox/3c2b1a09-8f7e-4d6c-b5a4-0e9f8d7c6b5a",
      "severity": "success",
      "count": 1,
      "read_at": null,
      "created_at": "2026-10-21T09:14:02.000Z",
      "updated_at": "2026-10-21T09:14:02.000Z"
    }
  ],
  "unread_count": 3,
  "next_cursor": null
}
POST/notifications/readwrite

Mark notifications read

Marks the given notices, or every unread one, read for the person behind the key, the same as opening them in the bell. A notice read before its email went out is not emailed. Accepts X-Workspace-Id: all.

ids

string[]1–100 items

Notification ids to mark read.
all

boolean

Send true instead of ids to mark everything read.
200
The notices are read. updated counts the ones that were unread.
409no_key_user
The key was not created by a person (no creator and no OAuth grant), so it has no notifications.
Request
POST https://www.emailpal.io/api/public/v1/notifications/read
Body
{ "ids": ["8a1f0c2e-5d43-4f7a-9b61-2e0f3c4d5a6b"] }
Response
{ "updated": 1 }
GET/notification-preferencesread

Notification preferences

What the person behind the key gets in the bell and by email, the same settings as Settings, Notifications in the dashboard. Preferences belong to the person and apply to every workspace; workspaces lists the ones this key reaches and whether each is muted (no email from it).

Each topic has in_app (shown in the bell) and email: off, instant (on the next pass, within about two minutes) or batched (one email collecting everything from the last stretch, at most hourly). Sending and billing email only warnings and worse (email_min_severity). The daily digest goes out at digest.hour in effective_timezone: the person’s own timezone, else their first workspace’s, else UTC.

200
Always, for a key with a person behind it.
409no_key_user
The key was not created by a person (no creator and no OAuth grant), so it has no notifications.
Request
GET https://www.emailpal.io/api/public/v1/notification-preferences
Response
{
  "email": "jo@acme.com",
  "sign_in_email": "jo@acme.com",
  "email_override": null,
  "timezone": "Europe/London",
  "effective_timezone": "Europe/London",
  "digest": { "enabled": true, "hour": 8 },
  "paused_until": null,
  "topics": [
    {
      "topic": "positive_reply",
      "label": "Positive replies",
      "description": "A lead answered with interest.",
      "in_app": true,
      "email": "instant",
      "email_default": "instant",
      "email_min_severity": null
    },
    {
      "topic": "replies",
      "label": "Campaign replies",
      "description": "New replies to your campaigns, counted per campaign.",
      "in_app": true,
      "email": "batched",
      "email_default": "batched",
      "email_min_severity": null
    }
  ],
  "workspaces": [
    { "workspace_id": "acc_7f3d21", "name": "Acme", "muted": false },
    { "workspace_id": "acc_2c90ab", "name": "Client: Halden Tools", "muted": true }
  ]
}
PUT/notification-preferenceswrite

Change notification preferences

Changes any part of the preferences and returns all of them. Send only what changes: a topic entry with only email keeps its in_app. Muting a workspace turns off its email and leaves its bell as it was; only workspaces this key reaches can be muted.

topics

object[]

{ topic, in_app?, email? }. topic is one of positive_reply, replies, new_inbox_mail, sending, billing, campaigns, pal, data; email is one of off, instant, batched.
workspaces

object[]

{ workspace_id, muted }.
digest

object

{ enabled?, hour? }, hour 0 to 23 in the person’s timezone.
timezone

string

An IANA name such as Europe/London, or null to follow the workspace.
email_override

string

Send notification email here instead of the sign-in address. null to undo.
paused_until

string

ISO time, at most 90 days ahead: no notification email until then. null to resume.
200
Saved. The body is the full preferences, as from GET.
409no_key_user
The key was not created by a person (no creator and no OAuth grant), so it has no notifications.
400invalid_request
An unknown topic or mode, a bad timezone, or a workspace the key cannot reach.
Request
PUT https://www.emailpal.io/api/public/v1/notification-preferences
Body
{
  "topics": [{ "topic": "replies", "email": "instant" }],
  "workspaces": [{ "workspace_id": "acc_2c90ab", "muted": true }],
  "digest": { "hour": 7 }
}
Response
{
  "email": "jo@acme.com",
  "sign_in_email": "jo@acme.com",
  "email_override": null,
  "timezone": "Europe/London",
  "effective_timezone": "Europe/London",
  "digest": { "enabled": true, "hour": 8 },
  "paused_until": null,
  "topics": [
    {
      "topic": "positive_reply",
      "label": "Positive replies",
      "description": "A lead answered with interest.",
      "in_app": true,
      "email": "instant",
      "email_default": "instant",
      "email_min_severity": null
    },
    {
      "topic": "replies",
      "label": "Campaign replies",
      "description": "New replies to your campaigns, counted per campaign.",
      "in_app": true,
      "email": "batched",
      "email_default": "batched",
      "email_min_severity": null
    }
  ],
  "workspaces": [
    { "workspace_id": "acc_7f3d21", "name": "Acme", "muted": false },
    { "workspace_id": "acc_2c90ab", "name": "Client: Halden Tools", "muted": true }
  ]
}

Compliance

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.

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.
200
Always.
400invalid_address
address is not a valid email address.
500read_failed
The query failed.
Request
GET https://www.emailpal.io/api/public/v1/suppressions
Response
{
  "address": "priya@acme.example",
  "suppressed": true,
  "reason": "Recipient unsubscribed.",
  "source": "unsubscribe",
  "scope": "address",
  "suppressed_at": "2026-07-11T08:02:00.000Z"
}
POST/suppressionswrite

Suppress an address or a domain

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.

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.
201
Newly suppressed.
200
Already suppressed. The body carries already_suppressed: true.
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/lead-records/{id}/reportwrite

Report a Prospects lead record

Tell us a record from Prospects, the lead search in the dashboard, is wrong, that the person asked not to be contacted, or that it bounced. One record per call; there is no bulk form.

The record is hidden for your account straight away and never shown to it again. It is hidden for other accounts only after review: when the person confirms, a hard bounce is confirmed, or enough unrelated accounts report the same person. Reports are rate limited per account, and an account whose reports are mostly rejected is flagged for review.

This is the only endpoint in the API for Prospects records. Searching people and companies and revealing business emails are part of the unlimited free leads included with every paid plan, but they are dashboard-only: there is no public search or reveal endpoint and no MCP tool for either.

{id} is the record id (lr_…) the Prospects table shows. It is our own id and says nothing about the provider.

reason

stringrequired

Why the record is being reported.wrong_dataasked_to_be_removedbounced
200
Reported. Repeating the same report is also 200.
404record_not_found
The id is not a lead-table record id.
429report_quota_exceeded
The account is sending reports too fast. Wait and retry.
Request
POST https://www.emailpal.io/api/public/v1/lead-records/{id}/report
Body
{ "reason": "wrong_data" }
Response
{
  "id": "lr_3f9a1c07d2b84e55a6c0b1d9e4f27a38c15d0e62",
  "reported": true,
  "hidden_for_account": true,
  "note": "The record is hidden for this account. Reports are reviewed before they affect anyone else."
}
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.

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.
202
Addresses were extracted, credits were taken, and verification was queued.
400no_addresses
Nothing in the content parsed as an email address.
402subscription_required
The account has no active subscription and is not complimentary, and has no operator verification grant. Carries needsSubscription: true. Nothing was queued and no credits were taken.
402verification_capacity_required
Subscribed, but the 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
Body
{
  "filename": "q3-prospects.csv",
  "content": "email\npriya@acme.example\nsam@example.org"
}
Response
{
  "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 }
}
GET/lists/{id}/resultsread

Per-address results, as they are decided

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.

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.
200
The upload exists on this account, including while it is still verifying.
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
Response
{
  "data": [
    {
      "email": "priya@acme.example",
      "status": "safe",
      "sub_status": null,
      "domain": "acme.example",
      "checked_at": "2026-07-25T15:21:44.000Z"
    },
    {
      "email": "sam@example.org",
      "status": "invalid",
      "sub_status": null,
      "domain": "example.org",
      "checked_at": "2026-07-25T15:21:44.000Z"
    }
  ],
  "has_more": true,
  "next_cursor": "2026-07-25T15:21:44.118Z|a3f0c7d2-1e44-4c8e-9d20-6b1f2c9a77e5",
  "list": {
    "id": "lst_7c31ee",
    "status": "verifying",
    "finished": false,
    "total": 2140
  }
}
GET/lists/{id}/results/exportread

Download a finished list’s answers as CSV

The answers for a finished list as one CSV file, filtered to the results you choose: email,result,reason,list,checked_at. This is what the "Use results" download on the Lists page produces, and it is logged in the same export history.

Any result can be downloaded, including the removed ones (invalid, disabled, disposable, inbox_full, suppressed) — cleaning those out of your own CRM is what that selection is for. Add more lists with combine and each address is written once, under the first answer it got.

Streamed, so a list of a quarter of a million addresses downloads without waiting for the whole file to be built.

Cells that begin with =, +, - or @ are written with a leading ' so a spreadsheet shows them as text rather than running them as formulas.

Only the address and its answer are stored, so the file has no other columns from your original upload.

statuses

stringrequired

Comma-separated results to include, e.g. safe,role_account. Any of safe, role_account, catch_all, unknown, invalid, disabled, disposable, inbox_full, suppressed. duplicate is never exported.
combine

string

More list ids, comma-separated (20 lists in all at most), merged into one de-duplicated file.
200
A text/csv attachment, possibly just the header row when nothing matches.
400empty_selection
statuses names no exportable result.
400invalid_request
The id or combine is not a list id, or there are more than 20.
404not_found
One of the lists is not on this account.
409still_checking
A list is still being checked. Use GET /lists/{id}/results to read answers as they arrive.
409results_expired
A list is older than 90 days and its per-address results have been deleted.
Request
GET https://www.emailpal.io/api/public/v1/lists/{id}/results/export
Response
email,result,reason,list,checked_at
priya@acme.example,safe,,q3-prospects.csv,2026-07-25 15:21:44.118204+00
sales@acme.example,role_account,,q3-prospects.csv,2026-07-25 15:21:44.118204+00
POST/lists/results/usewrite

Save a list’s answers to a contact list or a campaign

Turns the chosen results of one or more finished lists into contacts: a new contact list, an existing one, or a campaign (the enrolled leads also get a contact list of their own). No verification credits are spent; the answers already exist, and each address’s answer is stored on its contact (safe and role_account as valid, then catch_all and unknown).

Only sendable results can be saved: safe, role_account, catch_all, unknown. A selection whose estimated bounce rate is 8 % or more (catch-all and unknown addresses count 15 % each) is refused — download it with GET /lists/{id}/results/export instead, or narrow it.

Contacts are added exactly as an import adds them: blocklisted, suppressed, unsubscribed and previously bounced addresses are skipped, existing contacts keep what they have and only gain blanks, and campaign leads start pending for the scheduler. Asynchronous: poll GET /lists/results/use/{jobId}.

An address in several lists is saved once, under the first answer it got.

Campaign leads whose answer is catch_all or unknown are enrolled but held at send time unless the campaign allows risky or unknown addresses; held_by_settings counts them.

upload_ids

string[]required1–20 items

The lists, from POST /lists/verify.
statuses

string[]requiredmin 1 items

Results to save: any of safe, role_account, catch_all, unknown.
destination

objectrequired

{ "type": "new_list", "name" }, { "type": "list", "list_id" } or { "type": "campaign", "campaign_id", "list_name"? }. A new list whose name is taken gets a numbered suffix.
attached

object

Columns from your original file, since only addresses are stored: { "fields": [...], "rows": { "<email>": { "first_name": "Priya", "custom.Tier": "A" } } }. Fields: first_name, last_name, company, title, phone, website, linkedin_url, city, state, country, and custom.<name>.
202
The selection passed the checks and the work was queued.
400invalid_request
The body is malformed.
400empty_selection
Nothing in the lists matches those results.
400sync_blocked_removed
The results include a removed one (invalid, disabled, disposable, inbox_full, suppressed).
403plan_required
Contact lists and campaigns are not enabled on this account.
404not_found
A list, the contact list or the campaign is not on this account.
409sync_blocked_bounce
The selection’s estimated bounce rate is 8 % or more.
409still_checking
A list is still being checked.
409results_expired
A list is older than 90 days and its per-address results have been deleted.
Request
POST https://www.emailpal.io/api/public/v1/lists/results/use
Body
{
  "upload_ids": ["lst_7c31ee"],
  "statuses": ["safe", "role_account"],
  "destination": { "type": "campaign", "campaign_id": "cmp_41aa02" }
}
Response
{
  "job_id": "job_6j40wl",
  "count": 1563,
  "poll": "/api/public/v1/lists/results/use/job_6j40wl"
}
GET/lists/results/use/{jobId}read

Progress of a save to a contact list or campaign

Poll after POST /lists/results/use until state is done or failed. The counts fill in as the work runs. skipped_by_reason keys: blocklisted, suppressed, unsubscribed, bounced, invalid (the contact holds a newer failed answer), already_in_list, already_in_campaign.

enrolled and held_by_settings are null unless the destination is a campaign.

200
The job is a save on this account.
404not_found
No such job on this account, or the job is not a save.
Request
GET https://www.emailpal.io/api/public/v1/lists/results/use/{jobId}
Response
{
  "state": "done",
  "added": 1541,
  "skipped": 22,
  "skipped_by_reason": { "unsubscribed": 15, "already_in_campaign": 7 },
  "enrolled": 1541,
  "held_by_settings": 0,
  "list_id": "cl_19be70",
  "campaign_id": "cmp_41aa02",
  "error": null
}
GET/lists/{id}read

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 exceeds the bounce threshold — sending it would hurt deliverability for the whole 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.

200
The upload exists on this account, including while it is still verifying.
404not_found
No such upload on this account.
Request
GET https://www.emailpal.io/api/public/v1/lists/{id}
Response
{
  "id": "lst_7c31ee",
  "filename": "q3-prospects.csv",
  "status": "flagged",
  "sendable": true,
  "depth": "probed",
  "progress": {
    "answered": 2116,
    "remaining": 0,
    "results_url": "/api/public/v1/lists/lst_7c31ee/results"
  },
  "counts": {
    "total": 2140,
    "safe": 1502,
    "catch_all": 218,
    "role_account": 61,
    "disposable": 9,
    "inbox_full": 14,
    "disabled": 37,
    "invalid": 214,
    "unknown": 49,
    "suppressed": 12,
    "duplicate": 24,
    "risky": 288
  },
  "blocked_reason": null,
  "verified_at": "2026-07-25T15:22:41.000Z",
  "created_at": "2026-07-25T15:21:03.000Z"
}
POST/verifywrite

Check one address right now

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 once the account has an active subscription (or is on complimentary access); 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.

email

stringrequiredmax 320 characters

The address to check.
200
Always — an undeliverable address is a result, not an error.
402subscription_required
The account has no active subscription and is not complimentary, and has no operator verification grant. Carries needsSubscription: true. Nothing was probed and no credit was taken.
402verification_capacity_required
Subscribed, but the 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.
Request
POST https://www.emailpal.io/api/public/v1/verify
Body
{ "email": "priya@acme.example" }
Response
{
  "email": "priya@acme.example",
  "status": "safe",
  "sub_status": null,
  "domain": "acme.example",
  "mx_host": "mx1.acme.example",
  "checked_at": "2026-08-07T09:12:44.000Z",
  "credits": { "used": 214, "limit": 1500 }
}