Skip to content

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:

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 report member_count: 0 for 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, PATCH and DELETE on /api/mailspace/:mailspace_id/domains/:name share 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, answers stalwart_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 listGET .../app_passwords answers 200 with 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 emailsGET .../masked_emails answers 200 with zero rows when its read fails.
  • Resolving one group or mailing list by idGET .../groups/:stalwart_id and GET .../mailing_lists/:stalwart_id fail closed to 404: an unreadable principal cannot be proven to be yours, so it is refused rather than returned hollow. A single 404 there 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/:guid503 restore_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, GET and the download on .../archived_items/:stalwart_id503 archived_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/restore503 restore_unconfirmed. The restore instruction went out and may already be queued.
  • DELETE .../archived_items/:stalwart_id503 delete_unconfirmed. The erase went out and may already have happened, and it is irreversible. Read the item back — a 404 means it went through. Treating this as "the delete failed" is the worst of the available guesses.
  • POST .../aliases and DELETE .../aliases/:address503 aliases_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-read GET .../aliases rather than reporting a failure to a human.
  • DELETE .../mailboxes/:id/force_delete503 delete_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 422 mail_rules_unavailable when a mailbox's filter script cannot be read back — on a plain GET as much as on a write, where it also means nothing was saved. See Mailspace mailboxes.
  • Downloading an archived message answers download_unavailable under two statuses, and the status is the difference: 404 when the mail server answered that the message is gone (terminal — stop), 503 for 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, or inactive
    • 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, null while 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, null if never. Not a timestamp for used_mb / mailbox_count — see above
    • webmail_url: String | webmail entry point for the domain
    • verification: Object | present only while needs_domain_verification is 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
    • 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 — send X-Auth-Account or use an account-scoped credential
  • 400 invalid_domain | domain missing or not a plausible hostname
  • 400 unknown_product | package is not one of the mail packages
  • 422 domain_not_registered | the domain is not registered anywhere, so mail for it could never work. Checked after unknown_product, so a request that is wrong in both ways still answers unknown_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 a remediation string alongside errors and code.
  • 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 a 202, 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 | package is 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 a 409 instead
  • 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