Skip to content

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 | null when the site has no transactional-email account
    • status: String | pending, active or failed
    • 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, verified or failed. unknown is 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
    • sender_identity: Object
      • from_email: String | null | the effective From address, composed from dkim_domainnull until 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; null when 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
    • 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/suspension would be accepted right now
      • remaining_self_unsuspends: Integer | self-service unblocks left in the current window
    • created_at: DateTime
    • updated_at: DateTime
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:

  1. 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 configured simply reports whatever the site was already holding.
  2. A suspended account → torn down.
  3. A site with no domain → torn down.
  4. A blank dkim_domain → torn down. There is no signing domain to send as, so an existing configuration does not survive this one.
  5. A dkim_domain that 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 or null clears 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 null clears 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; errors carries 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.system is true) — 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_unsuspends is 0). 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 to T00:00:00Z
  • end_date: String | YYYY-MM-DD, expanded to T23:59:59Z
  • delivered: String | Y or N (case-insensitive). N is 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 010000

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, null on the last page
  • prev_start_at: Integer | null | cursor for the previous page, null on 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 volume and engagement. It does not affect usage, 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 30 and answers 200. The blank check runs before the parse, so an empty days counts as absent rather than as unparseable.
    • Present but unparseable is refused with 400 invalid_days, and nothing upstream is read. This is the one case that errors.
    • An out-of-range number is still clamped to 190, silently. That behaviour has not changed.
    • Parsing is explicitly base 10: 010 is ten days, not octal eight, and 0x1e is refused rather than read as thirty.

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-DD to 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-DD to Integer; {} when there is nothing to report
    • clicks: Object | YYYY-MM-DD to Integer; {} when there is nothing to report
  • usage: Object | null when 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 | sent as a percentage of limit, 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 at 100

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 | days was 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