Mailspace
Mailspace is workspace-level mailbox hosting: you buy it for a domain, and it provides the mailboxes, aliases and groups for that domain. These endpoints cover the mailspace itself — purchase, inspect, resize, delete and purge.
Everything inside a mailspace has its own reference page:
- Mailspace mailboxes — mailboxes, app passwords, filter rules, out-of-office replies
- Mailspace addresses — aliases, groups, mailing lists, masked emails
- Mailspace domains — extra domains, their DNS records, and domain verification
- Mailspace logs & recovery — delivery logs, deleted-mail archive, mailbox recovery
OAuth scopes: reads require mailspace:read, writes require
mailspace:write. Session and API-key credentials bypass scope checks
entirely (see OAuth).
Mailspace IDs are GUIDs. A mailspace that is not visible to your credential
returns 404; a credential without edit permission on the owning account
returns 403 {"errors":["Not Authorized"]}.
Mailspace is sold on an annual term only — there is no monthly price, so no
term parameter is accepted anywhere on this page.
A 202 Accepted does not mean the charge succeeded
POST /api/mailspace and PATCH /api/mailspace/:id are asynchronous: they
build a cart, charge the billing account off-session, and return 202
with the shared cart envelope.
A declined off-session charge is also a 202. The envelope reports
payment.status as "awaiting_authentication" and carries a
payment.hosted_invoice_url the customer can pay through (this also covers
3DS/SCA). Branch on payment.status, never on the HTTP status alone — a
client that treats every 202 as success will report a mailspace as ordered
when nothing was ever paid for or provisioned.
Billing plan determines the charge path
On a Stripe billing plan the cart is charged off-session and settles
asynchronously — this is the flow the envelope above describes, and it is the
only flow that requires a saved default payment method (400
no_default_payment_method).
On a non-Stripe billing plan (payment method "none") the account is
billed externally, no Stripe charge is attempted, and the
no_default_payment_method gate is skipped entirely. The response is still
the same 202 envelope. This mirrors the split on
site plan changes.
Read failures
Every listing inside a mailspace reads the mail server live, and those reads are
strict: a read that could not be performed answers 503 with a code
naming which read failed, never 200 with an empty payload. The consequence is
the useful part — on those endpoints, an empty list means the mail server was
asked, so a 200 no longer hides a failed read and you can reconcile your own
records against it.
What that does not buy you is completeness. Every one of these lists is capped
upstream and truncates silently, with no total and no flag; the caps are described
below, and the purged-mailbox list has a worse one than the rest. So: a 200 is
proof the read happened, not proof the list is whole.
| Endpoint | Code |
|---|---|
GET /api/mailspace/:mailspace_id/mailboxes |
mailboxes_unavailable |
GET /api/mailspace/:mailspace_id/domains |
domains_unavailable |
GET /api/mailspace/:mailspace_id/aliases |
aliases_unavailable |
GET /api/mailspace/:mailspace_id/groups |
groups_unavailable |
GET /api/mailspace/:mailspace_id/mailing_lists |
mailing_lists_unavailable |
GET /api/mailspace/:mailspace_id/archived_items |
archived_items_unavailable |
GET /api/mailspace/:mailspace_id/purged_mailboxes |
purged_mailboxes_unavailable |
All of them are transient — retry. None is a client error, and none means the resource is empty.
The purged-mailbox list is the one place an empty 200 can still lose data
Its cross-check reads the mail server's pending erase tasks 500 rows at a time
across the whole mail server, and filters them to this mailspace
afterwards. There is no total and no truncation flag, so on a busy server a
recoverable mailbox's task can fall outside the window and its snapshot is
silently omitted from an otherwise healthy 200. Treat a listed snapshot as
real; treat an empty list as "nothing found", never as "the mail is gone".
Do not drop your own last record of a mailbox on the strength of it.
Each code covers one read and nothing else. A failure in the q filter, in a
response template, or anywhere else in the endpoint is a genuine 500, not one of
these.
Listing groups has two codes, and the difference is which read failed
GET .../groups makes two reads — the group list, then a member tally for each
group — and each has its own code:
groups_unavailable— the group list could not be read, so nothing about this mailspace's groups is known. This is what a full mail-server outage answers.group_members_unavailable— the groups came back but the member counts did not. The endpoint refuses to reportmember_count: 0for every group when it could not ask.
group_members_unavailable also appears on GET .../groups/:stalwart_id (the
member addresses could not be read) and on PATCH .../groups/:stalwart_id,
where the update has already been applied and only the read-back failed —
do not re-send the change, re-read the group.
stalwart_unavailable (503) is a different kind of code. It is not confined
to the mailspace-level gate, and neither of its meanings is "the mailspace could
not be looked up" — an unknown or out-of-scope :mailspace_id always answers
404 with an empty body:
- mail hosting is not configured on the platform, which refuses every request in the Mailspace family before anything is looked up;
- one individual read failed.
GET,PATCHandDELETEon/api/mailspace/:mailspace_id/domains/:nameshare a lookup that asks the mail server for the domain and, when that read fails and the name is not a locally pending domain either, answersstalwart_unavailable. See Mailspace domains.
So do not read stalwart_unavailable as saying nothing about the individual
request: on the domain paths it says exactly that, and it is retriable there.
Where an empty or hollow result is still not proof
The table above is the strict set. Several reads are still tolerant — they
answer 200 with an empty or partial payload when the mail server cannot be
reached, and nothing in the response distinguishes that from a genuinely empty
result:
- Delivery logs — incoming and outgoing traces, the outbound queue and the derived delivery issues each degrade to an empty list. See Mailspace logs & recovery.
- Mailbox detail — group memberships, mailing-list memberships, app passwords and the TOTP flag each carry their own empty failure value, so a mailbox can come back looking like it belongs to no groups, holds no app passwords and has TOTP switched off. The mailbox index is strict; its detail view is not.
- The app-password list —
GET .../app_passwordsanswers200with an empty array when that read fails. This one is deliberate and is not going to change: revoking an app password needs its id, so an empty list cannot be acted on destructively. - Masked emails —
GET .../masked_emailsanswers200with zero rows when its read fails. - Resolving one group or mailing list by id —
GET .../groups/:stalwart_idandGET .../mailing_lists/:stalwart_idfail closed to404: an unreadable principal cannot be proven to be yours, so it is refused rather than returned hollow. A single404there is therefore not proof the group or list is gone — the index is the authoritative check. - Index caps — a principal listing is capped at 500 rows, and masked
emails at 2000. Truncation is silent: there is no total and no "more"
flag, and no error code reports it. The 500 is not per principal type:
mailboxes and groups are the same kind of object on the mail server and are
separated by type only after the capped query has been answered, so a
mailspace with 500 or more mailboxes can return zero groups with a
200. The masked-email 2000 is applied server-wide, before this mailspace's rows are picked out of the result.
Reconciling your own records against any of these — deleting whatever the response omits — destroys data during a mail-server blip.
Deleted mail: unreachable and unlicensed are different, and only one is visible
The deleted-mail archive is a licensed feature of the mail server. On a
deployment without that licence, GET .../archived_items answers 200 with an
empty list and every by-id route answers 404 — so "nothing archived" and
"not licensed" are indistinguishable, deliberately, because there is nothing
to probe.
That is not a hole in the strict read above. The mail server answers a request
for a licensed-only object, and an answer is what the strict read is checking
for; a server that gives no answer is the 503. So an unreachable mail
server is distinguishable from an empty archive. An unlicensed one is not.
On a write, 503 answers one of two opposite questions — check which
Some of these codes mean nothing was attempted. Others mean something was attempted and the outcome is unknown. The status is the same; the code is what tells you, and there is no way to infer it from the word "unavailable".
Nothing was attempted — the resource is untouched, retry it:
PATCH .../purged_mailboxes/:guid→503restore_unavailable. The restore asks the mail server whether it is still holding the account before it consumes the snapshot, so a read it cannot perform aborts the restore rather than destroying the last record of a live mailbox.POST,DELETE,GETand the download on.../archived_items/:stalwart_id→503archived_item_lookup_unavailable. The ownership check that resolves the id could not be performed, so nothing was sent to the mail server.
Something was attempted and its fate is unknown — re-read before retrying:
POST .../archived_items/:stalwart_id/restore→503restore_unconfirmed. The restore instruction went out and may already be queued.DELETE .../archived_items/:stalwart_id→503delete_unconfirmed. The erase went out and may already have happened, and it is irreversible. Read the item back — a404means it went through. Treating this as "the delete failed" is the worst of the available guesses.POST .../aliasesandDELETE .../aliases/:address→503aliases_unavailable. On a write this code also covers the write leg, so the alias may have been created or removed. Both writes are safe to repeat, but re-readGET .../aliasesrather than reporting a failure to a human.DELETE .../mailboxes/:id/force_delete→503delete_unavailable. A third case again: the mailbox is already gone from the mail server, and what could not be established is whether its mail is still recoverable. The local record survives, the mailbox still appears in the list, and the sweep retries.
A 422 is the only answer that means the mail server replied and refused.
On the purged-mailbox restore that split is worth learning as a pattern: 409
not_recoverable means nothing can be done, 422 restore_failed means the
server refused and a retry may work, 503 restore_unavailable means the check
could not be made.
Two endpoints answer something other than 503
This is current behaviour, not a rule to generalise from:
- The mail-rule and out-of-office endpoints answer
422mail_rules_unavailablewhen a mailbox's filter script cannot be read back — on a plainGETas much as on a write, where it also means nothing was saved. See Mailspace mailboxes. - Downloading an archived message answers
download_unavailableunder two statuses, and the status is the difference:404when the mail server answered that the message is gone (terminal — stop),503for any other read failure (the message may still be there — retry). See Mailspace logs & recovery.
List Mailspaces
GET /api/mailspace
Returns the mailspaces newest-first. Soft-deleted (pending-deletion) mailspaces are included — they remain listed for their retention window; purged rows are gone. This endpoint is not paginated.
With the X-Auth-Account header the list is that account's mailspaces; without
it, an account-less credential falls back to every mailspace the token's user can
reach.
Returned Params
- mailspaces: Array
- id: String | GUID
- domain: String | the mail domain, e.g.
example.com - account_name: String | the mail account name stored on the record
- package: String | mail Product short_name, e.g.
mail_5 - package_name: String | display name, e.g.
Mailspace 5GB - status: String |
pending,active,suspended, orinactive - storage_gb: Integer | storage included with the package
- max_mailboxes: Integer | mailbox limit of the package
- used_mb: Integer | last measured storage use, in MB
- mailbox_count: Integer | mailboxes currently defined
- provisioned: Boolean | the mail tenant exists and is ready to use
- verified: Boolean | domain ownership has been confirmed
- needs_domain_verification: Boolean | a DNS TXT record is still owed — see View a Mailspace
- pending_deletion: Boolean | soft-deleted; restore or purge only
- delete_scheduled_at: DateTime | frozen purge date,
nullwhile live - created_at: DateTime
- updated_at: DateTime
- account: Object
- id: String
- name: String
View a Mailspace
GET /api/mailspace/:id
Serialized from local state — no live call to the mail server, so used_mb and
mailbox_count are whatever was last measured, not live figures.
synced_at does not timestamp the usage figures
synced_at is stamped when someone opens the mailspace in the dashboard —
and it is stamped even if that page's usage read failed. used_mb and
mailbox_count carry their own measurement time internally, which this API
does not expose. So a workspace that only ever uses the API can see
synced_at: null alongside perfectly current usage figures (a resize
re-measures usage without touching synced_at), or a synced_at newer than
the numbers beside it. Treat the two as unrelated.
Returned Params
- mailspace: Object
- all fields from List Mailspaces, plus:
- synced_at: DateTime | when the mailspace was last opened in the dashboard,
nullif never. Not a timestamp forused_mb/mailbox_count— see above - webmail_url: String | webmail entry point for the domain
- verification: Object | present only while
needs_domain_verificationis true- txt_prefix: String |
_mailspace-verify - txt_host: String | the record host, e.g.
_mailspace-verify.example.com - txt_value: String | the token to publish as the TXT value
- txt_prefix: String |
- subscription: Object | present only when a billing subscription is linked
- id: String
- status: String
- created_at: DateTime
- updated_at: DateTime
- price: Object
- amount_cents: Integer
- term: String
- account: Object
- id: String
- name: String
The verification block is the post-purchase to-do
For a domain the workspace does not already own, the mailspace is created in
awaiting verification after payment settles. Poll this endpoint, publish
the TXT record from the verification block, and the mailspace provisions
itself on the next sweep. The block is gated on needs_domain_verification,
so it disappears as soon as ownership is confirmed — before
provisioned flips to true. Don't treat its absence as "the mailspace is
ready"; check provisioned. For a domain the workspace already owns,
verification is skipped and the block never appears at all.
Purchase a Mailspace
POST /api/mailspace
Builds a standalone mailspace cart and submits it. Returns 202 Accepted
with the cart envelope — re-read the warning at the top of this page before
handling the response.
An account context is required, because the purchase has to know what to
bill: send the X-Auth-Account header or use an account-scoped credential.
The mailspace itself does not exist yet when the 202 is returned — it is
created only after payment settles, so there is no mailspace ID in the response.
Poll cart.poll_url until the cart completes, then find the new mailspace with
GET /api/mailspace.
Params
- domain: String (required) | the mail domain, lower-cased and trimmed for you. Must look like a real hostname and must be a domain that actually exists (registered with us or elsewhere).
- package: String (optional) | mail Product short_name —
mail_5,mail_20,mail_50,mail_200,mail_500. Defaults to the base (smallest) tier.
curl -X POST \
-H "Authorization: Bearer $CLOUDPRESS_TOKEN" \
-H "X-Auth-Account: $ACCOUNT_ID" \
-H "Content-Type: application/json" \
-d '{"domain": "example.com", "package": "mail_50"}' \
https://my.cloudpress.com/api/mailspace
Returned Params (always 202 Accepted)
The shared cart envelope, identical to POST /api/orders:
- status: String |
"accepted" - cart: Object |
{ token, status, rollup_status, poll_url } - payment: Object |
{ status, method_type, hosted_invoice_url } - orders: Array |
[{ id, status, poll_url }](empty during the async window)
Errors
- 503
stalwart_unavailable| mail hosting is not currently available - 400
account_required| no account context — sendX-Auth-Accountor use an account-scoped credential - 400
invalid_domain|domainmissing or not a plausible hostname - 400
unknown_product|packageis not one of the mail packages - 422
domain_not_registered| the domain is not registered anywhere, so mail for it could never work. Checked afterunknown_product, so a request that is wrong in both ways still answersunknown_product. - 422
mailspace_exists| the domain already has a mailspace. Includes your own pending-deletion mailspace — the domain stays claimed for the whole retention window, so restore it instead of buying it again. - 400
no_default_payment_method| Stripe billing plans only: the billing account has no Stripe customer, no saved default payment method, or an incomplete billing contact. The body carries aremediationstring alongsideerrorsandcode. - 422
cart_item_rejected| the cart refused the mail item — most often mail pricing is not yet configured for the requested package - 422
cart_pay_failed| the cart was valid but the charge could not be initiated (a decline that was initiated is a202, not this)
No WordPress site is required
The in-cart rule that mail can only be added on top of WordPress or cPanel hosting does not apply here: this endpoint builds a dedicated mailspace cart, which satisfies that rule by construction. A mailspace can be bought standalone over the API, for any existing domain.
Resize a Mailspace
PATCH /api/mailspace/:id
Moves the mailspace to a different package. Like the purchase, this is
cart-mediated and asynchronous — 202 Accepted with the same envelope, and
the same payment.status caveat.
On a Stripe billing plan the change is prorated against the mailspace's existing subscription. On an externally-billed plan there is no Stripe charge; the new package is applied and billed outside the platform.
Current storage use is re-measured against the mail server on every resize — upgrades included, even though only a downgrade can be blocked by it — so a resize request is not instantaneous even before the charge.
Params
- package: String (required) | the target mail Product short_name. Must differ from the current package.
Returned Params (always 202 Accepted)
The shared cart envelope — see Purchase a Mailspace.
Errors
Evaluated in this order:
- 503
stalwart_unavailable| mail hosting is not currently available - 403
mailspace_suspended| the mailspace is on hold (staff block or an unpaid-invoice hold) and cannot be modified - 422
pending_delete| the mailspace is scheduled for deletion — restore it first - 400
unknown_product|packageis not one of the mail packages - 422
package_unchanged| already on that package - 422
not_provisioned| the mailspace has not finished setting up yet - 422
resize_unavailable| Stripe billing plans only: the mailspace has no active billing subscription to prorate against - 422
downgrade_blocked| the target package is smaller than the mailspace's current mailbox count or stored mail. Delete mailboxes or free storage first. - 400
no_default_payment_method| Stripe billing plans only, as on purchase - 422
cart_item_rejected| the cart could not build the change - 422
cart_pay_failed| the prorated charge could not be initiated
Delete a Mailspace
DELETE /api/mailspace/:id
What this does depends on whether the mailspace was ever provisioned — the
two outcomes are materially different, and the endpoint gives you no way to ask
for one or the other. Both return 200 with an empty body, so check
provisioned on the mailspace before you call this if the difference matters.
Soft delete. The mailspace stops serving mail and enters a retention
window; pending_deletion becomes true and delete_scheduled_at is the
frozen purge date. It still appears in GET /api/mailspace. Billing is
cancelled with a prorated early-cancellation credit. Purging it before
the window runs out is Purge a Mailspace below;
restoring it is not part of this API — use the dashboard.
Idempotent: deleting an already-pending-deletion mailspace succeeds and changes nothing.
Hard delete, and the full first term is credited back. A mailspace that
is still awaiting domain verification has no mail server tenant to suspend
and has used none of its prepaid term, so this cancels the purchase outright:
the record is destroyed (it disappears from GET /api/mailspace;
pending_deletion is never set, delete_scheduled_at is never assigned),
the originating order is refunded in full and marked cancelled, and the
subscription seat is removed.
Because the record is gone, the domain is released immediately — a
re-purchase for the same domain succeeds rather than hitting
mailspace_exists.
Runs synchronously in both cases.
curl -X DELETE \
-H "Authorization: Bearer $CLOUDPRESS_TOKEN" \
-H "X-Auth-Account: $ACCOUNT_ID" \
https://my.cloudpress.com/api/mailspace/$MAILSPACE_ID
A soft-deleted mailspace keeps its domain claimed
A provisioned mailspace holds its mail domain for the whole retention
window. Purchasing a new mailspace for that domain in the meantime fails with
422 mailspace_exists — restore the existing one instead. This does not
apply to the never-provisioned case above, where the record is destroyed and
the domain frees up at once.
Errors
- 503
stalwart_unavailable| mail hosting is not currently available - 403
mailspace_suspended| the mailspace is on hold (staff block or an unpaid-invoice hold) and cannot be deleted - 422
delete_failed| the deletion could not be completed. On the never-provisioned path this also covers a failed credit — the refund could not be processed, so the mailspace is deliberately left intact rather than deleted unpaid. Retry.
Purge a Mailspace
POST /api/mailspace/:mailspace_id/purge
Permanently destroys a mailspace that is already soft-deleted, instead of
waiting out its retention window. Returns 200.
Irreversible, and it takes the mail with it
The mail server tenant — every domain, mailbox and stored message — and the local record are destroyed, and the mail records CloudPress wrote into the customer's hosted DNS zone are removed. Nothing here is recoverable, and no task is created to follow: the work is done by the time you get the response.
There is no confirmation parameter. The only precondition is that the mailspace is already pending deletion.
Billing is untouched — it was already cancelled when the mailspace was soft-deleted, so purging early neither refunds nor charges anything.
Every purge is written to the platform audit log with the credential that made the call.
Returned Params
- purged: Boolean | always
true; a purge that removed nothing is a409instead - domain: String | the mail domain that was released, captured before the record was destroyed
curl -X POST \
-H "Authorization: Bearer $CLOUDPRESS_TOKEN" \
-H "X-Auth-Account: $ACCOUNT_ID" \
https://my.cloudpress.com/api/mailspace/$MAILSPACE_ID/purge
Purge a live mailspace and you get a 409
409 not_pending_deletion means nothing was purged and the mailspace is
still there. It covers both a mailspace that was never deleted — call
DELETE /api/mailspace/:id first — and the race where a restore lands
between the check and the destroy.
Errors
- 404 | the mailspace is not visible to your credential
- 503
stalwart_unavailable| mail hosting is not currently available - 403
not_authorized| the credential has no edit permission on the owning workspace - 403
mailspace_suspended| the mailspace is live and on hold (staff block or unpaid-invoice hold) - 409
not_provisioned| the mailspace has no mail server tenant to destroy - 409
not_pending_deletion| the mailspace is not scheduled for deletion — see above - 422
purge_failed| the purge could not be completed; nothing was destroyed
Error Codes
All errors use the standard {"errors": [...], "code": "..."} envelope described
in Error Responses.
The same code name, a different status, inside a mailspace
The endpoints on this page raise pending_delete and not_provisioned
themselves, as 422. The nested endpoints — mailboxes, addresses,
domains, logs — inherit them from a shared gate instead, which answers
403 for pending_delete and 409 for not_provisioned. A client
that branches on the status rather than the code will read one of the two
families wrong.
The 403 on this page also carries no code key at all
({"errors":["Not Authorized"]}); the nested gate emits not_authorized
beside it.
| Code | Status | Raised by |
|---|---|---|
stalwart_unavailable |
503 | create, update, destroy, purge |
account_required |
400 | create |
invalid_domain |
400 | create |
domain_not_registered |
422 | create |
unknown_product |
400 | create, update |
mailspace_exists |
422 | create |
no_default_payment_method |
400 | create, update |
cart_item_rejected |
422 | create, update |
cart_pay_failed |
422 | create, update |
mailspace_suspended |
403 | update, destroy, purge |
pending_delete |
422 | update |
package_unchanged |
422 | update |
not_provisioned |
422 | update |
resize_unavailable |
422 | update |
downgrade_blocked |
422 | update |
delete_failed |
422 | destroy |
not_authorized |
403 | purge |
not_provisioned |
409 | purge |
not_pending_deletion |
409 | purge |
purge_failed |
422 | purge |