Site transactional email
Transactional email is the outbound mail a WordPress site sends on its own
behalf — password resets, order confirmations, contact-form notifications. These
endpoints are all nested under a site, at /api/sites/{site-id}/mail, and cover
its delivery state, its sender identity, the self-service unblock, a DNS
re-check, the send log and per-period usage.
This is not Mailspace
CloudPress has two unrelated email products. This page is the mail a site sends, one account per site, created for you as part of the site. If you are looking for mailboxes people sign in to and read — aliases, groups, app passwords — that is workspace-level mailbox hosting: see Mailspace. Nothing on this page creates, lists or reads a mailbox.
For the dashboard walkthrough of the same feature, see Transactional email.
OAuth scopes: reads (GET) require sites:read, writes require
sites:write. On top of the scope, every write on this page requires the
can_modify (edit) role on the site — including the DELETE that lifts a
sending block, which is deliberately not gated on can_destroy. Without
the role the request is rejected with 403
{"errors":["Not Authorized"]}. Session and API-key credentials bypass
scope checks entirely (see OAuth).
Like all per-site endpoints, these inherit the site-level guards: an unknown
site ID returns 404 with no body, a dunning-suspended site returns 402
service_suspended, and a site pending deletion returns 403 pending_delete.
See Sites.
Provisioning States
Three distinct conditions are reported with three distinct codes, and they do
not apply uniformly across the page. Check which applies before treating a
4xx as a client error.
| Condition | Response | Applies to |
|---|---|---|
| Transactional email is not available on this platform at all | 503 mailchannels_unavailable |
every endpoint on this page |
| This site has no transactional-email account | 422 mail_not_provisioned |
every endpoint except the status read |
| The site's account exists but is not active | 422 mail_not_active |
send logs and usage metrics only |
The status read is the exception on purpose: a site that simply never
provisioned mail is a normal state, not a missing record, so
GET /api/sites/{site-id}/mail answers 200
with "mail": null rather than an error. A 404 there would be
indistinguishable from an unknown site ID. Every other endpoint rejects that
same site with 422 mail_not_provisioned.
mail_not_active is narrower still: the send log and the metrics endpoint both
read from the provider using the site's own credential, which only exists once
provisioning has landed. The sender-identity write, the unblock and the DNS
re-check work on a pending or failed account and do not raise it.
Nothing on this page is conditionally omitted
Every field described below is always present in a successful response.
Fields that can be empty are emitted as null (or as {} for the metrics
date maps) — there is no key that disappears from the payload.
Transactional Email Status
GET /api/sites/{site-id}/mail
Scope sites:read. The whole delivery state of the site's transactional email:
account status, whether the site currently holds a delivery configuration, DNS
verification, the effective sender identity, and any sending block.
Answers 200 with "mail": null when the site has no transactional-email
account. The same mail object is returned by the sender-identity update and by
the unblock endpoint.
Returned Params
- mail: Object |
nullwhen the site has no transactional-email account- status: String |
pending,activeorfailed - configured: Boolean | the site's hosting container currently holds a delivery configuration
- notifications_active: Boolean | this account has been enrolled for delivery-health notification email. It does not mean any will arrive — that email is switched off platform-wide at present
- dns: Object
- verdict: String |
unknown,verifiedorfailed.unknownis a hold, not a failure — see the note below - spf_verified: Boolean
- dkim_verified: Boolean
- lockdown_verified: Boolean | the domain-lockdown record
- all_verified: Boolean | all three of the above passed
- locally_checked: Boolean | the stored verdict is a local prediction, not a provider answer
- checked_at: DateTime | null
- verdict: String |
- sender_identity: Object
- from_email: String | null | the effective
Fromaddress, composed fromdkim_domain—nulluntil a DKIM domain exists - from_local_part: String | the effective part before the
@, with the default applied - from_name: String | the effective display name, with the default applied
- mail_from_local_part: String | null | the stored override only;
nullwhen the default is in force - mail_from_name: String | null | the stored override only
- dkim_domain: String | null | the domain the site's mail is signed for
- from_email: String | null | the effective
- suspension: Object
- suspended: Boolean | outbound mail is currently paused
- abuse: Boolean | the pause was raised by the abuse path
- system: Boolean | the pause was raised by billing or a lifecycle event
- reason: String | null
- suspended_at: DateTime | null
- can_self_unsuspend: Boolean | whether
DELETE .../mail/suspensionwould be accepted right now - remaining_self_unsuspends: Integer | self-service unblocks left in the current window
- created_at: DateTime
- updated_at: DateTime
- status: String |
curl -H "Authorization: Bearer $CLOUDPRESS_TOKEN" \
-H "X-Auth-Account: $ACCOUNT_ID" \
https://my.cloudpress.com/api/sites/$SITE_ID/mail
dns.verdict has three values, and unknown is not a failure
unknown means CloudPress holds no provider answer for this site — either
none has been fetched yet, or the stored verdict is a locally computed
prediction (dns.locally_checked is then true). It is a hold: nothing is
pushed to the site, nothing is torn down. Treat only failed as a failure,
and use the DNS re-check to ask for a fresh answer.
Three DNS records are checked, and each has its own boolean: SPF, DKIM and domain lockdown. The SPF value and the DKIM record's host are the same for every site on the platform; the DKIM record's value (it carries a public key minted for that site) and the lockdown record's value (it carries the site's own sender identifier) are per site. This API does not return any of the records themselves, only their verification state — read them from the dashboard's DNS screen.
configured and dns.verdict answer different questions
dns.verdict is what the DNS says; configured is whether the site's
container is actually holding a delivery configuration right now. A verified
verdict is what makes CloudPress attempt the push, and a verdict that stays
failed past a 24-hour grace window is what makes it tear the configuration
down again.
Five conditions are settled before the verdict is consulted at all, in this order:
- No transactional-email account, an account that is not yet active, or a
site with no hosting container — nothing happens either way. The
configuration is neither pushed nor torn down, and
configuredsimply reports whatever the site was already holding. - A suspended account → torn down.
- A site with no domain → torn down.
- A blank
dkim_domain→ torn down. There is no signing domain to send as, so an existing configuration does not survive this one. - A
dkim_domainthat is set but no longer matches the site's primary domain → a hold, not a teardown. An existing configuration survives, because the old domain's records usually still work.
Note the difference between 4 and 5: a missing DKIM domain is a teardown, a
stale one is a hold. Poll configured rather than inferring it from dns.
Errors
- 402
service_suspended| the site is dunning-suspended - 403
pending_delete| the site is soft-deleted and locked - 503
mailchannels_unavailable| transactional email is not available on this platform - 404 | unknown site ID (no body)
Update the Sender Identity
PATCH /api/sites/{site-id}/mail/settings
Scope sites:write, plus the can_modify (edit) role. Sets the part before the
@ and the display name the site's mail is sent as. The domain half is not
settable here — it follows the site's DKIM domain.
Params are flat (no wrapper key). Anything else you send is ignored: only these two keys are read.
Params
- mail_from_local_part: String | null (optional) | the part before the
@. Blank ornullclears the override and restores the default (noreply). Must start and end alphanumeric, may contain. _ % + -in between, no consecutive dots, max 64 bytes. - mail_from_name: String | null (optional) | the display name. Blank or
nullclears the override and falls back to the site's name. No control characters, max 255 bytes (bytes, not characters — an accented or emoji name hits the cap sooner than its length suggests).
Returns 200 with the same mail object as
the status read, so the effective
sender_identity is in the response.
curl -X PATCH \
-H "Authorization: Bearer $CLOUDPRESS_TOKEN" \
-H "X-Auth-Account: $ACCOUNT_ID" \
-H "Content-Type: application/json" \
-d '{"mail_from_local_part": "orders", "mail_from_name": "Example Store"}' \
https://my.cloudpress.com/api/sites/$SITE_ID/mail/settings
Saving is not the same as sending with it
This endpoint never calls the mail provider inline. The values are stored
immediately — including while DNS is unverified — and a reconcile is queued.
Whether the site actually starts stamping the new From depends on the same
conditions as configured above: an active, unsuspended account whose
dkim_domain still matches the site's primary domain, with a verified DNS
verdict. Read configured and sender_identity.from_email back from the
status endpoint rather than assuming the change reached the site.
Errors
- 402
service_suspended| the site is dunning-suspended - 403
pending_delete| the site is soft-deleted and locked - 403
{"errors":["Not Authorized"]}| the credential has no edit permission on the site - 422
mail_not_provisioned| the site has no transactional-email account - 422
invalid_sender_identity| validation rejected the values;errorscarries the per-field messages - 503
mailchannels_unavailable| transactional email is not available on this platform
Lift a Sending Block
DELETE /api/sites/{site-id}/mail/suspension
Scope sites:write. This removes a suspension, not the site, so it is gated
on the can_modify (edit) role — the same permission as the sender-identity
write, and deliberately not the stricter can_destroy. A member who can edit
the site can lift the block.
Outbound mail is paused automatically when the site's sending draws a spam
complaint, and when hard bounces and drops stop looking like accidents and start
looking like a list-quality problem. The API exposes the resulting state, not
the machinery: read suspension.suspended, suspension.abuse,
suspension.system, suspension.reason and suspension.suspended_at on
the status endpoint.
Check suspension.can_self_unsuspend first. This endpoint only clears an
abuse hold the customer is allowed to clear themselves. It returns 403
unsuspend_not_allowed when:
- the account is not suspended at all;
- the hold is a billing or lifecycle hold (
suspension.systemistrue) — those lift through the path that raised them, not from here; - an abuse verdict is parked behind a system hold, waiting to be applied when that hold lifts; or
- the self-service allowance is spent (
suspension.remaining_self_unsuspendsis0). The allowance is two self-service unblocks per 30-day window.
On success the response is 200 with the refreshed mail object — the same
shape as the status read — and a reconcile is
queued so the site's delivery configuration is re-evaluated rather than waiting
for the next scheduled sweep.
curl -X DELETE \
-H "Authorization: Bearer $CLOUDPRESS_TOKEN" \
-H "X-Auth-Account: $ACCOUNT_ID" \
https://my.cloudpress.com/api/sites/$SITE_ID/mail/suspension
Errors
- 402
service_suspended| the site is dunning-suspended - 403
pending_delete| the site is soft-deleted and locked - 403
{"errors":["Not Authorized"]}| the credential has no edit permission on the site - 403
unsuspend_not_allowed| not suspended, a system hold, a parked abuse verdict, or the allowance is spent - 422
mail_not_provisioned| the site has no transactional-email account - 422
unsuspend_failed| the reactivation did not complete. Usually the provider refused it and nothing changed — but a request that loses a race for the last remaining allowance can land here after the provider reactivated the account, leaving the site still suspended on our side. Re-read the status rather than assuming the state - 503
mailchannels_unavailable| transactional email is not available on this platform
Re-check Mail DNS
POST /api/sites/{site-id}/mail/dns_check
Scope sites:write, plus the can_modify (edit) role. Queues an out-of-band
re-check of the site's mail DNS instead of waiting for the scheduled sweep — the
call to make right after publishing the records. It returns 202 Accepted
immediately; poll GET /api/sites/{site-id}/mail
for the resulting verdict.
Returned Params (202 Accepted)
- status: String | always
"queued"
curl -X POST \
-H "Authorization: Bearer $CLOUDPRESS_TOKEN" \
-H "X-Auth-Account: $ACCOUNT_ID" \
https://my.cloudpress.com/api/sites/$SITE_ID/mail/dns_check
Rate limited: one check per site per 5 minutes
The limit is keyed on the site's transactional-email account, not on your
credential, and the window is shared with the dashboard's re-check
button — so a check a colleague just ran from the dashboard blocks yours,
and the API cannot be used to work around a limit the dashboard just
applied. A request inside the window returns 429 rate_limited and queues
nothing. Any accepted request opens the window, so retry after 5 minutes
rather than immediately.
Errors
- 402
service_suspended| the site is dunning-suspended - 403
pending_delete| the site is soft-deleted and locked - 403
{"errors":["Not Authorized"]}| the credential has no edit permission on the site - 422
mail_not_provisioned| the site has no transactional-email account - 429
rate_limited| a check for this site ran within the last 5 minutes - 503
mailchannels_unavailable| transactional email is not available on this platform
Search Send Logs
GET /api/sites/{site-id}/mail/logs
Scope sites:read. The site's outbound message log, searched upstream. Requires
an active account: an account that exists but has not finished provisioning
returns 422 mail_not_active.
Each search field is its own named parameter. All are optional; omit them all for the most recent messages.
Params (all optional)
- rcpt: String | recipient, substring match
- sender: String | sender, substring match
- subject: String | subject, substring match
- start_date: String |
YYYY-MM-DD, expanded toT00:00:00Z - end_date: String |
YYYY-MM-DD, expanded toT23:59:59Z - delivered: String |
YorN(case-insensitive).Nis the failures-only view. Any other value is ignored silently — no filter is applied and no error is raised - start_at: Integer | cursor offset, clamped to
0–10000
This endpoint is cursor-paged, not page-numbered: it does not read page or
per_page, and the page size is fixed. Page with start_at, following
next_start_at and prev_start_at.
Returned Params
- messages:
Array<Object>- sender: String | null
- rcpt: String | null
- subject: String | null
- delivered: Boolean or String | null — the upstream log sends both shapes; compare loosely
- rspcode: String | null | SMTP response code
- rspmsg: String | null | SMTP response text
- spam_disposition: String | null
- timestamp: String | null
- total: Integer | matching messages upstream, before local filtering
- start_at: Integer | the effective (clamped) offset of this page
- page_count: Integer | the raw page size upstream returned, before local filtering
- next_start_at: Integer | null | cursor for the next page,
nullon the last page - prev_start_at: Integer | null | cursor for the previous page,
nullon the first
messages can be shorter than page_count
Internal relay traffic is filtered out of messages after the page is
fetched, and total counts the unfiltered matches. So messages.length can
be less than page_count, and a page can even come back empty while
next_start_at is still set. Advance by next_start_at and keep going
until it is null — never treat an empty page as the end, and do not
compute paging from messages.length.
Errors
- 402
service_suspended| the site is dunning-suspended - 403
pending_delete| the site is soft-deleted and locked - 422
mail_not_provisioned| the site has no transactional-email account - 422
mail_not_active| the account exists but is not active - 502
log_search_failed| the upstream log search failed or was unreachable - 503
mailchannels_unavailable| transactional email is not available on this platform
Usage Metrics
GET /api/sites/{site-id}/mail/metrics
Scope sites:read. Daily send volume, open/click counts, and the site's usage
against its plan allowance for the current billing period. Requires an
active account (422 mail_not_active otherwise).
Params
- days: Integer (optional) | lookback window for
volumeandengagement. It does not affectusage, which always reports the current billing period. Four rules decide what a given value does:- Blank or absent — omitted, or sent as an empty string — falls back to the default of
30and answers200. The blank check runs before the parse, so an emptydayscounts as absent rather than as unparseable. - Present but unparseable is refused with
400invalid_days, and nothing upstream is read. This is the one case that errors. - An out-of-range number is still clamped to
1–90, silently. That behaviour has not changed. - Parsing is explicitly base 10:
010is ten days, not octal eight, and0x1eis refused rather than read as thirty.
- Blank or absent — omitted, or sent as an empty string — falls back to the default of
invalid_days is the last gate, not the first
The days check runs after the transactional-email account checks, so a
request that is both malformed and unanswerable reports the account problem.
On a site with no transactional-email account, ?days=abc answers 422
mail_not_provisioned; on an account that exists but is not yet active it
answers 422 mail_not_active. Neither one tells you that days was
accepted — the absence of invalid_days is not proof the value parsed.
Returned Params
- days: Integer | the effective (clamped) window
- volume: Object | each member maps
YYYY-MM-DDto an Integer, one key per day of the window- processed: Object
- delivered: Object
- dropped: Object
- bounced: Object | the 5xx (permanent-rejection) subset of
dropped— do not add the two together
- engagement: Object
- opens: Object |
YYYY-MM-DDto Integer;{}when there is nothing to report - clicks: Object |
YYYY-MM-DDto Integer;{}when there is nothing to report
- opens: Object |
- usage: Object |
nullwhen the provider reported no usage figure- period_start: DateTime | start of the current period
- period_end: DateTime | end of the current period
- sent: Integer | messages sent this period
- limit: Integer | the site plan's allowance for the period
- usage_pct: Integer |
sentas a percentage oflimit, rounded - cycle_pct: Integer | how far through the period the current time is, rounded
- projected: Integer | end-of-period projection at the current rate
- projected_pct: Integer | that projection as a percentage of
limit, capped at100
The billing period is anchored on the day the site's transactional-email account was created and advances a month at a time from that anchor — it is not tied to your invoice date.
dropped and bounced are not additive
dropped counts every failed delivery attempt. bounced counts only the
5xx permanent rejections among them, so it is a subset of dropped, not a
separate category beside it. Adding the two together double-counts every hard
failure. For the transient (4xx and code-less) failures on their own,
subtract instead: dropped minus bounced.
dropped was deliberately not narrowed to the 4xx remainder to make the two
add up. dropped already carries a specific meaning elsewhere on the
platform — it is the mail provider's own event name for "the provider refused
to send" — and redefining it here would have renamed an existing chart series
out from under its data.
volume counts attempts, not messages
The upstream search log is not collapsed by message, so each delivery attempt
is counted on its own. A message that is deferred twice and then delivered
contributes 2 to dropped, 1 to delivered and 3 to processed —
three log rows for one message. These series therefore will not reconcile
against a count of the messages you sent, and processed is not a message
count.
volume is derived from a bounded scan; treat it as indicative
Unlike usage, the daily volume series is not an upstream aggregate: it is
computed by walking the message search, at most 10 pages of 50 messages,
and bucketing what it finds by day. For a site that sends more than that in
the window, the older days will read low. Use usage.sent for anything that
has to be right, such as a quota alert; use volume for the shape of the
trend.
Poll this one sparingly
Every call makes several upstream round trips — the message walk above plus a separate engagement read. There is no cached aggregate behind it, so this is not an endpoint to put on a short timer.
Errors
- 400
invalid_days|dayswas sent but is not a whole number - 402
service_suspended| the site is dunning-suspended - 403
pending_delete| the site is soft-deleted and locked - 422
mail_not_provisioned| the site has no transactional-email account - 422
mail_not_active| the account exists but is not active - 503
mailchannels_unavailable| transactional email is not available on this platform
Error Codes
All errors use the standard {"errors": [...], "code": "..."} envelope described
in Error Responses. The permission failure is the
exception — it carries errors but no code.
| Code | Status | Raised by |
|---|---|---|
invalid_days |
400 | usage metrics |
service_suspended |
402 | every endpoint |
pending_delete |
403 | every endpoint |
(no code) ["Not Authorized"] |
403 | settings update, suspension removal, DNS re-check |
unsuspend_not_allowed |
403 | suspension removal |
mail_not_provisioned |
422 | settings update, suspension removal, DNS re-check, send logs, usage metrics |
mail_not_active |
422 | send logs, usage metrics |
invalid_sender_identity |
422 | settings update |
unsuspend_failed |
422 | suspension removal |
rate_limited |
429 | DNS re-check |
log_search_failed |
502 | send logs |
mailchannels_unavailable |
503 | every endpoint |