Every endpoint the API serves — 162 of them — with what each one needs and the exact body it accepts. Generated from the running server, so it describes the API you are calling rather than the one somebody remembered to document.
Account
PATCH/v1/accountdashboard session only
Rename the account
Requires a signed-in person with the **admin** role or higher — rename the account. NOT reachable with an API key: this is an action where a leaked server-side credential would be catastrophic.
| Field | Type | Notes |
|---|
| name | string required | |
GET/v1/account/deletiondashboard session only
Status of a deletion request
Requires a signed-in session, acting on your own record — see whether a deletion is scheduled. Deliberately not role-gated, because the row being changed is the caller’s own.
POST/v1/account/deletiondashboard session only
Request account deletion
Right to erasure. Scheduled rather than immediate, so it can be cancelled.
Requires a signed-in person with the **owner** role or higher — delete the account. NOT reachable with an API key: this is an action where a leaked server-side credential would be catastrophic.
DELETE/v1/account/deletiondashboard session only
Cancel a deletion request
Requires a signed-in person with the **owner** role or higher — cancel a scheduled deletion. NOT reachable with an API key: this is an action where a leaked server-side credential would be catastrophic.
GET/v1/account/verifyaccount:read
Whether the delivery record is intact
The same verdict as `/v1/audit/verify`, in the older, narrower shape: `intact`, `brokenAt`, `problem`. Reachable with `account:read` because it returns no part of the log itself.
Requires an API key with `account:read`, or a signed-in session.
GET/v1/meaccount:read
Your account, plan and current sending allowance
Requires an API key with `account:read`, or a signed-in session.
PATCH/v1/profiledashboard session only
Change your own name
Requires a signed-in session, acting on your own record — set your own name. Deliberately not role-gated, because the row being changed is the caller’s own.
| Field | Type | Notes |
|---|
| name | string optional | |
| displayName | string optional | |
| timezone | string optional | |
| signature | string optional | |
| signatureAddresses | string[] | null optional | |
GET/v1/usageaccount:read
Current usage against your allowance
Requires an API key with `account:read`, or a signed-in session.
Alerts
GET/v1/alerts/eventsalerts:read
What has fired, and where it went
Newest first, keyset-paginated. Each row carries the value and the threshold AS THEY STOOD, not as they are now, plus which channels the notice was delivered on — so "you never told us" has an answer that survives somebody editing the rule afterwards.
Requires an API key with `alerts:read`, or a signed-in session.
GET/v1/alerts/rulesalerts:read
List alert rules
Every threshold on the account, with the live firing state and the last measurement. Also returns the vocabulary — the available kinds, their units and their defaults — so a client never has to hard-code them.
Requires an API key with `alerts:read`, or a signed-in session.
POST/v1/alerts/rulesalerts:write
Create an alert rule
With a body, creates one rule. With NO body, creates the default set for every kind — which is the "switch alerting on" call, because picking four thresholds before you have ever seen one fire is guesswork.
One rule per kind: a second one answers 409. Two rules watching one signal would mean two emails about it, which is the failure alerting exists to avoid.
`threshold` is a fraction for the rate kinds (`0.003` is 0.3%) and a count for `dns_drift`. It is refused above the point at which the account would be suspended for the same numbers, since an alert that can only arrive after the suspension is not an alert.
Requires an API key with `alerts:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| kind | "complaint_rate" | "bounce_rate" | "quota_usage" | "dns_drift" | "blocklist" required | |
| threshold | number required | |
| windowDays | integer optional | |
| cooldownMinutes | integer optional | |
| notifyEmail | boolean optional | |
| notifyWebhook | boolean optional | |
| enabled | boolean optional | |
PATCH/v1/alerts/rules/{id}alerts:write
Change a threshold, a channel, or switch a rule off
Editing a rule never re-arms it. An alert that is currently firing stays firing, and raising the threshold while it is open is not a way to be told about it a second time.
Requires an API key with `alerts:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| threshold | number optional | |
| windowDays | integer optional | |
| cooldownMinutes | integer optional | |
| notifyEmail | boolean optional | |
| notifyWebhook | boolean optional | |
| enabled | boolean optional | |
DELETE/v1/alerts/rules/{id}alerts:write
Stop watching a signal
Requires an API key with `alerts:write`, or a signed-in session.
Audit
GET/v1/audit/eventsmessages:read
Page the account audit log
The append-only, hash-chained record of everything that has happened to this account’s mail, newest first. Each entry carries its own `hash` and `prevHash`, so a single row can be checked without downloading the whole log.
Deliberately does NOT verify the chain: verification replays the entire history, and doing that per page would make paging cost more the longer you have been a customer. Use `/v1/audit/verify` for the verdict.
Requires an API key with `messages:read`, or a signed-in session.
| Parameter | Type | Notes |
|---|
| limit | integer optional | |
| before | integer optional | |
| type | string optional | |
| from | string optional | |
| until | string optional | |
GET/v1/audit/exportmessages:read
Export the audit log as a file
Streams the whole chain as newline-delimited JSON, oldest first, with a header record describing how the hash is constructed and a trailer record carrying the event count and the verdict.
`payload` is the exact text the database hashed and `occurredAt` the exact timestamp string. Hash them as given — re-serialising the parsed JSON changes the bytes and fails verification on an untouched record.
A file with no trailer record is INCOMPLETE. The response status is sent before the last row is read, so a failure mid-transfer can only truncate the download; the trailer is how you tell a whole export from half of one.
Requires an API key with `messages:read`, or a signed-in session.
| Parameter | Type | Notes |
|---|
| from | string optional | |
| until | string optional | |
GET/v1/audit/verifymessages:read
Prove the audit chain is intact
Replays every event on the account, in order, and reports the first break by sequence number. Answers 200 whether or not the record holds — a broken chain is evidence, not a server fault, and returning 5xx would make it look like a transient error worth retrying.
`purgedThroughSeq` is non-zero when the oldest events have aged out of your plan’s retention window through the audited purge path. That is why a chain may legitimately start above 1.
Requires an API key with `messages:read`, or a signed-in session.
Auth
POST/v1/auth/2fa/disabledashboard session only
Disable TOTP
Requires a signed-in session, acting on your own record — remove your own authenticator. Deliberately not role-gated, because the row being changed is the caller’s own.
POST/v1/auth/2fa/enabledashboard session only
Enable TOTP
Requires a signed-in session, acting on your own record — enrol your own authenticator. Deliberately not role-gated, because the row being changed is the caller’s own.
POST/v1/auth/2fa/setupdashboard session only
Begin TOTP enrolment
Requires a signed-in session, acting on your own record — enrol your own authenticator. Deliberately not role-gated, because the row being changed is the caller’s own.
POST/v1/auth/email/changedashboard session only
Ask to change the address you sign in with
Requires the current password. A confirmation link goes to the NEW address and a notice to the old one; nothing moves until the link is followed. The answer is the same whether or not the address is already in use, so this cannot be used to enumerate accounts.
Requires a signed-in session, acting on your own record — change your own sign-in address. Deliberately not role-gated, because the row being changed is the caller’s own.
POST/v1/auth/email/change/confirm
Confirm a new sign-in address
Followed from the new mailbox, which is the proof. The address is marked verified by construction, and the pending change is re-read at this point rather than carried in the link.
No authentication: is followed from the new mailbox, which is the proof itself.
GET/v1/auth/google/callback
Google sign-in callback
No authentication: is how a session is obtained.
POST/v1/auth/google/mobile/exchange
Redeem a mobile Google sign-in code for a session
The native flow returns a short-lived signed code to the app by deep link, never a session token. Posting it here with the verifier it was bound to is what creates the session.
No authentication: is how a session is obtained.
GET/v1/auth/google/nonce
Nonce for Google One Tap
No authentication: is how a session is obtained.
POST/v1/auth/google/one-tap
Sign in with a Google One Tap credential
No authentication: is how a session is obtained.
GET/v1/auth/google/start
Begin Google sign-in
No authentication: is how a session is obtained.
POST/v1/auth/google/totp
Finish a Google sign-in with a TOTP code
An account with an authenticator app enrolled is not signed in by Google alone: the Google flow answers `totp_required` with a short-lived ticket, and posting that ticket with a current code creates the session.
No authentication: is how a session is obtained after a Google sign-in that owes a TOTP code.
POST/v1/auth/login
Sign in
No authentication: is how a session is obtained.
| Field | Type | Notes |
|---|
| email | string required | |
| password | string required | |
| code | string optional | |
POST/v1/auth/logout
Sign out
No authentication: must work even with a dead session.
GET/v1/auth/me
Who am I
How the dashboard learns its auth state on boot; no token is readable from JavaScript.
No authentication: answers "am I signed in" for the dashboard on boot.
GET/v1/auth/passkeysdashboard session only
List your registered passkeys
Requires a signed-in session, acting on your own record — list your own passkeys. Deliberately not role-gated, because the row being changed is the caller’s own.
DELETE/v1/auth/passkeys/{id}dashboard session only
Remove a passkey
Requires a signed-in session, acting on your own record — remove your own passkey. Deliberately not role-gated, because the row being changed is the caller’s own.
POST/v1/auth/passkeys/login/options
Begin passkey sign-in
No authentication: is how a session is obtained.
POST/v1/auth/passkeys/login/verify
Complete passkey sign-in
No authentication: is how a session is obtained.
POST/v1/auth/passkeys/register/optionsdashboard session only
Begin registering a passkey
Requires a signed-in session, acting on your own record — add your own passkey. Deliberately not role-gated, because the row being changed is the caller’s own.
POST/v1/auth/passkeys/register/verifydashboard session only
Finish registering a passkey
Requires a signed-in session, acting on your own record — add your own passkey. Deliberately not role-gated, because the row being changed is the caller’s own.
POST/v1/auth/password/changedashboard session only
Change your password
Requires a signed-in session, acting on your own record — change your own password. Deliberately not role-gated, because the row being changed is the caller’s own.
POST/v1/auth/password/forgot
Request a password reset
Answers the same way whether or not the address is known, so it cannot be used to test which addresses exist.
No authentication: is reached by somebody who has by definition failed to sign in.
POST/v1/auth/password/reset
Set a new password with a reset token
No header credential. A single-use token in the request body IS the credential, and it is reached from a mailbox.
GET/v1/auth/sessionsdashboard session only
List your active sessions
Requires a signed-in session, acting on your own record — list your own sessions. Deliberately not role-gated, because the row being changed is the caller’s own.
POST/v1/auth/sessions/revoke-othersdashboard session only
Sign out everywhere else
Requires a signed-in session, acting on your own record — sign your other devices out. Deliberately not role-gated, because the row being changed is the caller’s own.
POST/v1/auth/signup
Create an account
No authentication: creates the first account.
| Field | Type | Notes |
|---|
| email | string required | |
| password | string required | |
| accountName | string optional | |
| turnstileToken | string optional | |
POST/v1/auth/verify-email
Verify an email address
No header credential. A single-use token in the request body IS the credential, and it is reached from a mailbox.
POST/v1/auth/verify-email/senddashboard session only
Resend the verification email
Requires a signed-in session, acting on your own record — resend your own confirmation. Deliberately not role-gated, because the row being changed is the caller’s own.
Billing
GET/v1/billingbilling:read or account:read
Subscription and plan
Requires an API key with `billing:read` or `account:read`, or a signed-in session.
POST/v1/billing/{provider}/webhook
Payment provider webhook
Authenticated by the provider signature over the RAW body, so it cannot be re-signed from a parsed copy.
No header credential. The caller is a payment or mail provider and is authenticated by its signature over the RAW request body — so this operation cannot be re-signed from a parsed copy.
POST/v1/billing/canceldashboard session only
Cancel the subscription at period end
Requires a signed-in person with the **admin** role or higher — cancel the plan. NOT reachable with an API key: this is an action where a leaked server-side credential would be catastrophic.
POST/v1/billing/checkoutdashboard session only
Start a checkout
Requires a signed-in person with the **admin** role or higher — start a checkout. NOT reachable with an API key: this is an action where a leaked server-side credential would be catastrophic.
| Field | Type | Notes |
|---|
| plan | string required | |
| interval | "month" | "year" optional | |
| profile | object required | |
POST/v1/billing/checkout/abandondashboard session only
Abandon a checkout in progress
Requires a signed-in person with the **admin** role or higher — abandon a checkout. NOT reachable with an API key: this is an action where a leaked server-side credential would be catastrophic.
GET/v1/billing/historybilling:read or account:read
Billing history
Requires an API key with `billing:read` or `account:read`, or a signed-in session.
GET/v1/billing/invoicesbilling:read or account:read
List invoices
Requires an API key with `billing:read` or `account:read` (checked via `canReadBilling`), or a signed-in session.
GET/v1/billing/invoices/{id}billing:read or account:read
Download an invoice
Requires an API key with `billing:read` or `account:read` (checked via `canReadBilling`), or a signed-in session.
POST/v1/billing/pausedashboard session only
Pause the subscription
Requires a signed-in person with the **admin** role or higher — pause the plan. NOT reachable with an API key: this is an action where a leaked server-side credential would be catastrophic.
PATCH/v1/billing/profiledashboard session only
Set the legal name and tax id printed on invoices
Requires a signed-in person with the **admin** role or higher — change the billing details. NOT reachable with an API key: this is an action where a leaked server-side credential would be catastrophic.
POST/v1/billing/resumedashboard session only
Resume a paused subscription
Requires a signed-in person with the **admin** role or higher — resume the plan. NOT reachable with an API key: this is an action where a leaked server-side credential would be catastrophic.
POST/v1/webhooks/payu/failed
PayU webhook (Failed events)
An alias for the provider webhook, at the URL PayU delivers Failed events to. Identical handler and identical verification; the outcome is read from the signed body, never from this path.
No header credential. The caller is a payment or mail provider and is authenticated by its signature over the RAW request body — so this operation cannot be re-signed from a parsed copy.
POST/v1/webhooks/payu/refund
PayU webhook (Refund events)
An alias for the provider webhook, at the URL PayU delivers Refund events to. PayU sends refund notifications as unsigned JSON, so they are recorded and never allowed to move money on their own.
No header credential. The caller is a payment or mail provider and is authenticated by its signature over the RAW request body — so this operation cannot be re-signed from a parsed copy.
POST/v1/webhooks/payu/success
PayU webhook (Successful events)
An alias for the provider webhook, at the URL PayU delivers Successful events to — PayU registers one URL per event type. Identical handler and identical verification; the outcome is read from the signed body, never from this path.
No header credential. The caller is a payment or mail provider and is authenticated by its signature over the RAW request body — so this operation cannot be re-signed from a parsed copy.
Broadcasts
GET/v1/broadcastsbroadcasts:read
List broadcasts
Requires an API key with `broadcasts:read`, or a signed-in session.
| Parameter | Type | Notes |
|---|
| limit | integer optional | |
| before | string optional | |
POST/v1/broadcastsbroadcasts:write
Create a broadcast draft
One message to every contact on a list, for operational mail to your own users. Give a template, or a subject with html or text; `{{email}}`, `{{name}}` and any contact field can be merged. Sent on the `announcements` stream unless you name another — never the transactional one. Nothing is sent until `/send`.
Requires an API key with `broadcasts:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| name | string required | |
| list | string required | |
| stream | string optional | |
| from | string required | |
| replyTo | string optional | |
| template | string optional | |
| subject | string optional | |
| html | string optional | |
| text | string optional | |
GET/v1/broadcasts/{id}broadcasts:read
Fetch a broadcast, its progress and delivery
Requires an API key with `broadcasts:read`, or a signed-in session.
PATCH/v1/broadcasts/{id}broadcasts:write
Edit a draft broadcast
Requires an API key with `broadcasts:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| name | string optional | |
| list | string optional | |
| stream | string optional | |
| from | string optional | |
| replyTo | string optional | |
| template | string optional | |
| subject | string optional | |
| html | string optional | |
| text | string optional | |
DELETE/v1/broadcasts/{id}broadcasts:write
Delete a draft broadcast
A broadcast that sent anything keeps its record; cancel it instead.
Requires an API key with `broadcasts:write`, or a signed-in session.
POST/v1/broadcasts/{id}/cancelbroadcasts:write
Cancel a broadcast
Nothing further is sent. Mail already sent stays sent.
Requires an API key with `broadcasts:write`, or a signed-in session.
POST/v1/broadcasts/{id}/pausebroadcasts:write
Pause a broadcast
Requires an API key with `broadcasts:write`, or a signed-in session.
POST/v1/broadcasts/{id}/resumebroadcasts:write
Resume a paused broadcast
Refused when the pause was for high bounce or complaint rates.
Requires an API key with `broadcasts:write`, or a signed-in session.
POST/v1/broadcasts/{id}/sendbroadcasts:write
Send a broadcast now or later
Every recipient goes through the same checks as a single send — suppressions, your daily and monthly allowance, content lint. Each message carries a one-click unsubscribe header and a visible unsubscribe link. Reaching the daily cap pauses the send until the next UTC day; high bounce or complaint rates stop it for good. An account’s first broadcast to more than 1,000 people is reviewed by us before it starts.
Requires an API key with `broadcasts:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| scheduledAt | string optional | |
POST/v1/broadcasts/{id}/testbroadcasts:write
Send a test to your team
Up to five addresses, each of a person on this account’s team. Merge fields come from the matching contact when there is one.
Requires an API key with `broadcasts:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| to | string[] required | |
GET/v1/contactscontacts:read
List contacts
Newest first. Each contact says whether it is suppressed and why — that answer comes from the suppression list, so an unsubscribe shows here the moment it happens.
Requires an API key with `contacts:read`, or a signed-in session.
| Parameter | Type | Notes |
|---|
| limit | integer optional | |
| before | string optional | |
| search | string optional | |
| list | string optional | |
POST/v1/contactscontacts:write
Add a contact
For operational mail to your own users. `consent` records where permission to mail this person came from, and is required. An address already in your contacts is a 409 carrying its id.
Requires an API key with `contacts:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| email | string required | |
| name | string optional | |
| fields | object optional | |
| consent | "signed_up" | "customer" | "imported_with_consent" required | |
| lists | string[] optional | |
GET/v1/contacts/{id}contacts:read
Fetch a contact and its lists
Requires an API key with `contacts:read`, or a signed-in session.
PATCH/v1/contacts/{id}contacts:write
Update a contact
Requires an API key with `contacts:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| name | string | null optional | |
| fields | object optional | |
DELETE/v1/contacts/{id}contacts:write
Delete a contact
Removes the contact from every list. It does not remove a suppression: re-adding somebody who unsubscribed does not make them mailable again.
Requires an API key with `contacts:write`, or a signed-in session.
POST/v1/contacts/importcontacts:write
Import contacts
Up to 1,000 rows per call, merged by address: an existing contact is updated rather than duplicated. Bad rows are reported by index instead of failing the batch, and rows past your plan’s contact limit are counted in `overLimit`.
Requires an API key with `contacts:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| contacts | object[] required | |
| consent | "signed_up" | "customer" | "imported_with_consent" required | |
| list | string optional | |
GET/v1/listscontacts:read
List contact lists
Requires an API key with `contacts:read`, or a signed-in session.
POST/v1/listscontacts:write
Create a contact list
Requires an API key with `contacts:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| name | string required | |
| description | string optional | |
GET/v1/lists/{id}contacts:read
Fetch a contact list
Requires an API key with `contacts:read`, or a signed-in session.
PATCH/v1/lists/{id}contacts:write
Rename a contact list
Requires an API key with `contacts:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| name | string optional | |
| description | string | null optional | |
DELETE/v1/lists/{id}contacts:write
Delete a contact list
The contacts on it stay in your contacts.
Requires an API key with `contacts:write`, or a signed-in session.
POST/v1/lists/{id}/memberscontacts:write
Add contacts to a list
Idempotent. Ids that match no contact are returned in `notFound`.
Requires an API key with `contacts:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| contacts | string[] required | |
DELETE/v1/lists/{id}/members/{contactId}contacts:write
Remove a contact from a list
Requires an API key with `contacts:write`, or a signed-in session.
Deliverability
GET/v1/analytics/deliveryanalytics:read
Delivery rate and time to inbox, by receiving domain or by tag
Answers "is Outlook slower than Gmail for our mail". Returns the delivery rate and the p50/p95 time to inbox for each receiving domain, or for each of your own tags, over a window you choose.
Time to inbox is measured between two events on the message: `accepted`, when it entered the delivery queue, and `delivered`, when the receiving mail server accepted it. It does not include what that server does afterwards, which no sender can observe.
A rate with nothing settled behind it and a percentile with no delivered message behind it are both `null`, never `0`. Every row carries the number of messages its percentiles were computed from.
Requires an API key with `analytics:read`, or a signed-in session.
| Parameter | Type | Notes |
|---|
| days | integer optional | |
| groupBy | string optional | |
| stream | string optional | |
| tag | string optional | |
| limit | integer optional | |
| cursor | string optional | |
GET/v1/analytics/dmarcanalytics:read
Who is sending as your domains, and whether it aligns
Summarises the DMARC aggregate reports receiving providers send back about your domains, grouped by the IP that sent the mail and ranked by volume.
`messages` is the volume the reporters accounted for, summed from each report row's own count — not a number of rows, since a single row can stand for millions of messages. A message counts as aligned when SPF **or** DKIM aligned with the From domain, which is what DMARC itself requires; a mechanism that passed for some unrelated domain does not count.
A source with volume and no alignment is either a legitimate sender you have forgotten about or somebody spoofing you. The two are indistinguishable from here, so this reports what the receivers observed and leaves that judgement to you.
`alignmentRate` is `null` rather than `0` when no reports have arrived yet, because "nothing reported" and "everything failed" are opposite situations.
Requires an API key with `analytics:read`, or a signed-in session.
| Parameter | Type | Notes |
|---|
| days | integer optional | |
| domain | string optional | |
Inbound
GET/v1/inbound/addressesinbound:read or domains:read
List inbound addresses
Requires an API key with `inbound:read` or `domains:read`, or a signed-in session.
POST/v1/inbound/addressesinbound:write or domains:write
Create an inbound address
Requires an API key with `inbound:write` or `domains:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| address | string required | |
| label | string optional | |
GET/v1/inbound/addresses/{id}inbound:read or domains:read
Fetch an inbound address
Carries the unread count, which the list deliberately does not.
Requires an API key with `inbound:read` or `domains:read`, or a signed-in session.
PATCH/v1/inbound/addresses/{id}inbound:write or domains:write
Update an inbound address
The label, and whether it accepts mail. Disabling is the reversible half of deletion: the address is refused at RCPT while everything it has already received stays where it is. The address itself cannot be changed — that is a delete and a create.
Requires an API key with `inbound:write` or `domains:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| label | string | null optional | |
| enabled | boolean optional | |
DELETE/v1/inbound/addresses/{id}inbound:write or domains:write
Delete an inbound address
Requires an API key with `inbound:write` or `domains:write`, or a signed-in session.
GET/v1/inbound/domainsinbound:read or domains:read
List domains and whether their mail reaches us
The receiving view of the domains on the account. Carries the MX record to publish, which the sending setup deliberately never offers: publishing it moves ALL of a domain’s incoming mail here, including whatever its current provider is carrying.
Requires an API key with `inbound:read` or `domains:read`, or a signed-in session.
GET/v1/inbound/domains/{id}inbound:read or domains:read
Fetch a domain’s receiving setup
Both record sets — the MX that makes mail arrive, and the DKIM that lets it reply.
Requires an API key with `inbound:read` or `domains:read`, or a signed-in session.
POST/v1/inbound/domains/{id}/verifyinbound:write or domains:write
Check a domain’s receiving and sending records
Answers both questions at once: whether mail for this domain reaches us (MX, reported and never enforced) and whether the domain may send (DKIM, which this call applies). Every check is reported, passing or not.
Requires an API key with `inbound:write` or `domains:write`, or a signed-in session.
GET/v1/inbound/messagesinbound:read or messages:read
List received messages
Requires an API key with `inbound:read` or `messages:read`, or a signed-in session.
GET/v1/inbound/messages/{id}inbound:read or messages:read
Fetch a received message
HTML is sanitised on the way out, without exception.
Requires an API key with `inbound:read` or `messages:read`, or a signed-in session.
DELETE/v1/inbound/messages/{id}inbound:write or domains:write
Delete a received message
Requires an API key with `inbound:write` or `domains:write`, or a signed-in session.
GET/v1/inbound/messages/{id}/attachments/{attachmentId}inbound:read or messages:read
Download a received attachment
Always served as a download, never rendered: `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, and a content type from a small allowlist rather than the one the sender declared. A stranger’s HTML or SVG rendered on this origin would be script execution with your session in scope.
An attachment that matched a virus signature when it arrived is listed on the message — you should know it came — but refused here with `attachment_infected`.
Requires an API key with `inbound:read` or `messages:read`, or a signed-in session.
POST/v1/inbound/messages/{id}/readinbound:write or domains:write
Mark a received message read
Read state is shared by the account, which is what makes a shared support mailbox work. Marking an already-read message read again keeps the first time it was opened.
Requires an API key with `inbound:write` or `domains:write`, or a signed-in session.
POST/v1/inbound/messages/{id}/spaminbound:write or domains:write
Mark a message as spam, or as not spam
Overrides the spam score for one message. `false` rescues a false positive, `true` files one the filter was too generous about, and `null` hands the decision back to the score. The score itself is never rewritten — it is the record of what the filter said.
Requires an API key with `inbound:write` or `domains:write`, or a signed-in session.
GET/v1/inbound/messages/countinbound:read or messages:read
Count received messages matching a filter
Takes the same filters as the list and answers with a count alone, so a client can show how many a filter would match before applying it. The pagination cursor is ignored — a count has no pages.
Requires an API key with `inbound:read` or `messages:read`, or a signed-in session.
GET/v1/inbound/mta-stsinbound:read or domains:read
Hosted MTA-STS policies and their DNS state
MTA-STS tells senders to refuse delivery rather than downgrade when TLS to your MX cannot be verified. It needs a policy served over HTTPS at `mta-sts.<your-domain>` with a certificate for that name — we host and certify it; you publish two DNS records.
`live` is the conjunction of all three checks (CNAME, TXT, certificate). Any one missing means senders cannot fetch the policy, so "configured" and "working" are different words here.
Requires an API key with `inbound:read` or `domains:read`, or a signed-in session.
PUT/v1/inbound/mta-sts/{domain}inbound:write or domains:write
Publish or change a hosted MTA-STS policy
`testing` reports failures and still delivers; `enforce` makes senders refuse rather than downgrade; `none` switches enforcement off without deleting anything — which is the only safe way, since deleting the record leaves every sender on its cached copy until `max_age` expires.
Changing the policy changes its id, and **senders keep using the policy they already have until you republish the TXT record with the new id**. The response carries it.
Requires an API key with `inbound:write` or `domains:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| mode | "none" | "testing" | "enforce" required | |
| maxAge | integer optional | |
POST/v1/inbound/mta-sts/{domain}/verifyinbound:write or domains:write
Re-check the MTA-STS DNS records
Reported, never enforced. `txt: "stale"` is the one to act on — the policy changed and the published id did not, so the change has reached nobody while appearing to have been made.
Requires an API key with `inbound:write` or `domains:write`, or a signed-in session.
GET/v1/inbound/routesinbound:read or domains:read
List inbound routing rules
Requires an API key with `inbound:read` or `domains:read`, or a signed-in session.
POST/v1/inbound/routesinbound:write or domains:write
Route mail arriving at an address or domain
Without a rule, mail is kept in the mailbox and waits to be read. A rule can push it to a signed webhook instead, or do both.
A rule targets one address or one whole domain, and an address rule beats a domain rule on the same message. There is deliberately no pattern language: the address namespace is public, and a rule whose match set cannot be enumerated is one nobody can check before it starts forwarding a stranger’s mail.
Omit `webhookId` to reach every active endpoint subscribed to `inbound.received`. Webhook-routed mail is still stored — that is what makes an SMTP retry a no-op instead of a second delivery — it simply does not appear in the mailbox.
Requires an API key with `inbound:write` or `domains:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| address | string optional | |
| domain | string optional | |
| action | "mailbox" | "webhook" | "both" required | |
| webhookId | string | null optional | |
GET/v1/inbound/routes/{id}inbound:read or domains:read
Fetch an inbound routing rule
Requires an API key with `inbound:read` or `domains:read`, or a signed-in session.
PATCH/v1/inbound/routes/{id}inbound:write or domains:write
Update an inbound routing rule
Send `webhookId: null` to detach the endpoint; omitting the field leaves it as it was. Disabling a rule falls back to the mailbox, so mail is never lost by switching one off.
Requires an API key with `inbound:write` or `domains:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| action | "mailbox" | "webhook" | "both" optional | |
| webhookId | string | null optional | |
| enabled | boolean optional | |
DELETE/v1/inbound/routes/{id}inbound:write or domains:write
Remove an inbound routing rule
The mail it governed goes back to the mailbox, which is the default.
Requires an API key with `inbound:write` or `domains:write`, or a signed-in session.
Messages
GET/v1/messagesmessages:read
List messages
Filterable by status, tag, stream, recipient domain, metadata and date. Repeated `tag` parameters are ANDed.
Requires an API key with `messages:read`, or a signed-in session.
| Parameter | Type | Notes |
|---|
| limit | integer optional | |
| before | string optional | |
| status | string optional | |
| to | string optional | |
| search | string optional | |
| domain | string optional | |
| stream | string optional | |
| tag | string optional | |
| metadata | string optional | |
| from | string optional | |
| until | string optional | |
GET/v1/messages/{id}messages:read
Fetch one message
Requires an API key with `messages:read`, or a signed-in session.
GET/v1/messages/{id}/attachments/{attachmentId}messages:read
Download an attachment
Served `Content-Disposition: attachment` — never rendered inline on our origin, whatever the declared content type.
Requires an API key with `messages:read`, or a signed-in session.
GET/v1/messages/tailmessages:read
Stream message events as they happen
A Server-Sent Events stream of the same events the message log holds, delivered as they are written. Nothing polls: the connection is idle until an event lands.
Each `message` event carries the account `seq` as its SSE id. Reconnect with `Last-Event-ID` (browsers send it automatically) or `?after=<seq>` and everything after that position is replayed, so a dropped connection loses nothing.
A `ready` event opens the stream, `heartbeat` arrives every 15 seconds, and `bye` says why we closed. The stream is closed after 15 minutes with no message event; reconnect and resume. Five concurrent tails per account — a sixth is refused with `too_many_streams`.
Requires an API key with `messages:read`, or a signed-in session.
| Parameter | Type | Notes |
|---|
| status | string optional | |
| stream | string optional | |
| tag | string optional | |
| replay | integer optional | |
| after | integer optional | |
GET/v1/stats/messagesmessages:read
Message counts over time
Requires an API key with `messages:read`, or a signed-in session.
| Parameter | Type | Notes |
|---|
| days | integer optional | |
| domain | string optional | |
| tz | string optional | |
Templates
GET/v1/templatestemplates:read
List templates
Requires an API key with `templates:read`, or a signed-in session.
POST/v1/templatestemplates:write
Create a template
Requires an API key with `templates:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| subject | string optional | |
| html | string optional | |
| text | string optional | |
| variables | object[] optional | |
GET/v1/templates/{id}templates:read
Fetch a template
Requires an API key with `templates:read`, or a signed-in session.
PATCH/v1/templates/{id}templates:write
Edit a template, publishing a new version
A published version is immutable. Editing content publishes the next version and leaves the previous one byte-identical, so a send can always be traced to the exact content that went out.
Requires an API key with `templates:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| subject | string optional | |
| html | string optional | |
| text | string optional | |
| variables | object[] optional | |
DELETE/v1/templates/{id}templates:write
Delete a template
Requires an API key with `templates:write`, or a signed-in session.
POST/v1/templates/{id}/previewtemplates:read
Render a template without sending
Returns the rendered HTML and text for sample variables, and reports the same refusals a send would — so a preview cannot pass where the send fails.
Requires an API key with `templates:read`, or a signed-in session.
| Field | Type | Notes |
|---|
| version | integer optional | |
| variables | object optional | |
GET/v1/templates/{id}/versionstemplates:read
List template versions
Requires an API key with `templates:read`, or a signed-in session.
POST/v1/templates/previewtemplates:read
Render content without saving it
Renders what you supply rather than what you have published, so an editor can show a template before it exists. Reports the same refusals and the same lint a send would, and stores nothing.
Requires an API key with `templates:read`, or a signed-in session.
| Field | Type | Notes |
|---|
| subject | string optional | |
| html | string optional | |
| text | string optional | |
| variables | object[] optional | |
| values | object optional | |
Verify
POST/v1/address-checksverifications:read
Check whether addresses are worth sending to
Syntax, domain, MX (with the RFC 5321 A-record fallback), null MX, disposable providers, role accounts — and, the part nobody else can answer, whether the address is on YOUR suppression list.
Send one `address` or up to 100 `addresses`. Prefer the batch: lookups are deduplicated by domain, so sixty Gmail addresses are one DNS query, and it is the form the rate limits are designed around.
POST rather than GET because an address in a query string is personal data in every access log between you and us.
A verdict of `unknown` means DNS could not be reached — NOT that the address is bad. It is never charged and is never a reason to drop an address; ask again later.
There is no catch-all detection and no `catchAll` field, deliberately: that requires probing a stranger’s mail server with RCPT TO, which is indistinguishable from directory harvesting, does not work against Gmail, Outlook or Yahoo, and would turn this endpoint into a public mailbox oracle. See /docs/deliverability.
Requires an API key with `verifications:read`, or a signed-in session.
| Field | Type | Notes |
|---|
| address | string optional | |
| addresses | string[] optional | |
POST/v1/verificationsverifications:write
Send a one-time code
Mints a code, mails it from your own verified domain, and returns the id you check it against. Hold that id — a verification is addressed by it and never by recipient, so nobody can ask this API questions about an address they were not already given.
The code is six digits by default (set your own with `codeLength`, 4-8, or once for the account under settings), expires in ten minutes, and allows five attempts. Starting a verification supersedes any code still pending for the same address: only one is ever live, because two live codes in one mailbox means the older still works after the newer is used.
Requires an API key with `verifications:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| to | string required | |
| from | string required | |
| codeLength | integer optional | |
| stream | string optional | |
| tags | string[] optional | |
| metadata | object optional | |
| idempotencyKey | string optional | |
GET/v1/verifications/{id}verifications:read
Fetch a verification
Status, attempts remaining, and the id of the message the code went out on. It never returns the destination address, because the destination is never stored — only a keyed digest of it, the same treatment the suppression list gets.
Verifications are kept for 48 hours after they finish and then purged. The row holds a credential digest and nothing needs it after a day, so this answers 404 for anything older. A verification is a short-lived object by contract.
Requires an API key with `verifications:read`, or a signed-in session.
POST/v1/verifications/{id}/cancelverifications:write
Cancel a verification
Closes a pending verification for good — for when the user abandons the flow, or your risk engine changes its mind.
Cancelling twice is a 200: this is the call most likely to be retried on a timeout, and the desired state already holds. Cancelling something that finished some other way — approved, expired, out of attempts — is a 409, because there it was already used and that is worth knowing.
Requires an API key with `verifications:write`, or a signed-in session.
POST/v1/verifications/{id}/checkverifications:write
Check a code
A WRONG CODE IS A 200, with `status: "pending"` and how many attempts remain. The request succeeded; the answer is no. Reserving an error for the most common outcome in the product — a mistyped digit — would make every SDK throw on it.
A 409 means terminal: expired, cancelled, already used, or too many wrong guesses. Waiting cannot help, so start a new verification. Note that exhausting the attempts BURNS the code — the correct one stops working too, which is what makes the cap a protection rather than a tally.
Requires an API key with `verifications:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| code | string required | |
POST/v1/verifications/{id}/resendverifications:write
Re-send a code
Mails a FRESH code, on the original deadline and the original attempt budget. It cannot re-send the first one: only a digest of it is stored, and that is deliberate. Rotating is better anyway — the earlier mail stops working the moment a new code is asked for.
The address is required, and the sender is taken from the message the first code went out on rather than from this request. So an id on its own can never redirect somebody’s code to a new mailbox, or deliver a second code under a different name than the one they already saw.
Requires an API key with `verifications:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| to | string required | |
| stream | string optional | |
GET/v1/verifications/settingsverifications:read
Read your verification branding
The name and logo applied to every code you send. Returns the logo’s public URL rather than its storage key — the URL is the thing you can open to see what your recipients see.
Requires an API key with `verifications:read`, or a signed-in session.
PUT/v1/verifications/settingsverifications:write
Set your verification branding
Set once, applied to every verification. There is one built-in template and no per-send content, deliberately: every knob that could vary the body is a way for a code mail to arrive with no code in it.
The logo is base64 image bytes — PNG, JPEG or WebP, up to 512 KB. The format is determined by reading the bytes, not by anything you declare, and SVG is refused: it can carry script, and the file is served from our domain to your recipients.
`codeLength` (4-8) sets the default for every verification; a per-request value still wins. Before choosing 4, read what it costs: the attack budget on an address is roughly 4,200 guesses a week, which breaks a four-digit code about 42% of the time. It is offered only because three exhausted verifications now lock a destination for an hour.
Omitting a field leaves it alone; sending null clears it. Design for the logo to be BLOCKED — most mail clients block images by default, so the brand name is rendered as text and the logo carries that name as its alt.
Requires an API key with `verifications:write`, or a signed-in session.
| Field | Type | Notes |
|---|
| brandName | string | null optional | |
| codeLength | integer | null optional | |
| logo | object | null optional | |