REST API
The platform exposes one JSON API under /api/v1. The web UI and external apps use the same endpoints and the same authorization rules; only the way the caller authenticates differs.
The platform exposes one JSON API under /api/v1. The web UI and external apps use the same endpoints and the same authorization rules; only the way the caller authenticates differs.
- Postmark-compatible aliases (
/email,/email/batch) are documented in postmark-compatibility.md. - Outbound webhooks are documented in webhooks.md.
- The MCP server is documented in mcp.md.
Authentication
| Caller | How | CSRF |
|---|---|---|
| Web UI | SESSION cookie (HttpOnly) set by POST /api/v1/auth/login or Google sign-in |
Required on every non-GET request: send the value of the XSRF-TOKEN cookie in the X-CSRF-Token header |
| Apps and agents | Authorization: Bearer <api key> or X-Postmark-Server-Token: <api key> |
Not used |
| A user's own agents and scripts | Authorization: Bearer <personal token> |
Not used |
When an API key or personal token header is present the request is authenticated by it only; any session cookie is ignored. CSRF only applies to requests carrying the session cookie (and to the sign-in endpoints); a request without credentials gets 401 UNAUTHENTICATED.
API keys look like mp_live_<32 random chars> and belong to an app. Personal tokens look like mp_pat_<32 random chars> and belong to a user (see Personal tokens). Both are shown once at creation and stored only as a keyed hash. An agent platform connected with OAuth gets access tokens (mp_oat_…) that work at the MCP endpoint only; the REST API answers them 401.
Tenancy
Every business resource belongs to a tenant. The tenant of a request is:
- the API key's tenant;
- for a signed-in user, the session's active workspace: one person has one account and can belong to several tenants, and a session works in one of them at a time (
POST /api/v1/me/workspaceswitches it, see Workspaces); - for a personal token, the tenant it was created in (a token belongs to one membership).
Platform admins have no tenant and cannot select one: they read and manage tenants only through the platform endpoints. Every tenant-scoped endpoint refuses them, with 403 FORBIDDEN (or 409 TENANT_NOT_SELECTED where the endpoint needs no capability); a session that selected a tenant before this rule is ignored.
A resource id that exists in another tenant behaves exactly like an id that does not exist (404).
Conventions
- JSON field names are camelCase. Timestamps are ISO-8601 UTC (
2026-09-27T10:15:30Z). Ids are UUIDs. - Enumerations are UPPER_SNAKE_CASE strings unless stated otherwise.
- PATCH bodies are partial: an absent field is left unchanged; an explicit
nullclears it (where the field is optional). - Paginated lists take
page(0-based, default 0) andsize(default 25, max 100) and return{ "items": [...], "page": 0, "size": 25, "total": 123 }. - Errors always use this envelope:
{ "error": { "code": "VALIDATION_FAILED", "message": "Human readable message", "details": [ { "field": "to", "message": "must not be empty" } ] } }
| HTTP | error.code examples |
|---|---|
| 400 | VALIDATION_FAILED, BAD_REQUEST, INVALID_CODE, INVALID_PASSWORD |
| 401 | UNAUTHENTICATED, INVALID_API_KEY, INVALID_CREDENTIALS |
| 403 | FORBIDDEN, CSRF_INVALID, CLIENT_ACCOUNTS_DISABLED, PAUSE_LOCKED, PLATFORM_SUPPRESSION, CONFIRMATION_REQUIRED |
| 404 | NOT_FOUND |
| 409 | CONFLICT, TERMS_VERSION_CHANGED, TENANT_NOT_SELECTED, IDEMPOTENCY_CONFLICT, ACCOUNT_IN_USE, ACCOUNT_HAS_DOMAINS, ACCOUNT_HAS_PENDING_ERASURES, ADDRESS_ERASED, DOMAIN_IN_USE, IDENTITY_NOT_MANAGED, RECEIPT_RULE_SET_CONFLICT, USER_LIMIT_REACHED, MAILBOX_LIMIT_REACHED, DOMAIN_LIMIT_REACHED, AGENT_LIMIT_REACHED, PERSONAL_TOKENS_DISABLED, TOKEN_LIMIT_REACHED, STARTER_ADDRESS_TAKEN, STARTER_ADDRESS_EXISTS, POOL_IN_USE, FAILOVER_NOT_ACTIVE, PLAN_NAME_IN_USE, STRIPE_PRICE_IN_USE, PLAN_DEFAULT_REQUIRED, PLAN_ARCHIVED, PLAN_IS_DEFAULT, PLAN_IN_USE, RECEIVING_ALREADY_PAUSED, RECEIVING_NOT_PAUSED |
| 413 | PAYLOAD_TOO_LARGE |
| 422 | STREAM_PAUSED, ALL_RECIPIENTS_SUPPRESSED, ALL_RECIPIENTS_INVALID, DOMAIN_NOT_VERIFIED, SCOPE_DENIED, DAILY_LIMIT_REACHED, USER_DAILY_LIMIT_REACHED, KEY_DAILY_LIMIT_REACHED, PLAN_VOLUME_REACHED, BROADCAST_NOT_IN_PLAN, BROADCAST_CONSENT_REQUIRED, TERMS_ACCEPTANCE_REQUIRED, TENANT_SENDING_PAUSED, ORGANIZATION_RESTING, STARTER_BROADCAST_NOT_ALLOWED, STREAM_NOT_AVAILABLE, STARTER_INBOUND_REQUIRED, STARTER_TRACKING_NOT_AVAILABLE, PLATFORM_SENDING_UNAVAILABLE, TOO_MANY_PENDING_DOMAINS, DNS_MANAGED_BY_PLATFORM, DNS_RECORDS_IN_PLACE |
| 429 | RATE_LIMITED, READ_RATE_LIMITED, TOO_MANY_DNS_INSTRUCTIONS, TOO_MANY_CODES, TOO_MANY_CODE_ATTEMPTS, TOO_MANY_ORGANIZATIONS |
| 502 | PROVIDER_ERROR |
Errors of platform sending:
| Code | HTTP | When |
|---|---|---|
DAILY_LIMIT_REACHED |
422 | A send through the platform would take the tenant past its daily recipient cap (or its starter address past 100 recipients). Counts reset at midnight UTC; failures are not refunded. |
ALL_RECIPIENTS_INVALID |
422 | From a platform-mode domain, the recipient checks left no recipient: every one that is not suppressed has a domain that takes no mail or, for broadcast, is a disposable-address service. Nothing is stored. |
BROADCAST_DMARC_REQUIRED |
422 | Broadcast from a platform-mode domain that has no DMARC record (its own or its organizational domain's; any policy). Transactional mail is not affected. |
BROADCAST_NOT_HELD |
409 | Resuming a platform account's broadcast that is not held. |
TOO_MANY_PENDING_DOMAINS |
422 | Adding a platform-mode domain while the tenant already has 10 (messaging.platform-sending.max-pending-domains) waiting for their ownership proof. Starter addresses do not count. |
TENANT_SENDING_PAUSED |
422 | Any send while platform admins paused the tenant's sending — from every domain of the tenant, including those on its own AWS accounts. |
ORGANIZATION_RESTING |
422 | A send with an API key or a personal token from a platform-mode domain of a Free organization that rested after 90 days without activity, or is waking from it. The key's or token's use wakes it: retry in a few minutes. Mail a person writes in the web app, or the organization's own campaigns send, is accepted and waits instead. |
STARTER_BROADCAST_NOT_ALLOWED |
422 | A broadcast send from a starter address (transactional only). |
STREAM_NOT_AVAILABLE |
422 | The domain has no stream of that type (a starter address has only the transactional stream). |
STARTER_INBOUND_REQUIRED |
422 | Turning inbound off on a starter address. |
STARTER_TRACKING_NOT_AVAILABLE |
422 | Turning the custom tracking domain on for a starter address (it keeps the default, shared tracking domain). |
PLATFORM_SENDING_UNAVAILABLE |
422 | Adding a domain through the platform before the pool the tenant would send from has an account. |
PROVIDER_ERROR |
502 | Platform mode: the platform's DNS zones refused a write while adding a domain or starter address (nothing is kept) or a delete while removing a domain (the domain stays); the sending service refused to remove a platform hard-bounce list entry (platform admins; the entry stays). Retry. |
STARTER_ADDRESS_TAKEN |
409 | Another tenant uses that starter slug. |
STARTER_ADDRESS_EXISTS |
409 | The tenant already has a starter address. |
CLIENT_ACCOUNTS_DISABLED |
403 | Creating an AWS account while the tenant's own AWS accounts are not enabled. |
PAUSE_LOCKED |
403 | A tenant resuming a platform-mode stream that the platform paused automatically. |
POOL_IN_USE |
409 | Registering a platform account for a pool that has an active (not retired) one, or an AWS account already registered. |
ACCOUNT_HAS_DOMAINS |
409 | Deleting a client account that domains still use (a platform account in use is retired instead). |
ACCOUNT_HAS_PENDING_ERASURES |
409 | Deleting a client account holding originals of received mail an erasure could not delete there yet (the account unreachable): restore its access, then delete it again. |
ADDRESS_ERASED |
409 | Adding a suppression for an address whose data the organization erased: it is blocked already, and never stored again in clear. |
FAILOVER_NOT_ACTIVE |
409 | Stopping failover when none is active. |
PLATFORM_SUPPRESSION |
403 | A tenant deleting the platform hard-bounce entry an exact-address lookup showed: only platform admins remove it. |
Errors of the daily send limits of a person or an API key (every domain, platform or client mode; see daily send limits):
| Code | HTTP | When |
|---|---|---|
USER_DAILY_LIMIT_REACHED |
422 | A send by a user — from the web inbox, or through their personal token — would take the user past the tenant's userDailySendLimit recipients today (UTC). Resets at 00:00 UTC. Nothing is stored. |
KEY_DAILY_LIMIT_REACHED |
422 | A send with an API key would take the key past its own dailyRecipientLimit recipients today (UTC). Resets at 00:00 UTC. Nothing is stored. Postmark aliases: ErrorCode 405. |
Request bodies are limited per endpoint and refused with 413 PAYLOAD_TOO_LARGE before they are parsed — at once when Content-Length is over the limit, otherwise (chunked bodies) as soon as the limit is passed while reading:
| Endpoint | Default limit | Property |
|---|---|---|
POST /api/v1/messages, POST /api/v1/threads/{id}/reply, POST /api/v1/mailboxes/{id}/compose (and Postmark /email) |
15 MB | messaging.limits.send-request |
POST /api/v1/messages/batch (and Postmark /email/batch) |
50 MB | messaging.limits.batch-request |
POST /api/v1/uploads (multipart; the file itself is at most 20 MB) |
25 MB | messaging.limits.upload-request |
POST /mcp |
15 MB | messaging.limits.mcp-request |
| Every other endpoint | 1 MB | messaging.limits.default-request |
Bodies also share the room in memory. On any endpoint, a body of 32 KB or more reserves its size before it is read, so a burst of them takes turns: a body waits up to 10 seconds for room, and with none it is refused with 429 RATE_LIMITED and Retry-After, unread, in the error shape of the API called. Nothing is stored: send it again after those seconds. Smaller bodies never wait. An organization holds at most 48 MB at once; a larger body runs on its own. A body with no Content-Length counts as its endpoint's whole limit, so declaring the length waits less. An organization has at most 800 requests with a body in flight at once, and an address with no credentials 100; past that the answer is 429 at once. Contact imports (POST /api/v1/contact-imports, 200 MB, streamed to storage) run 2 at a time per instance, across organizations, whatever their size, and answer 429 in the same way; a form or a multipart body sent there counts as a body. A refused body over 50 MB may end in a closed connection before the client has sent it all, and so may any body refused unread when an address has many refused in a few seconds. On the endpoints with a limit of their own above, a body with no credentials is refused with 401 UNAUTHENTICATED, unread.
Capabilities
GET /api/v1/me returns the caller's capabilities. The UI uses them to show or hide navigation; the server enforces them on every request.
| Capability | Platform admin | Tenant admin | Member | API key permission |
|---|---|---|---|---|
TENANTS_MANAGE |
yes | – | – | – |
USERS_MANAGE |
– | yes | – | – |
APPS_MANAGE |
– | yes | – | – |
ACCOUNTS_MANAGE |
– | yes | – | – |
DOMAINS_MANAGE |
– | yes | – | – |
MAILBOXES_MANAGE |
– | yes | – | – |
MAILBOXES_READ |
– | yes | own mailboxes | MAILBOXES_READ |
MESSAGES_SEND |
– | yes | – | SEND |
MESSAGES_READ |
– | yes | – | MESSAGES_READ |
INBOX_READ |
– | yes | mailboxes with READ or REPLY |
INBOX_READ |
INBOX_REPLY |
– | yes | mailboxes with REPLY |
INBOX_REPLY |
INBOX_DRAFT |
– | yes | mailboxes with REPLY |
INBOX_DRAFT |
SUPPRESSIONS_READ |
– | yes | – | SUPPRESSIONS_READ |
SUPPRESSIONS_WRITE |
– | yes | – | SUPPRESSIONS_WRITE |
WEBHOOKS_MANAGE |
– | yes | – | – |
ALERTS_READ |
– | yes | – | – |
PLAN_READ |
– | yes | – | – |
AUDIT_READ |
– | yes | – | – |
CONTACTS_MANAGE |
– | yes | – | CONTACTS or CAMPAIGNS |
CAMPAIGNS_MANAGE |
– | yes | – | CAMPAIGNS |
CAMPAIGNS_SEND |
– | yes | – | CAMPAIGNS |
PRIVACY_ERASE |
– | yes (signed in) | – | – |
ORGANIZATION_TERMS |
– | yes (signed in) | – | – |
A platform admin holds only TENANTS_MANAGE: GET /api/v1/me returns "capabilities": ["TENANTS_MANAGE"] for them, and no tenant capability ever reaches them (see Tenancy).
API keys are additionally restricted to their domainIds, streamTypes and mailboxIds (when set). Suppressions belong to a domain's streams, not to a mailbox, so a key with mailboxIds cannot hold SUPPRESSIONS_READ or SUPPRESSIONS_WRITE (400).
A personal token has only the capabilities its user's role and its scopes both allow: mailboxes:read → MAILBOXES_READ, inbox:read → INBOX_READ, inbox:reply → INBOX_REPLY, inbox:draft → INBOX_DRAFT (both reply and draft include inbox:read). A tenant admin's token may also carry the administration scopes: organization:read → PLAN_READ, ALERTS_READ, AUDIT_READ; domains:manage → DOMAINS_MANAGE; addresses:manage → MAILBOXES_MANAGE; apps:manage → APPS_MANAGE, WEBHOOKS_MANAGE; team:manage → USERS_MANAGE; suppressions:manage → SUPPRESSIONS_READ, SUPPRESSIONS_WRITE; campaigns:manage → CONTACTS_MANAGE, CAMPAIGNS_MANAGE; campaigns:send → those and CAMPAIGNS_SEND. No scope ever reaches TENANTS_MANAGE, ACCOUNTS_MANAGE, MESSAGES_SEND, PRIVACY_ERASE or ORGANIZATION_TERMS. The inbox scopes reach the mailbox endpoints (list, get) and the inbox endpoints (threads, bodies, attachments, reply, compose, uploads), within its user's mailboxes and its own mailbox subset; the administration scopes reach the matching administration endpoints (see MCP).
Some changes need a signed-in person, whatever the capabilities, and answer a personal token 403 SIGNED_IN_ONLY (the message names what):
- what cannot be undone:
DELETE /api/v1/domains/{id},DELETE /api/v1/mailboxes/{id},DELETE /api/v1/apps/{id},DELETE /api/v1/apps/{id}/keys/{keyId},POST /api/v1/apps/{id}/webhook-secret/rotate,DELETE /api/v1/users/{id}/tokens/{tokenId}; - administrator rights:
POST /api/v1/userswithrole: TENANT_ADMIN, andPATCH /api/v1/users/{id}changing the role or status of an admin, or making one; POST /api/v1/apps/{id}/keyswith any permission butSEND,SUPPRESSIONS_READ,SUPPRESSIONS_WRITE, andCONTACTSorCAMPAIGNSwhen the token holdscampaigns:manageorcampaigns:send(a key a token creates raisesAGENT_API_KEY_CREATEDto every admin);- routing an address's mail:
PATCH /api/v1/apps/{id}changingwebhookUrlorwebhookEventsof an app that owns addresses or has received their mail (inboundorconversationdeliveries), andPOST/PATCH /api/v1/mailboxeswithappIdorinboundWebhookUrl; PUT /api/v1/apps/{id}/keys/{keyId}/scope: changing what a key reaches;POST /api/v1/alerts/{id}/acknowledgeof anADMIN_TOKEN_CREATED,AGENT_API_KEY_CREATED,AGENT_QUEUE_CANCELEDorAPI_KEY_REACH_WIDENEDnotice;- in Campaigns: releasing an agent hold (
POST /api/v1/newsletters/{id}/release), changing a scheduled newsletter (PUT /api/v1/newsletters/{id}), and the deletes that cannot be undone (DELETEof a list, a contact field, a template, a form, an automation or an image); POST /api/v1/domainswith aproviderAccountId(a domain in the organization's own AWS account).GET /api/v1/dashboardshows a token noaccounts, andGET /api/v1/webhook-deliveries/{id}nopayloadorlastResponseExcerpt(they can name recipients and reference received mail).
Auth and profile
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/auth/options |
– | { "password": true, "code": true, "google": { "enabled": true, "authorizeUrl": "/api/v1/auth/oidc/authorize/google" }, "microsoft": { "enabled": false, "authorizeUrl": "…/microsoft" }, "linkedin": { "enabled": false, "authorizeUrl": "…/linkedin" }, "devSignIn": false } |
| POST | /api/v1/auth/code/request |
{ "email" } |
202 (always, whether or not the email has an account; the code is emailed in the background) |
| POST | /api/v1/auth/code/verify |
{ "email", "code" } |
Me (sets the session cookie); 400 INVALID_CODE for a wrong, used or expired code, or an unknown email; 429 TOO_MANY_CODE_ATTEMPTS once the address's wrong codes of the last 24 hours are used (see codes below) |
| POST | /api/v1/auth/login |
{ "email", "password" } |
Me (sets the session cookie); 403 GOOGLE_SIGN_IN_REQUIRED for a platform admin |
| POST | /api/v1/auth/logout |
– | 204 |
| POST | /api/v1/auth/password-reset/request |
{ "email" } |
202 (always, whether or not the email exists; the email is sent in the background) |
| POST | /api/v1/auth/password-reset/confirm |
{ "token", "password" } |
204; 200 Me for a platform admin's recovery link (POST /api/v1/platform/tenants/{id}/recovery-link), which also signs the person in to its organization whatever its sign-in policy allows (that once, audited there as user.recovery_sign_in); 403 GOOGLE_SIGN_IN_REQUIRED for a platform admin's token. A new password ends every other reset or recovery link of the account and removes its recovery address (the account's email is told) |
| GET | /api/v1/auth/invitations/{token} |
– | { "email", "name", "tenantName", "expiresAt", "termsRequired", "termsVersion", "accountExists", "signedIn" } |
| POST | /api/v1/auth/invitations/accept |
{ "token", "password", "acceptTerms"?, "termsVersion"? } |
Me (a new person: sets the password and signs in); 409 SIGN_IN_TO_ACCEPT when the account already has a way in |
| POST | /api/v1/auth/invitations/join |
{ "token", "acceptTerms"?, "termsVersion"? } |
Me (signed in as the invited account: joins, and the session moves to the organization); 403 INVITATION_FOR_ANOTHER_ACCOUNT for anyone else |
| GET | /api/v1/me |
– | Me |
| PATCH | /api/v1/me |
{ "name"?, "locale"?, "homeMode"? } (locale: pt-PT, en or AUTO; homeMode: SEND, INBOX or AUTO, deprecated) |
Me |
| POST | /api/v1/me/workspace |
{ "tenantId" } |
Me in that organization, under a new session id; 404 for an organization the person is not an active member of |
| POST | /api/v1/me/workspaces/{tenantId}/leave |
– | Me, where the session works now; 409 LAST_ADMIN for its last active admin, 404 for an organization the person is not in |
| POST | /api/v1/me/organizations |
{ "organizationName", "acceptTerms", "termsVersion" } |
201 Me in the new organization, its admin (see signup) |
| GET | /api/v1/me/account |
– | Account: { "email", "password", "identities", "recoveryEmail", "recoveryVerifiedAt", "signedInWith", "lastAdminOf", "confirmedUntil" }, where lastAdminOf names the organizations the account is the last admin of (deleting it is refused until none is left) and confirmedUntil until when this session may change the recovery address (null: confirm first) |
| POST | /api/v1/me/account/confirmation/code |
– | 202: a code goes to the account's own email (never to its recovery address), to confirm it's the person; 429 TOO_MANY_CODES |
| POST | /api/v1/me/account/confirmation |
{ "password" } or { "code" } |
Account, confirmed for 15 minutes; 400 INVALID_PASSWORD (also for an account without one), 400 INVALID_CODE, 429 TOO_MANY_CODE_ATTEMPTS |
| PUT | /api/v1/me/account/recovery |
{ "email" } |
202: a code goes to that address; 403 CONFIRMATION_REQUIRED without a confirmation in the last 15 minutes, 422 RECOVERY_ADDRESS_IN_ORGANIZATION, 422 RECOVERY_ADDRESS_IS_ACCOUNT, 429 TOO_MANY_CODES past the address's or the account's codes |
| POST | /api/v1/me/account/recovery/verify |
{ "code" } |
Account, with the address as recoveryEmail (the account's email is told); 400 INVALID_CODE, 429 TOO_MANY_CODE_ATTEMPTS |
| DELETE | /api/v1/me/account/recovery |
– | Account without it (the account's email is told); 403 CONFIRMATION_REQUIRED |
| POST | /api/v1/me/deletion |
{ "email" } (the account's, to confirm) |
204: the account is deleted and signed out everywhere; 409 LAST_ADMIN with each organization it is the last admin of in details (field: "organization"), 403 PLATFORM_ADMIN_ACCOUNT, 403 SIGNED_IN_ONLY for a token |
| GET | /api/v1/organization/sign-in-policy |
– | { "methods", "available" } (tenant admins) |
| PUT | /api/v1/organization/sign-in-policy |
{ "methods" } (PASSWORD, CODE, GOOGLE, MICROSOFT, LINKEDIN) |
{ "methods", "available" }; 422 SIGN_IN_METHOD_REQUIRED with none this deployment offers, 422 SIGN_IN_METHOD_IN_USE without the session's own; a person signed in only |
The /api/v1/me endpoints need a signed-in session: a personal token gets 403 FORBIDDEN. Me.inboxAvailable says the organization has an address that receives email, Me.sendingAvailable that it has an app or an address that only sends: the web app's menu follows both. Me.homeMode and Me.homeModePreference, and homeMode in the PATCH, are deprecated since 16 October 2026: the web app no longer reads them, and they will be removed. Me.environment.closingGraceSeconds is how long a closed organization waits before its data is deleted (messaging.closure.grace), so a confirmation shows the right date. Me.closing is { "requestedAt", "deleteAfter" } while the organization is being closed (only its admins are signed in then), null otherwise. Me.terms is the version of the terms of service in force; required is true for a tenant admin whose organization has not accepted that version (the web app then shows only the terms screen, after the closing screen; members and platform admins never), and changed when the organization accepted an earlier one.
Google, Microsoft and LinkedIn: the browser navigates to the provider's authorizeUrl; after the round trip it lands on / (signed in), on /signup/organization (an email with no account: see signup) or on /login?error=<code> (OIDC_EMAIL_NOT_VERIFIED, OIDC_ACCOUNT_MISMATCH when the account is linked to another account at that provider, OIDC_FAILED, ACCOUNT_DISABLED, OIDC_WORKSPACE_REQUIRED, SIGN_IN_METHOD_NOT_ALLOWED, SIGNUPS_PAUSED, ORGANIZATION_CLOSING for a member, or an invitation, of an organization being closed). A provider account already linked signs in by its subject; otherwise the email decides, and a provider account joins an existing account (or opens one) only when the provider verified the email: Google's and LinkedIn's email_verified, Microsoft's xms_edov. A person's first way in (a provider, or a code) accepts their invitations; someone who already has one accepts a further invitation with POST /api/v1/auth/invitations/join.
Codes: a 6-digit code by email (to the account's address and its verified recovery address), valid 10 minutes, once, 5 tries; asking and entering are throttled (10 a minute per client address, and per email from one client network, 429 RATE_LIMITED). Platform admins never get one. Caps that a new code doesn't reset, kept in the database for 24 hours as keyed hashes, counted per address and client network (an IPv4 address, an IPv6 /64) so a stranger elsewhere can't lock the owner out: from one network an email address is sent at most 5 codes an hour and 20 in 24 hours (sign-in, signup, recovery and confirmation codes together; recovery codes also count per account, whatever the addresses), and takes at most 20 wrong codes in 24 hours across every kind of code, after which entering one from that network answers 429 TOO_MANY_CODE_ATTEMPTS, a right one included, while a code entered from another network still works; from every network together an address is sent at most 50 codes in 24 hours. Password-reset links have the same per-network budget (5 an hour, 20 a day) and 50 a day from every network, counted apart from the codes. Every try at a code is counted before the code is compared and given back when it was right, so requests at once never pass a code's 5 tries or a network's 20. Someone using at least 3 networks can still use up an address's 50 codes of the day: its owner then gets no code for up to 24 hours and signs in another way meanwhile. Past 100 wrong codes at one address in 24 hours, platform admins get a CODE_GUESSING notice, without the address. The caps count whether or not the address has an account: asking for a sign-in code or a reset past them answers 202 and sends nothing; signup and the recovery address answer 429 TOO_MANY_CODES. Configured by messaging.signup.per-email-per-hour, per-email-per-day, per-email-all-networks-per-day and failed-codes-per-day. A platform admin clears an address's counts (POST /api/v1/platform/code-limits/clear).
Sign-in policy: an organization's admins choose the methods it allows (every one by default). The session records how it was signed in; signing in lands in the last organization that allows it, and entering one that doesn't (signing in, switching, joining) answers 403 SIGN_IN_METHOD_NOT_ALLOWED with each allowed method in details (field: "method"); after a provider, the sign-in page gets them as /login?error=SIGN_IN_METHOD_NOT_ALLOWED&methods=PASSWORD,CODE. A session opened before the release, whose method is not known, enters as before. A platform admin's recovery link is the one way past it, for its one sign-in, so a locked-out admin can change the policy.
// Account
{ "email": "ana@cliente.pt", "password": true,
"identities": [{ "provider": "GOOGLE", "linkedAt": "…", "lastUsedAt": "…" }],
"recoveryEmail": "ana@pessoal.example", "recoveryVerifiedAt": "…", "signedInWith": "PASSWORD" }
Signup
Anyone opens an organization. These endpoints keep CSRF without a session, as every /api/v1/auth/ call does.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/auth/challenge |
– | { "algorithm": "SHA-256", "challenge", "maxnumber", "salt", "signature", "minimumFillMillis" }; 503 SIGNUPS_PAUSED while signups are paused |
| POST | /api/v1/auth/signup |
{ "name", "email", "organizationName", "way": "PASSWORD" | "CODE", "password"?, "acceptTerms", "termsVersion", "plan"?, "period"?, "locale"?, "challenge": { "algorithm", "challenge", "number", "salt", "signature" }, "website" } |
202 { "email", "expiresAt", "resendAfterSeconds" }: a code and a link are emailed |
| POST | /api/v1/auth/signup/resend |
{ "email" } |
202, a new code and link (429 RESEND_TOO_SOON within resendAfterSeconds) |
| POST | /api/v1/auth/signup/verify |
{ "email", "code" } or { "token" } (the link's) |
{ "me": Me, "plan", "period", "passwordNotSet" }: the organization is open and the session signed in to it; passwordNotSet is true when a password was chosen on the form but the email was verified in another browser, so it was not set; 400 INVALID_CODE, 400 INVALID_TOKEN, 429 TOO_MANY_CODE_ATTEMPTS, 429 TOO_MANY_ORGANIZATIONS (the signup stays open) |
| GET | /api/v1/auth/signup/pending |
– | { "email", "name", "provider" } of the provider's identity waiting in this session; 404 with none (30 minutes) |
| POST | /api/v1/auth/signup/complete |
{ "organizationName", "acceptTerms", "termsVersion", "plan"?, "period"? } |
{ "me": Me, "plan", "period" }; 409 SIGN_IN_TO_CONTINUE when the email got an account meanwhile |
- Nothing exists until the email is verified. The code (6 digits, 5 tries) and the link work for 24 hours; a new signup for the same email replaces the open one. Verifying opens, in one transaction, the organization (on the default plan, Free, a new account in probation), the person's account or a new membership of their existing account (which keeps its ways in: the form's password never reaches it), their admin membership, the terms' acceptance on its behalf and the audit entry
tenant.signed_up.planandperiodare the plan picked on the website (solo,team,business;nullfor Free), for the web app to open Checkout (billing).POST /signupsets the HttpOnly cookiemp_signup(path/api/v1/auth/signup, 24 hours) on every202, a dropped bot's and a platform admin's address included (with a token stored nowhere): a password chosen on the form is set only on a new account whose email is verified in a browser holding it, since anyone can type an address and only its owner reads the email. - The challenge is an ALTCHA-style proof of work: find
number(at mostmaxnumber) such thatSHA-256(salt + number)(lowercase hex) ischallenge, and send it back with the rest unchanged. It is signed by the server, expires after 10 minutes and counts once (400 CHALLENGE_FAILEDotherwise).websiteis a field people never see: filled, or a form sent less thanminimumFillMillis(3 seconds) after its challenge was issued, is answered202and dropped; the web app waits that long after receiving the challenge, also when it tries again after an error, and fetches a new challenge for each attempt. - Refusals:
422 DISPOSABLE_EMAIL(a disposable-address service),422 EMAIL_DOMAIN_NO_MAIL(the domain has no MX, A or AAAA record),429 RATE_LIMITED(10 signups per client network an hour),429 TOO_MANY_CODES(5 codes per email an hour and 20 a day from one network, 50 a day from all, see codes above),429 TOO_MANY_ORGANIZATIONS(one person opens at most 10 organizations in 24 hours, by signup orPOST /api/v1/me/organizations;messaging.signup.organizations-per-person-per-day),422 TERMS_ACCEPTANCE_REQUIRED,409 TERMS_VERSION_CHANGED,400 VALIDATION_FAILED(also for anameororganizationNameholding a web address,Leave web addresses out of names: the signup email goes to any address typed in, and invitations name the organization), and503 SIGNUPS_PAUSEDon every one of them (and onPOST /api/v1/me/organizations) while a platform admin paused new signups. - With a provider, the identity waits in a session that is not signed in (
pending) while the person names the organization (complete): the provider verified the email, so there is no code.
Workspaces
One person has one account and can belong to several organizations, each a membership with its own role and status. A session works in one of them, its active workspace:
- Signing in (a password, a code, Google, Microsoft or LinkedIn) lands in the organization the person was in last that allows that way in (sign-in policy). A sign-in is refused, as before, when every organization of theirs is suspended or they are disabled in all (
401 INVALID_CREDENTIALS,ACCOUNT_DISABLEDafter Google), or when they are only a member of organizations being closed (423 ORGANIZATION_CLOSING). With no membership at all, or only invitations to accept, the person is signed in with no organization:Me.tenantandMe.activeTenantarenull,roleisMEMBER,capabilitiesis empty, and every tenant endpoint refuses them. - Switching (
POST /api/v1/me/workspace) moves the session to another of the person's organizations and changes its id (the previousSESSIONcookie stops working). An organization they are not an active member of, one suspended, or one being closed for a member answers404, as any foreign id. - Leaving (
POST /api/v1/me/workspaces/{tenantId}/leave) deletes the membership, with its address access; the person's sessions there stop, their tokens for it are revoked (revokedAtset, listedREVOKED), and the organization's audit log recordsuser.left. It is for good: a membership that comes back later, through a new invitation, starts at a version no earlier session or token carries, so none works again (and a session opened before workspaces is refused once the person has left an organization). The last active admin is refused (409 LAST_ADMIN): they make someone else an admin, or close the organization. Leaving the organization the session works in moves the session to the one the person was in last, or to no organization, under a new session id. - Cutting access is per membership: disabling a person in an organization, changing their role there, or their leaving ends their sessions in it and revokes their tokens for it, and nothing else. A password change still ends every session and token of the person.
Switching, leaving and joining need a signed-in session: a personal token gets 403 FORBIDDEN. Platform admins have no membership: workspaces is empty for them.
Platform admins sign in only with Google, from the platform's Google Workspace (OIDC_WORKSPACE_REQUIRED from any other Google account with the same address). They have no password: POST /api/v1/auth/login answers 403 GOOGLE_SIGN_IN_REQUIRED for them whatever the password, and a reset request sends them nothing (still 202). With dev tools on (local development and tests only; devSignIn in the options), POST /api/v1/dev/sign-in/platform-admin with { "email"? } signs a platform admin in (the bootstrap admin by default) and returns Me; it is 404 otherwise and for anyone who is not an active platform admin.
Password rules: at least 12 characters and at most 72 bytes in UTF-8 (BCrypt's limit); otherwise 400 VALIDATION_FAILED on password.
One-time links: a password-reset link is valid for 1 hour, an invitation for 7 days, and an invitation link a platform admin hands over (or a recovery link) for 24 hours; each works once, and a new one replaces the previous (an invitation: the previous one into the same organization). Resetting a password ends the user's other sessions. An invitation is into one organization; it also stops working once its membership is active (accepted, or a first sign-in with Google), disabled, or while the tenant is suspended. accountExists is true when the invited person already has a way in (a password, a linked Google, Microsoft or LinkedIn account, or a code sign-in): they sign in as usual and accept with join, never with a password (409 SIGN_IN_TO_ACCEPT). signedIn says whether the browser asking is signed in as the invited account (INVITEE), as someone else (ANOTHER_ACCOUNT) or not at all (NONE). An unusable link answers 400 INVALID_TOKEN on both GET /api/v1/auth/invitations/{token} and the accept/confirm calls. termsRequired is true when the invited person is an admin of an organization that has not accepted the terms of service in force (termsVersion): accepting the invitation then needs "acceptTerms": true and that termsVersion (422 TERMS_ACCEPTANCE_REQUIRED without it, 409 TERMS_VERSION_CHANGED for another version), and records the acceptance on the organization's behalf with the password. Only the sign-in throttle (10 attempts per minute per client address and per email, 429 RATE_LIMITED) runs before a reset request answers, so the answer never depends on whether the account exists.
// Me
{
"id": "…", "email": "ana@cliente.pt", "name": "Ana", "role": "TENANT_ADMIN", "locale": "pt-PT",
"tenant": { "id": "…", "name": "Cliente" },
"activeTenant": { "id": "…", "name": "Cliente" },
"capabilities": ["USERS_MANAGE", "…"],
"personalTokensEnabled": true,
"homeMode": "SEND", "homeModePreference": null, "inboxAvailable": false,
"environment": { "simulatedProviderAllowed": true, "devTools": true },
"closing": null,
"terms": { "version": "2026-10-19", "required": false, "changed": false },
"workspaces": [
{ "id": "…", "name": "Cliente", "role": "TENANT_ADMIN" },
{ "id": "…", "name": "Loja Nova", "role": "MEMBER" }
]
}
role is PLATFORM_ADMIN, or the person's role in the active workspace: TENANT_ADMIN or MEMBER. tenant and activeTenant are both the active workspace, the tenant the request operates on; both are null for a platform admin (POST /api/v1/me/active-tenant was removed: platform admins cannot select a tenant) and for a person with no organization to work in. workspaces lists every organization the person can switch to, by name, with their role in each (an active membership of an active organization, or of one being closed for its admins). locale is the person's own language, pt-PT or en, or null when they have not chosen one: the web app then follows the browser (Portuguese for a Portuguese browser, English otherwise), and emails to them use the language of the request that sent them, English otherwise. PATCH with locale: "AUTO" clears the choice. personalTokensEnabled tells whether the user's tenant allows personal tokens (always false for platform admins).
homeMode is the screen the web app opens on: SEND (Home, Activity, Campaigns) or INBOX (Home, Inbox, Activity). homeModePreference is the person's own choice (null: automatic) and inboxAvailable whether the tenant has an address that receives email (INBOUND or BOTH). Members always get INBOX; admins get INBOX once inboxAvailable is true unless they chose SEND, and SEND while it is false.
PATCH with homeMode: "AUTO" clears the preference. Any other value is 400 VALIDATION_FAILED.
Personal tokens
A personal token lets a user connect their own AI agent (over MCP) or script to their mailboxes. It acts as the user, narrowed to its scopes and, optionally, to some of the user's mailboxes. Any active tenant user can create tokens; platform admins cannot.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/me/tokens |
– | PersonalToken[] (the signed-in user's in the active workspace, newest first) |
| POST | /api/v1/me/tokens |
{ "name", "scopes", "mailboxIds"?, "expiresInDays"? } |
201 { "token": PersonalToken, "secret": "mp_pat_…" } |
| DELETE | /api/v1/me/tokens/{id} |
– | 204 (revoke) |
| GET | /api/v1/users/{id}/tokens |
– | PersonalToken[] (tenant admins, USERS_MANAGE) |
| DELETE | /api/v1/users/{id}/tokens/{tokenId} |
– | 204 (a signed-in tenant admin revokes a user's token) |
| GET | /api/v1/oauth/requests/{id} |
– | { "id", "clientName", "clientUri", "redirectHost", "requestedScopes", "expiresAt" }: an agent platform's connect request (the consent screen, /connect/:id) |
| POST | /api/v1/oauth/requests/{id}/approve |
{ "scopes", "mailboxIds"? } |
{ "redirectUrl" }: creates the connection and answers where the browser goes back to the agent, with its one-time code |
| POST | /api/v1/oauth/requests/{id}/deny |
– | { "redirectUrl" }: back to the agent with error=access_denied |
// PersonalToken
{ "id": "…", "userId": "…", "name": "Claude Desktop", "prefix": "mp_pat_Xy12", "scopes": ["inbox:read", "inbox:reply"],
"mailboxIds": null, "expiresAt": "2026-12-28T08:00:00Z", "createdAt": "…", "lastUsedAt": null, "revokedAt": null,
"status": "ACTIVE", "kind": "TOKEN", "appHost": null }
-
kindisTOKEN(made in Settings, with a secret) orOAUTH(an agent platform connected through OAuth); a connection has no secret and no expiry, itsprefixismp_oat_, itsnameis the agent's name andappHostthe host it returns to. It is listed, counted, limited and revoked like any token, and is created only by approving a connect request: the consent endpoints need a signed-in session (a token gets403), validatescopesandmailboxIdsexactly asPOST /api/v1/me/tokens, and an expired, used or unknown request is404 CONNECT_REQUEST_EXPIRED. Unknown scopes the agent asked for are left out ofrequestedScopes. -
scopes⊆mailboxes:read,inbox:read,inbox:reply,inbox:draftand, for a tenant admin only, the administration scopesorganization:read,domains:manage,addresses:manage,apps:manage,team:manage,suppressions:manage(at least one; an administration scope from anyone else is400onscopes). Creating a token with an administration scope raises anADMIN_TOKEN_CREATEDnotice to every admin of the organization.inbox:replyincludesinbox:read(a reply quotes and a forward sends the conversation), so the stored scopes always contain both. Replying and composing also needREPLYaccess to the mailbox (tenant admins have it on every mailbox). -
mailboxIds: nullreaches every mailbox the user can access, now or later; a list must name mailboxes the user can read at creation (400otherwise) and limits the token to them (other mailboxes answer404). -
expiresInDaysis30,90,365, or absent/nullfor no expiry.statusisACTIVE,EXPIREDorREVOKED; a token that a password reset, a role change or a disable has invalidated is reportedREVOKED(it no longer works and no longer counts towards the 20); leaving the organization also sets itsrevokedAt. Revoking twice answers404the second time. -
The token endpoints under
/api/v1/meneed a signed-in session: a token can never create, list or revoke tokens (403). -
A token belongs to the membership it was created in: it works in that organization only, and is listed, counted and revoked there (
GET /api/v1/users/{id}/tokensshows an admin only their organization's tokens of that person; another organization's token answers404). -
A user holds at most 20 active tokens in each organization (
409 TOKEN_LIMIT_REACHED; invalidated tokens do not count), and a session that was authenticated before a password reset, or a role change or disable in that organization, cannot mint one (401); a tenant with personal tokens turned off refuses new ones (409 PERSONAL_TOKENS_DISABLED). -
A token stops working (
401) on the next request once it is revoked or expired, its user is disabled in its organization, changes role there or leaves it, resets their password (every token of theirs), the tenant is suspended, or the tenant turns personal tokens off (it works again if they are turned back on). -
A token is refused on the send API (
POST /api/v1/messages,/batch) and on the Postmark endpoints: users send through their mailboxes (reply, compose). -
Each token has its own rate limit, like an API key; actions taken with it are audited with the user and the token.
Organization data and closing (tenant admins)
A session of one of the organization's admins, never a key or a token. These endpoints also answer while the organization is being closed; personal tokens (/api/v1/me/tokens) and profile changes (PATCH /api/v1/me) don't (423 ORGANIZATION_CLOSING).
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/organization/closing |
– | { "status", "requestedAt", "deleteAfter", "blockedReason" }: status ACTIVE or CLOSING; blockedReason SYSTEM_ORGANIZATION when it can't be closed |
| POST | /api/v1/organization/closing |
{ "confirmName" } |
202 with the same shape as a platform admin's closing |
| DELETE | /api/v1/organization/closing |
– | the same shape: cancels the closing |
| POST | /api/v1/organization/exports |
– | 202 Export (409 EXPORT_IN_PROGRESS while one is being prepared) |
| GET | /api/v1/organization/exports/latest |
– | Export, or 204 when there is none |
| GET | /api/v1/organization/exports/{id}/download |
– | the .zip (application/zip, Content-Disposition: attachment), while READY and not expired; 404 otherwise |
// Export: status QUEUED, RUNNING, READY, FAILED or EXPIRED
{ "id": "…", "status": "READY", "createdAt": "…", "readyAt": "…", "expiresAt": "…", "sizeBytes": 2400000 }
An export is built in the background, streamed straight into storage (any size), and kept for 7 days (messaging.closure.export-expiry, at most 7 days: the bucket's lifecycle removes exports/ after 8), never past the deletion of an organization being closed (its expiresAt is then at most the deletion date; canceling the closing gives it back its full time), then deleted. A build that fails is tried again 5 minutes later, then 10, up to messaging.closure.export-attempts (3) in all, then FAILED; none is built once its organization's deletion is due. The admin who asked is emailed when it's ready; for an export a platform admin's closing started, every admin of the organization is. The archive holds the organization's email as .eml files rebuilt from the stored parts (mail/conversations/<address>/<conversation>/, and app mail by month in mail/app-mail/<yyyy-mm>/), and organization.json, members.json, addresses.json, domains.json, apps.json (no keys or secrets), audit-log.json (the organization's own actions: when, what, on what, and who of it, a person, a key or an AI agent token; never the platform's entries about it, nor details), suppressions.csv, events.csv (details in Mailroomly's words) and usage.csv, with a README.txt in the reader's language. A message stored as it arrived that can't be rebuilt strictly (no sender, an address written badly) is rebuilt leniently; the files of a message flagged as carrying a virus are never included; both, and any message that still can't be rebuilt, are listed in not-included.txt. Requests and downloads are audited (organization.export_requested, organization.export_downloaded). Platform admins get 403: they never read an organization's content.
Erasing a person's data
A signed-in tenant admin (PRIVACY_ERASE), never a key or a token; another organization's data is never reached.
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /api/v1/privacy/erasures/preview |
{ "address" } |
Counts: what erasing would remove; nothing changes |
| POST | /api/v1/privacy/erasures |
{ "address" } |
Counts: what was removed. Running it again is safe and finishes an interrupted one |
| GET | /api/v1/privacy/erasures/summary |
– | { "erasedAddresses" } (SUPPRESSIONS_READ): erased addresses are kept as keyed hashes (and, only while an erasure's sweeps run, encrypted), so they are counted, never listed |
| POST | /api/v1/privacy/erasures/allow |
{ "address" } |
204: email to an erased address is allowed again; 404 when it wasn't erased |
// Counts
{ "messagesDeleted": 8, "conversationsDeleted": 3, "messagesKept": 5, "events": 21, "drafts": 0, "suppressions": 1, "inFlight": 0,
"rawOriginalsLeft": 0 }
address is any email address (case and surrounding spaces ignored; 400 VALIDATION_FAILED otherwise) but the organization's own addresses (its mailboxes and any address on its own domains: 422 OWN_ADDRESS). Messages the person sent, or whose only recipient they were, go whole (messagesDeleted, with their stored bodies, attachments, events and webhook deliveries; conversationsDeleted counts the conversations left empty). Messages others also received stay for them without the person (messagesKept). inFlight counts messages being handed to the provider at that moment, left as they are: the erasure runs again by itself 1 minute, 10 minutes, 1 hour, 1 day and 15 days later, and takes them and anything else still on its way (erasing again yourself also takes mail the person sent since). A received message's original kept in the organization's own AWS account is deleted there; rawOriginalsLeft counts those that could not be yet (the account unreachable): Mailroomly's copies are erased all the same, and every later erasure of the organization retries them (always 0 in a preview). Deleting that account retries them too, and is refused while any is left (409 ACCOUNT_HAS_PENDING_ERASURES); each lapses 62 days after its mail arrived, once the account's storage has expired it. The counts of an erasure are what it did; inFlight is what still names the person (a message being handed to the provider at that moment). From the first step, every send to the address is refused as suppressed and never stores it as a recipient: 422 ALL_RECIPIENTS_SUPPRESSED with no message kept when it was the only recipient, and left out of the stored message otherwise. Audited as privacy.address_erased (counts only) and privacy.erased_address_allowed, without the address.
Terms of service
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/organization/terms |
– | Terms (the organization's people, signed in or through a personal token; never an API key) |
| POST | /api/v1/organization/terms |
{ "version" } |
Terms; a signed-in admin (ORGANIZATION_TERMS) accepts them on the organization's behalf. 409 TERMS_VERSION_CHANGED when version is not the latest |
// Terms
{ "version": "2026-10-19", "accepted": true, "acceptedVersion": "2026-10-15", "acceptedAt": "…", "acceptedByName": "Ana Admin",
"upcoming": { "version": "2026-10-19", "effective": "2026-11-18" } }
version is the latest terms, the ones accepting covers, and accepted whether the organization has nothing to accept (it accepted the version in force, or the latest); acceptedVersion, acceptedAt and acceptedByName are its latest acceptance, of any version (acceptedByName null when none was recorded, as for the organizations that existed when the terms were introduced). upcoming is the latest version while it waits for the UTC day it applies from (effective), null once it applies: until then the earlier version stays in force, nobody is asked to accept, and every organization bound by the earlier version has its admins emailed once. An acceptance meanwhile records both versions. An admin accepts at the invitation or on the terms screen; a new version is accepted at their next sign-in once it applies. The terms include consent before marketing (opt-in only, no bought or scraped lists, sender identified and unsubscribes honored, the acceptable use): a broadcast send from an organization that never accepted any version is refused with 422 BROADCAST_CONSENT_REQUIRED (Postmark ErrorCode 412; MCP BROADCAST_CONSENT_REQUIRED), after the plan's BROADCAST_NOT_IN_PLAN, and one already queued waits (uncounted) until an admin accepts. A new version stops nothing meanwhile, and transactional mail is never affected. Audited as organization.terms_accepted. The /api/v1/organization/broadcast-consent endpoints were removed (only the web app used them).
Users (tenant admins)
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/users |
– | User[] |
| POST | /api/v1/users |
{ "email", "name", "role", "mailboxes"?, "access"? } (TENANT_ADMIN, MEMBER; access as in PUT /api/v1/users/{id}/access, in one transaction, signed-in admins only, with MEMBER, without everything and without mailboxes: otherwise 400) |
User (invitation sent) |
| PATCH | /api/v1/users/{id} |
{ "name"?, "role"?, "status"? } (ACTIVE, DISABLED) |
User |
| GET | /api/v1/users/access |
– | Access[]: what everyone reaches |
| GET | /api/v1/users/access/apps |
– | { "appId", "everyDomain", "domains" }[]: what each app's keys reach |
| GET | /api/v1/users/{id}/access |
– | Access |
| PUT | /api/v1/users/{id}/access |
{ "everything"?, "domainIds"?, "appIds"?, "addresses"?: [{ "mailboxId", "permission" }], "canAdd"?, "campaigns"? } (NONE, PREPARE, SEND) |
Access. Replaces everything the person reaches, in one transaction. Signed-in admins only (403 SIGNED_IN_ONLY to any token). everything: true makes them an administrator and clears what they were given |
| GET | /api/v1/me/access |
– | Access: what the caller reaches (a person, an agent or an API key), the same answer as get_my_access |
| POST | /api/v1/users/{id}/resend-invitation |
– | 204 |
| GET | /api/v1/users/{id}/mailboxes |
– | [{ "mailboxId", "address", "permission" }]; ADMIN for an address on a domain they run |
Access is { "userId", "actingAs", "name", "role", "scope", "domains", "apps", "addresses", "canAdd", "campaigns", "agentScopes", "summary", "signedInOnly" }; scope is EVERYTHING, LIMITED or NOTHING, and an address carries viaDomain when it comes with a domain they run.
A member who runs some domains and apps keeps the role MEMBER: anything outside it is closed to them (403 OUTSIDE_YOUR_ACCESS), and an id outside what they run answers 404. An app that reaches beyond their domains, by a live key, an address it owns or a delivery it holds, keeps its webhook and can't be deleted by them. They send (POST /api/v1/messages) from their own domains only, and with a template only if they have Campaigns. A key belongs to its app: it sends the organization's templates whoever created it. The API keys they create must name domains among their own.
// User
{ "id": "…", "email": "…", "name": "…", "role": "MEMBER", "status": "INVITED", "locale": null, "lastLoginAt": null, "createdAt": "…" }
An admin cannot disable or demote themselves if they are the tenant's last active admin (409 LAST_ADMIN).
A User here is the person as a member of this organization: role, status, createdAt (when they joined) and lastLoginAt (the last time they entered this organization) are the membership's. Inviting an email that already has an account adds a membership to it (no new account, the person's name stays theirs) and emails an invitation to sign in and join; until they accept, the organization's User carries the name its admin typed in the invitation (their email when none was), never the account's own, and a PATCH of name changes that one; an email already in the organization, or a platform admin's, is 409 EMAIL_IN_USE. A PATCH changes the person's name only when they belong to this organization alone; a role or status change ends their sessions here and revokes their tokens for this organization only.
mailboxes grants addresses with the invitation: [{ "mailboxId", "permission" }], with permission READ or REPLY.
- It needs
MAILBOXES_MANAGE. - Every mailbox must be the tenant's: another tenant's id is
400 VALIDATION_FAILEDonmailboxes, like a mailbox that does not exist (as for any id in a request body). - An empty grant, a missing
mailboxIdor an unknown permission is400 VALIDATION_FAILEDonmailboxes. - The user and the grants are created in one transaction: if one grant is refused, nobody is invited.
Apps and API keys (tenant admins)
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/apps |
– | App[] |
| POST | /api/v1/apps |
{ "name", "webhookUrl"?, "webhookFormat"?, "webhookEvents"? } |
App |
| GET | /api/v1/apps/{id} |
– | App |
| PATCH | /api/v1/apps/{id} |
same fields as POST | App |
| DELETE | /api/v1/apps/{id} |
– | 204 (revokes its keys) |
| GET | /api/v1/apps/{id}/webhook-secret |
– | { "secret": "whsec_…" } |
| POST | /api/v1/apps/{id}/webhook-secret/rotate |
– | { "secret": "whsec_…" } |
| GET | /api/v1/apps/{id}/keys |
– | ApiKey[] |
| POST | /api/v1/apps/{id}/keys |
{ "name", "permissions", "domainIds"?, "streamTypes"?, "mailboxIds"?, "dailyRecipientLimit"? } |
{ "key": ApiKey, "secret": "mp_live_…" } |
| PATCH | /api/v1/apps/{id}/keys/{keyId} |
{ "dailyRecipientLimit" } (a number, or null for none) |
ApiKey |
| PUT | /api/v1/apps/{id}/keys/{keyId}/scope |
{ "domainIds", "mailboxIds" } (each a list, or null/absent for no restriction) |
ApiKey |
| GET | /api/v1/apps/{id}/keys/{keyId}/addresses-in-use |
– | AddressInUse[] |
| DELETE | /api/v1/apps/{id}/keys/{keyId} |
– | 204 (revoke) |
| POST | /api/v1/apps/{id}/webhook/test |
– | { "delivered", "statusCode", "durationMs", "error" } |
| POST | /api/v1/apps/{id}/test-message |
{ "from", "subject"?, "text"? } |
202 with the send result |
// App
{ "id": "…", "name": "CRM", "webhookUrl": "https://crm.example/hooks/mail", "webhookFormat": "NATIVE",
"webhookEvents": ["delivered", "bounced", "complained", "opened", "clicked", "inbound", "delivery_delayed", "unsubscribed"],
"webhookVerificationKey": null, "activeKeyCount": 1, "createdAt": "…", "lastUsedAt": null,
"webhook": { "retrying": 0, "dead": 0, "lastFailureAt": null, "lastStatusCode": null }, "requests": { "failedLastHour": 0 } }
// ApiKey
{ "id": "…", "name": "production", "prefix": "mp_live_Ab3d", "permissions": ["SEND", "MESSAGES_READ"],
"domainIds": null, "streamTypes": ["TRANSACTIONAL"], "mailboxIds": null, "dailyRecipientLimit": 500, "createdAt": "…",
"lastUsedAt": null, "revokedAt": null }
webhookFormat is NATIVE, POSTMARK, SENDGRID or MAILGUN (Mailgun compatibility); webhookVerificationKey is the public key a SENDGRID app's events are verified with, null for the other formats (SendGrid compatibility). webhookEvents left out gives the format's default events (webhooks). webhook is how its webhook is doing: deliveries tried and still being retried, deliveries given up in the last 7 days, and the latest failure's time and answer; requests.failedLastHour counts its calls that failed in the last hour (requests). An app's lastUsedAt is the latest use of one of its active keys (null while none has been used); a revoked key no longer counts. permissions ⊆ SEND, MESSAGES_READ, MAILBOXES_READ, INBOX_READ, INBOX_REPLY, INBOX_DRAFT, SUPPRESSIONS_READ, SUPPRESSIONS_WRITE, CONTACTS, CAMPAIGNS (INBOX_DRAFT without INBOX_REPLY: an agent that only leaves drafts for approval; CONTACTS: contacts, lists and forms; CAMPAIGNS: those, templates, images, newsletters and automations, sending included, within the key's domainIds). domainIds: null, streamTypes: null and mailboxIds: null mean "all".
dailyRecipientLimit is the key's own cap on recipients per UTC day (0 or more; null: no limit of its own, the tenant's caps still apply). The PATCH replaces it — an absent field counts as null and removes it; a negative value is 400 VALIDATION_FAILED; audited api_key.limit_updated. See daily send limits.
A key with mailboxIds is contained to those mailboxes. They must be mailboxes of the tenant (on the chosen domainIds, if any), and the key's domainIds become the domains of its mailboxes. The key lists and reads only them (other mailboxes, their threads, bodies and attachments answer 404), sees only their messages in the message log, and sends only with one of them as the From address, and only a mailbox that sends (a receive-only one is refused on compose and reply too) (422 SCOPE_DENIED otherwise, also on an idempotent retry of another mailbox's message). Use it for an agent that handles one mailbox.
PUT /api/v1/apps/{id}/keys/{keyId}/scope changes what the key reaches from its next request; its secret, permissions, stream types and daily limit stay. Both fields are replaced, under the same rules as a new key: mailboxIds must be mailboxes of the tenant (on domainIds, if given) and the key's domainIds become their domains; a key with a suppressions permission can't take mailboxIds; both fields must be present (null for no restriction): a missing one is 400 BAD_REQUEST, and an empty list 400 VALIDATION_FAILED. For a limited member, what they don't run is 400 VALIDATION_FAILED, with one answer whether it exists or not, and a key that reaches beyond them, or holds CONTACTS or CAMPAIGNS, is 403 OUTSIDE_YOUR_ACCESS. Messages already queued still go out. A key's scope changes only with a signed-in person (403 SIGNED_IN_ONLY), audited api_key.scope_updated; a revoked key is 404. Each change that gives the key anything it didn't reach raises API_KEY_REACH_WIDENED to every admin, in place of an earlier one still open.
GET /api/v1/apps/{id}/keys/{keyId}/addresses-in-use says what depends on the key, to warn before taking addresses from it: { "address", "mailboxId", "domainId", "lastSentAt", "ownedByApp" }, for each sender of its last 30 days and each address its app owns. mailboxId is null for a sender that isn't an address of the tenant, domainId for one on none of its domains, and lastSentAt for an address the key didn't send from. Reads aren't traced per address, so an empty list doesn't prove the key is unused. It needs APPS_MANAGE; a revoked key is 404; a limited member gets 403 OUTSIDE_YOUR_ACCESS for a key beyond them, and otherwise only what is on domains they run.
POST /api/v1/apps/{id}/webhook/test sends one signed sample event to the app's webhook URL, synchronously, and reports what the endpoint answered.
- The event is the app's format: the native shape with
"type": "test", or a Postmark Delivery event. - It carries the header
X-Messaging-Event: test. - It passes the same URL safety checks as real deliveries.
- Nothing is stored: no delivery row, no retry.
deliveredis true for a 2xx answer.- It is judged as a real delivery is: the body is read only up to the same short excerpt, and an answer not finished within
messaging.webhooks.timeoutfails (delivered: false), the connection dropped, so a slow or endless body costs nothing.statusCodestill says what the endpoint began to answer, if anything. errorexplains a refused URL, a timeout (no answer withinmessaging.webhooks.timeout) or a connection failure.- At most 10 tests per app per minute on each running instance (counted in memory):
429 RATE_LIMITED. - An app without a webhook URL answers
400 VALIDATION_FAILEDonwebhookUrl.
POST /api/v1/apps/{id}/test-message sends a real test email as the app from the web app, so the app's webhooks get its events as they would for a send with one of its keys.
- It goes only to the signed-in person's own email: a
toin the body is ignored, so it never becomes a way to mail others as the app. - It needs a signed-in session with
APPS_MANAGEandMESSAGES_SEND(a tenant admin); a personal token or an API key gets403. Another tenant's app is404. fromis required (400 VALIDATION_FAILED) and follows the normal send rules (a sending address on a verified domain of the tenant).subjectandtextdefault to an English sample; the web app sends them in the person's language.- It is a transactional message tagged
test, sent and counted like any other (limits, suppressions), and recorded in the message log with the app's name. It is auditedapp.test_message. - The answer is the send's (
202withid,status,submittedAt,to,suppressedRecipients), or422 ALL_RECIPIENTS_SUPPRESSEDwhen the person's address is blocked. - At most 5 test emails per app per minute on each running instance (counted in memory):
429 RATE_LIMITED. - Its webhook deliveries can be followed with
GET /api/v1/webhook-deliveries?messageId=….
Provider accounts (client AWS accounts)
A tenant's own AWS accounts are the enterprise option: POST /api/v1/accounts is refused with 403 CLIENT_ACCOUNTS_DISABLED unless a platform admin enabled clientAccountsEnabled in the tenant's limits. Accounts already connected keep working either way. These endpoints only ever list and return the tenant's own accounts, never the platform's.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/accounts |
– | Account[] |
| POST | /api/v1/accounts |
{ "name", "provider", "awsAccountId"? } |
Account (status: PENDING) |
| GET | /api/v1/accounts/{id} |
– | Account |
| DELETE | /api/v1/accounts/{id} |
– | 204 (409 while domains exist) |
| GET | /api/v1/accounts/{id}/setup |
– | AccountSetup |
| POST | /api/v1/accounts/{id}/verify |
– | { "ok": true, "checks": [Check], "account": Account } |
| POST | /api/v1/accounts/{id}/health-check |
– | Account |
| POST | /api/v1/accounts/{id}/production-access |
{ "mailType", "websiteUrl", "useCaseDescription", "additionalContactEmails" } |
Account |
| GET | /api/v1/accounts/{id}/sandbox-recipients |
– | [{ "email", "status" }] (PENDING, VERIFIED, FAILED) |
| POST | /api/v1/accounts/{id}/sandbox-recipients |
{ "email" } |
201 |
status: PENDING, CONNECTED, FAILED. A tenant can add an AWS account once (409 ACCOUNT_IN_USE); several tenants may have pending entries for the same AWS account, but only one can connect it (the others fail verification with the check ACCOUNT_IN_USE). healthStatus: UNKNOWN, HEALTHY, DEGRADED (sending disabled by SES), FAILED. The hourly health check (and health-check) retries a failed check once; only send-critical failures (ASSUME_ROLE, SES_ACCOUNT, SES sending disabled) pause the account's streams (pausedReason: ACCOUNT_HEALTH), and those streams resume by themselves once the account is healthy. The dispatcher does the same when SES refuses a send because the whole account cannot send (SendingPausedException, AccountSuspendedException): the message goes back to the queue uncounted, the account's streams are paused with ACCOUNT_HEALTH and the critical alert is raised, so queued mail waits instead of failing. enforcementStatus is SES's value (HEALTHY, PROBATION, SHUTDOWN). provider is SES or SES_SIMULATED (the simulated provider is only accepted when Me.environment.simulatedProviderAllowed is true; awsAccountId is then generated). mailType is TRANSACTIONAL or MARKETING.
// Account
{ "id": "…", "name": "Cliente AWS", "provider": "SES", "awsAccountId": "123456789012", "region": "eu-west-1",
"roleArn": "arn:aws:iam::123456789012:role/messaging-platform-access",
"status": "CONNECTED", "healthStatus": "HEALTHY", "healthDetail": null, "lastHealthCheckAt": "…",
"productionAccess": false, "productionAccessRequestedAt": null, "sendingEnabled": true, "enforcementStatus": "HEALTHY",
"maxSendRate": 1.0, "max24HourSend": 200, "sentLast24Hours": 12, "createdAt": "…" }
// AccountSetup
{ "externalId": "…", "platformPrincipalArn": "arn:aws:iam::…:role/…", "region": "eu-west-1",
"cloudFormation": { "quickCreateUrl": "https://eu-west-1.console.aws.amazon.com/cloudformation/home?…", "templateUrl": "https://…/ses-client-baseline.yaml", "stackName": "messaging-platform" },
"openTofu": { "snippet": "module \"messaging_platform\" { … }" },
"expected": { "roleArn": "…", "eventsTopicArn": "…", "inboundTopicArn": "…", "landingBucket": "…" } }
// Check
{ "key": "ASSUME_ROLE", "label": "Assume the access role", "status": "OK", "detail": null }
Check keys: ASSUME_ROLE, SES_ACCOUNT, EVENTS_TOPIC, INBOUND_TOPIC, LANDING_BUCKET, RECEIPT_RULE_SET, and ACCOUNT_IN_USE (added, FAILED, when another tenant already connected the AWS account). Status: OK, WARNING, FAILED.
Domains and streams
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/domains |
– | Domain[] (with streams, without records) |
| POST | /api/v1/domains |
{ "name", "providerAccountId"?, "inboundEnabled", "trackingEnabled" } |
201 Domain |
| POST | /api/v1/domains/starter |
{ "slug" } |
201 Domain (the tenant's starter address) |
| GET | /api/v1/domains/{id} |
– | Domain |
| PATCH | /api/v1/domains/{id} |
{ "inboundEnabled"?, "trackingEnabled"?, "catchAllMailboxId"? } |
Domain |
| POST | /api/v1/domains/{id}/verify |
– | Domain (re-checks DNS and the sending setup now) |
| POST | /api/v1/domains/{id}/dns-instructions |
{ "email" } |
202 (emails the records to whoever manages the DNS) |
| DELETE | /api/v1/domains/{id} |
– | 204 |
| PATCH | /api/v1/streams/{id} |
{ "trackOpens"?, "trackClicks"?, "paused"? } |
Stream |
// Domain
{ "id": "…", "name": "mail.cliente.pt", "sendingMode": "PLATFORM", "kind": "OWN", "ownershipStatus": "VERIFIED",
"provisioning": { "state": "READY", "ready": 3, "total": 3 },
"providerAccountId": null, "providerAccountName": "",
"inboundEnabled": true, "catchAllMailboxId": null,
"status": "VERIFIED", "identityStatus": "VERIFIED", "dkimStatus": "VERIFIED", "mailFromStatus": "VERIFIED",
"dmarcStatus": "VERIFIED", "inboundMxStatus": "VERIFIED",
"trackingEnabled": true, "trackingDomain": "track.mail.cliente.pt", "trackingStatus": "ACTIVE", "trackingDetail": null,
"driftDetected": false, "lastDnsCheckAt": "…", "verifiedAt": "…", "createdAt": "…", "dnsProvider": "CLOUDFLARE",
"records": [ DnsRecord ], "warnings": [ { "code": "EXISTING_MX", "message": "…" } ], "streams": [ Stream ] }
// DnsRecord
{ "purpose": "DKIM", "type": "CNAME", "name": "mpk3v9x2ab._domainkey.mail.cliente.pt",
"value": "mpk3v9x2ab-0192a3b4c5d6.dkim.messaging.example.com",
"priority": null, "required": true, "status": "OK", "observed": ["mpk3v9x2ab-0192a3b4c5d6.dkim.messaging.example.com"],
"established": true, "drift": false }
// Stream
{ "id": "…", "domainId": "…", "type": "BROADCAST", "configurationSet": "mp-<domain id>-broadcast", "trackOpens": true, "trackClicks": true, // null for a platform-managed domain
"paused": false, "pausedReason": null, "pausedAt": null,
"stats": { "windowDays": 14, "sent": 120, "delivered": 118, "bounced": 1, "complained": 0, "bounceRate": 0.0083, "complaintRate": 0.0 } }
-
Stream
statscover the stream's mail sent in the lastwindowDaysdays:sentcounts recipients the sending service accepted (each address once per message, each address once per message, suppressed recipients excluded; for broadcast, only the copies already accepted), anddelivered,bounced(hard bounces) andcomplainedcount those recipients' events, sobounceRate=bounced/sent. The guardrails use the same figures. -
providerAccountIdon create: absent ornullsends the domain through the platform (sendingMode: PLATFORM, the default); the id of one of the tenant'sCONNECTEDAWS accounts sends it through that account (sendingMode: CLIENT, the enterprise option;422 ACCOUNT_NOT_CONNECTEDotherwise). A platform-mode domain hasproviderAccountId: nullandproviderAccountName: "": tenants never see the platform's accounts. Adding one before the pool the tenant would send from has an account is422 PLATFORM_SENDING_UNAVAILABLE; adding one while 10 of the tenant's platform-mode domains (messaging.platform-sending.max-pending-domains; starter addresses do not count) still wait for their ownership proof is422 TOO_MANY_PENDING_DOMAINS; a name under the platform's DKIM zone or starter domain is400; if the platform's DNS zone refuses the domain's DKIM record, nothing is kept and the answer is502 PROVIDER_ERROR. -
dnsProvider(detail only): who serves the domain's DNS, detected from the name servers of the domain or of its nearest parent that has them.- Values:
CLOUDFLARE,GODADDY,ROUTE53,GOOGLE,OVH,NAMECHEAP,PTISP,AMEN,DOMINIOS_PT,HOSTINGER,DIGITALOCEAN,AZURE,GANDI,IONOS,VERCEL,WIXorSQUARESPACE. OTHERfor an unknown host.nullwhen nothing could be looked up, for a starter address, and in the list.- The walk stops at the registrable domain, never at a public suffix such as
com.pt, and once 5 seconds have passed (a lookup already under way still finishes, up to its own 4-second timeout). Answers are cached for an hour, a failed lookup for 2 minutes. Reserved names (.test,.local,.localhost,.example,.invalid) are never looked up.
The web app's DNS assistant uses it to show that panel's tip.
- Values:
-
POST /api/v1/domains/{id}/dns-instructionsemails the records still to add, with how to add them and the DNS host's tip, to the address given, in the requester's language.- Records already in place are only counted ("leave them as they are"). A DMARC record is never asked for when the domain already has or inherits one, nor an inbound MX when the domain already receives mail elsewhere; the email carries those warnings instead, since its reader never sees the web app.
- Nothing left to add:
422 DNS_RECORDS_IN_PLACE. A starter address, whose records the platform publishes:422 DNS_MANAGED_BY_PLATFORM. An organization whose sending the platform paused:422 TENANT_SENDING_PAUSED. - It needs a signed-in user with
DOMAINS_MANAGE. API keys and personal tokens get403. - An invalid email is
400 VALIDATION_FAILEDonemail. - At most 5 per domain and 20 per organization in any hour, counted from the audit log (so across instances and restarts):
429 TOO_MANY_DNS_INSTRUCTIONS. - Each send is audited.
-
kind:OWNorSTARTER.ownershipStatus:NOT_APPLICABLE(client mode),PENDING(the DKIM CNAME is not seen yet: nothing is set up for sending yet),VERIFIED(proven; the domain is being or has been set up).provisioning(platform mode only,nullotherwise):stateWAITING(ownership pending),PENDING,READYorFAILED, withreadyoftotalplatform accounts done (an account kept only for mail still queued there or for the release grace does not count); it never names them. A new domain with the name of one just deleted staysPENDINGin each account until the earlier domain's identity is removed there. -
POST /api/v1/domains/starter(DOMAINS_MANAGE) creates<slug>.<starter domain>with inbound on and only a transactional stream; the platform publishes its records, so it is owned at once.slug: 3 to 30 lower-case letters, digits or hyphens, not starting or ending with a hyphen, not a reserved word (400); one per tenant (409 STARTER_ADDRESS_EXISTS); unique across tenants (409 STARTER_ADDRESS_TAKEN; two requests racing for one slug get one address and one of these). Creation is atomic: if the platform's DNS refuses a record, what was written is removed and the answer is502 PROVIDER_ERROR. Its DMARC record isv=DMARC1; p=reject;. Its inbound cannot be turned off (422 STARTER_INBOUND_REQUIRED), it keeps the default, shared tracking domain (turningtrackingEnabledon is422 STARTER_TRACKING_NOT_AVAILABLE), and it is capped at 100 recipients per day. -
Domain
status:PENDING,VERIFIED,FAILED. Per-check statuses:PENDING,VERIFIED,FAILED,NOT_APPLICABLE. A platform-mode domain isVERIFIEDwhen its identity and DKIM are verified in every platform account its streams send through. -
Record
purpose:DKIM,MAIL_FROM_MX,MAIL_FROM_SPF,DMARC,INBOUND_MX,TRACKING_CERTIFICATE,TRACKING_CNAME. A platform-mode domain has oneDKIMrecord (a CNAME from<selector>._domainkey.<domain>to the platform's DKIM zone) instead of three, and noTRACKING_CERTIFICATErecord: itsTRACKING_CNAMEpointstrack.<domain>to the platform's tracking routing endpoint. Recordstatus:PENDING(not found),OK,MISMATCH(a different value is published),NOT_APPLICABLE.established: the record wasOKat or after the domain's verification.drift: an established record is now missing or different;driftDetectedis true when any record hasdrift. When a DNS lookup fails (timeout,SERVFAIL), the record keeps its previous status and flags. Records stored before these flags existed return them asnulluntil the next check. -
Warning codes:
EXISTING_MX(the domain already receives mail elsewhere; enabling inbound would take it over — use a subdomain),EXISTING_DMARC(keep the existing record),RECEIPT_RULE_SET_CONFLICT(inbound is on butmessaging-platformis not the account's active receipt rule set, so SES does not hand over inbound mail;inboundMxStatusstaysPENDING),PLATFORM_INBOUND_UNAVAILABLE(platform mode: the platform's transactional account is not receiving because its rule set is inactive; the platform is fixing it and the tenant has nothing to do;inboundMxStatusisPENDINGalthough the MX record is right, and returns toVERIFIEDby itself),DOMAIN_CLAIMED(another tenant verified or proved this name first; a client-mode domain isFAILED, a platform-mode one stays with ownershipPENDING; neither can send). -
A tenant can add a name once (
409 DOMAIN_IN_USE), and a name another tenant already verified or proved is refused the same way; other tenants' unverified rows with the same name do not block it. When the SES identity already exists in the AWS account without the tagmanaged-by=platform, creation fails with409 IDENTITY_NOT_MANAGEDand the identity is left untouched. -
Enabling inbound (on create or PATCH) fails with
409 RECEIPT_RULE_SET_CONFLICTwhen another SES receipt rule set is active in the AWS account (SES allows only one active set per region); deactivate it or move its rules intomessaging-platformfirst. Tracking setup failures leave the domain usable withtrackingStatus: FAILEDand the reason intrackingDetail(toggle tracking off and on to retry). Turning tracking off keeps the certificate and the CloudFront distribution, which are reused when it is turned on again. -
trackingStatus:DISABLED,PENDING_CERTIFICATE,PENDING_DISTRIBUTION,ACTIVE,FAILEDin client mode;DISABLED,PENDING_DNS(waiting for the domain's ownership proof and thetrack.<domain>CNAME; nothing is set up before the proof),PENDING_CERTIFICATE,ACTIVE,FAILEDin platform mode.trackingDetail: why the tracking setup failed, otherwisenull. A starter address has no custom tracking domain (422 STARTER_TRACKING_NOT_AVAILABLE). Deleting a platform-mode domain disables its tracking setup; the platform deletes it later by itself. The name added again with tracking staysPENDING_DNS, with atrackingDetailsaying the deleted domain's tracking setup still holds it, until that setup is gone, then proceeds by itself. -
DELETEof a platform-mode domain answers204at once and frees the name immediately: its records in the platform's zones are deleted in the request, its DKIM key last (502 PROVIDER_ERRORif that fails; the domain stays, still signing, and the request can be repeated), and the platform removes its resources from its accounts in the background, with retries: its sending identity at once, the rest 3 days later (late events of its mail are still published). Mail it already sent through the platform stays in the message log, outside any conversation, until retention removes it; the same holds for a deleted mailbox. -
The receipt rule set errors and warnings (
RECEIPT_RULE_SET_CONFLICT) andIDENTITY_NOT_MANAGEDconcern client mode only; a platform-mode domain getsPLATFORM_INBOUND_UNAVAILABLEinstead. -
Stream
pausedReason:MANUAL(paused by hand),AUTO_GUARDRAIL(per-stream guardrails),ACCOUNT_HEALTH(the account cannot send; resumes by itself), and for platform-mode streamsAUTO_TENANT_GUARDRAIL(the tenant's guardrails for that stream type),AUTO_RAMP(a broadcast wave bounced too much),REPUTATION_PAUSED(paused for the tenant's bounce or complaint rates; resumes on its own once sending is allowed again, wherever the tenant still sends). A tenant admin resuming a platform-mode stream paused for any reason butMANUALgets403 PAUSE_LOCKED: only a platform admin lifts the platform's protections, from outside the tenant . A per-tenant guardrail, ramp or reputation pause replaces aMANUALorACCOUNT_HEALTHpause of a platform-mode stream (and a guardrail or ramp pause replacesREPUTATION_PAUSED); a replacedMANUALpause is remembered, so once the automatic pause is lifted the stream stays paused withMANUALuntil the tenant resumes it. Pausing a stream already paused automatically changes nothing. Resuming closes the related pause alerts; it restarts the guardrail window only when it lifts a pause other thanMANUAL.
Mailboxes and members
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/mailboxes |
– | Mailbox[] (members: only mailboxes they can access) |
| POST | /api/v1/mailboxes |
{ "domainId", "localPart", "direction", "appId"?, "inboundWebhookUrl"?, "displayName"?, "signatureHtml"?, "signatureText"? } |
Mailbox |
| GET | /api/v1/mailboxes/{id} |
– | Mailbox |
| PATCH | /api/v1/mailboxes/{id} |
same fields except domainId/localPart, plus agentTimeoutMinutes (null clears) |
Mailbox |
| DELETE | /api/v1/mailboxes/{id} |
– | 204 |
| GET | /api/v1/mailboxes/{id}/members |
– | [{ "userId", "name", "email", "permission" }] |
| GET | /api/v1/mailboxes/{id}/assignees |
– | [{ "userId", "name", "email", "permission" }]: whom a conversation can be assigned to (active admins, members with REPLY); for callers with reply access |
| PUT | /api/v1/mailboxes/{id}/members/{userId} |
{ "permission" } (READ, REPLY) |
204 |
| DELETE | /api/v1/mailboxes/{id}/members/{userId} |
– | 204 |
// Mailbox
{ "id": "…", "address": "suporte@mail.cliente.pt", "localPart": "suporte", "domainId": "…", "domainName": "mail.cliente.pt",
"direction": "BOTH", "appId": null, "inboundWebhookUrl": null, "displayName": "Suporte Cliente",
"signatureHtml": "<p>--<br>Suporte</p>", "signatureText": "--\nSuporte",
"access": "ADMIN", "unreadCount": 3, "createdAt": "…", "hasAgents": true, "agentTimeoutMinutes": 30, "receiving": true }
direction: INBOUND, OUTBOUND, BOTH. receiving: whether mail reaches it now. An address keeps its direction while its domain only sends (inboundEnabled: false) and is receiving: false until the domain receives again. unreadCount: the Inbox conversations unread for the caller (capped at 100: show "99+"). access is the caller's access: ADMIN, REPLY, DRAFT (an agent that may only leave drafts) or READ. hasAgents: an agent may reply or leave drafts here; agentTimeoutMinutes: how long an agent may hold a conversation it claimed before it needs a person (15, 30, 60 or 240; null: until it replies; see agents and people). A mailbox with an inboundWebhookUrl needs an appId: the owning app's secret signs the webhook. Mailboxes can only be created on verified domains (400 VALIDATION_FAILED on domainId otherwise), except a starter address, which is the tenant's from creation (the platform publishes its records): its mailboxes can be created while it is still being verified, so the web app creates ola@ (or hello@) right after the free address. Create, direction changes and delete update the domain's inbound routing in the same transaction: if the sending service refuses, nothing changes and the response is 502 PROVIDER_ERROR.
Inbox
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/mailboxes/{id}/threads?filter=&q=&cursor=&size= |
– | { "items": ThreadSummary[], "nextCursor" } |
| GET | /api/v1/threads/{id} |
– | Thread |
| PATCH | /api/v1/threads/{id} |
{ "read"?, "archived"?, "spam"? } |
ThreadSummary |
| POST | /api/v1/threads/bulk |
{ "ids": [..], "read"?, "archived"?, "spam"? }, or { "ids": [..], "assign": { "userId" } } on its own (null: back to the agents; a signed-in person) — up to 100 |
{ "updated": 2 } |
| POST | /api/v1/mailboxes/read |
{ "mailboxIds"? } (none: every address the caller can open) |
{ "markers": [{ "mailboxId", "readAt" }] } (the marks replaced) |
| POST | /api/v1/mailboxes/{id}/read |
– | as above, for one address |
| PUT | /api/v1/mailboxes/read-markers |
{ "markers": [{ "mailboxId", "readAt" }] } |
204 (puts the replaced marks back: Undo) |
| GET | /api/v1/recipients/suggest?q= |
– | up to 8 [{ "address", "name", "teammate", "suppressed" }] |
| GET | /api/v1/correspondents?address= |
– | { "correspondent": { "address", "name", "sentCount", "receivedCount", "firstAt", "lastAt" } | null, "threads": ThreadSummary[] } (404 when the caller's addresses never wrote with them) |
| GET | /api/v1/messages/{id}/body?images=blocked |
– | { "html": "…", "text": "…", "remoteImagesBlocked": true } |
| POST | /api/v1/threads/{id}/reply |
Reply |
{ "messageId", "threadId" } |
| POST | /api/v1/mailboxes/{id}/compose |
Compose |
{ "messageId", "threadId" } |
| GET | /api/v1/mailboxes/{id}/thread-counts |
– | { "needsYou": 2, "withAgent": 5, "mine": 1, "spam": 0 } (each capped at 1000: show "999+") |
| GET | /api/v1/threads/assigned |
– | up to 10 ThreadSummary: what the caller holds that awaits a reply, in the addresses they can open |
| POST | /api/v1/uploads |
multipart file |
{ "id", "filename", "contentType", "sizeBytes" } |
| GET | /api/v1/attachments/{id} |
– | file download (inline: ?disposition=inline); 409 VIRUS_DETECTED for mail flagged as a virus |
| POST | /api/v1/dev/mailboxes/{id}/simulate-inbound |
{ "from", "fromName"?, "subject", "text", "html"?, "inReplyToMessageId"? } |
201 { "messageId", "threadId" } (non-prod only; 409 SIMULATED_ACCOUNT_REQUIRED for a mailbox on a real account, 409 MAILBOX_DOES_NOT_RECEIVE for a send-only mailbox) |
| POST | /api/v1/dev/inbound?to=<mailbox address> |
raw message, Content-Type: message/rfc822 |
202 { "objectKey" } — filed asynchronously by the inbound queue consumer (non-prod only) |
filter, the boxes:INBOX(the default: everything that came in, not archived, not spam),SENT(the address wrote in it, ordered by its latest send, archived ones too),ARCHIVED,SPAM; and inside the Inbox:NEEDS_YOUandWITH_AGENT(who has the conversation),UNCLAIMED(awaiting an agent's reply with no agent on it: what an agent may claim),MINE(the caller holds it),ANSWERED(the latest message that counts went out),NEEDS_REPLY(the latest message that counts came in, whoever has it),UNREAD. An unknown filter is400. What counts: received mail a person wrote (not an automatic reply, a delivery report, bulk mail, a no-reply sender or spam: those carry alabelor sit inSPAM), and mail that went out and reached someone (not failed, refused, suppressed, or bounced for every recipient; a reply whose customer copy was delivered counts even if a Cc bounced); a forward counts. The web app opens onNEEDS_YOU, and the MCPlist_threadstool takes the same values.qsearches the subject, the participants and the latest message's beginning (substring, case-insensitive).- Paging by keyset: the first page names no
cursor, the next ones the previous page'snextCursor(nullon the last page), so new mail never shifts them. Nothing is counted: the counters come fromthread-counts.size: 1–100 (25). - Read state is the caller's own (
403for an app's key): a conversation is unread when received mail (not spam), a colleague's or an agent's reply reached Mailroomly after the caller last read it (mail that waited in the queue counts from when it was filed, not from when the provider received it). The caller's own reply never makes it unread, nor does an app's send that starts a conversation. "Mark all as read" marks everything up to now in the addresses given; a later mark on one conversation (read: false) wins over it, and the other way round. Archiving and spam need reply access.spam: truemoves conversations to the Spam box,falsemoves them back and makes their received messages count as a person's. Bulk changes are all or nothing: an id the caller cannot see (another organization's, or in an address they cannot open) is404and nothing changes. - Suggestions: from 2 characters, the caller's teammates (only for a signed-in person), then the people the addresses they can open wrote to or received from (never the organization's own addresses, spam, automatic or bulk senders), matched by prefix on the address, its domain or a word of the name (lower case, without accents; every typed word must match), ranked by how often and how recently those addresses wrote with them.
suppressed: the address is blocked for the organization. Erasing an address removes it from suggestions; correspondents with no activity within the organization's retention are purged. images:blocked(default) replaces remote images with placeholders;allowedkeeps them. Scripts, event handlers, forms and iframes are always removed.- Body
htmlis the sanitized fragment (no<html>,<head>or<body>): wrap it in your own document (the web UI renders it in a sandboxed frame with its own CSP). Inline images (cid:) point to relative, same-origin links/a/inline/<token>(signed; identical for a UTC day so browsers cache them; valid one to two days); resolve them against the platform URL. Images embedded asdata:image/…stay; relative image sources written in the email itself are removed. A message flagged as a virus shows no inline images. - Attachment downloads are
application/octet-streamwithContent-Disposition: attachment, unless?disposition=inlineis asked for a PNG, JPEG, GIF, WebP or PDF; every response carriesX-Content-Type-Options: nosniffandContent-Security-Policy: sandbox; default-src 'none'. The files of a message whosevirusVerdictisFAILare refused with409 VIRUS_DETECTED, here and on the public/a/…links. - Uploads expire after 24 hours when not attached; max 20 MB each (
413 PAYLOAD_TOO_LARGEbeyond that). A reply or new message must still fit the send limit of 10 MB in total — bodies plus attachments after base64 encoding (a third more) — so the largest file that can actually be sent is 7.5 MB. The web composer checks both limits before uploading and sending.
// ThreadSummary
{ "id": "…", "mailboxId": "…", "subject": "Fatura 123", "participants": ["joao@exemplo.pt"], "snippet": "Bom dia, …",
"lastActivityAt": "…", "messageCount": 3, "unread": true, "archived": false, "hasAttachments": true,
"handledBy": "AGENT", "handledByUserId": null, "handledByName": null, "handoverAgent": null, "handoverNote": null,
"lane": "WITH_AGENT", "pendingDraft": false, "agentOverdue": false, "latestDraftStatus": null,
"label": null, "replyFailed": false, "spam": false, "inInbox": true, "lastSentAt": null,
"claim": { "agent": "Helpdesk", "claimedAt": "…", "expiresAt": "…", "active": true, "yours": false } }
// claim: the agent working on it (null when none): since claimedAt until expiresAt (null: until it replies), active while
// it holds it, yours when the caller's app or token does.
// label: AUTOMATIC or BULK when the latest received message is an automatic reply or bulk mail; replyFailed: the
// address's latest reply reached nobody; inInbox: something came in (otherwise the conversation is only in SENT).
// Thread (also the ThreadSummary handoff fields, "claim", "handledByUserId", "handledChangedAt", "agentsReach", "draft",
// "spam", "label" and "replyFailed")
{ "id": "…", "mailboxId": "…", "subject": "…", "participants": [...], "archived": false, "unread": true,
"agentsReach": true, "draft": null,
"messages": [ {
"id": "…", "direction": "INBOUND", "fromAddress": "joao@exemplo.pt", "fromName": "João",
"to": ["suporte@mail.cliente.pt"], "cc": [], "replyTo": ["maria@exemplo.pt"], "subject": "…", "occurredAt": "…",
"status": "RECEIVED", "snippet": "…", "hasHtml": true, "messageIdHeader": "<…>", "sentByUserName": null,
"agentName": null, "approvedByName": null,
"spamVerdict": "PASS", "virusVerdict": "PASS", "tracked": false,
"attachments": [ { "id": "…", "filename": "fatura.pdf", "contentType": "application/pdf", "sizeBytes": 12345, "inline": false } ]
} ] }
// Reply
{ "mode": "REPLY", "to": [], "cc": [], "bcc": [], "html": "<p>Olá</p>", "text": null, "attachmentIds": [], "forwardAttachmentIds": [],
"agentCarriesOn": false }
// Compose
{ "to": ["joao@exemplo.pt"], "cc": [], "bcc": [], "subject": "…", "html": "<p>…</p>", "text": null, "attachmentIds": [],
"agentCarriesOn": false }
ThreadMessage fields:
occurredAt: for received mail, the sender'sDateheader — display only. Messages are ordered by when the platform received them (the time it was received) or accepted them for sending; the thread'slastActivityAtand the retention period use that time too, so a forwarded or resent old message is neither purged early nor sorted into the past.replyTo: the addresses of theReply-Toheader;[]when absent.spamVerdict,virusVerdict: the inbound scan's verdictsPASS,FAIL,GRAYorPROCESSING_FAILED;nullfor outbound mail. WithvirusVerdict: FAILthe message and its attachment list are shown, but its files are never served (409 VIRUS_DETECTED), its body embeds no inline images, and MCPget_threadwithholds its text and attachments.tracked: the message tracks opens or clicks. Mail written in the inbox never does; mail an app sent into the conversation may.sentByUserName: the person who sent it (or whose personal token did);agentName: the agent that wrote it, its app or a personal token with its user ("Claude (Ana Lima)");approvedByName: the person who approved it as a draft.
mode: REPLY, REPLY_ALL, FORWARD. The reply answers the latest message of the thread (in arrival order). For REPLY/REPLY_ALL the server fills to when it is empty: the parent's Reply-To addresses, otherwise its sender (for a parent sent from the mailbox, its recipients); REPLY_ALL also fills cc. FORWARD requires to. Replying needs reply access to the thread's mailbox (for API keys INBOX_REPLY and a domain scope that includes the mailbox's domain) — INBOX_READ is not required for API keys and sessions; a personal token's inbox:reply scope always includes inbox:read, which a reply from a token requires. An agent's reply is refused with 409 CONVERSATION_WITH_PERSON while a person has the conversation; a person's reply takes it over unless agentCarriesOn is true (agents and people). The mailbox signature and the quote are appended by the server: the quote is the stored original made safe for sending (remote images kept), and the inline images it shows travel as inline parts — in order, only images of at most 2 MB and only while they fit what the new text, the quote, uploads and forwarded files leave of the 10 MB limit; the others are left out of the quote (their alt text stays), so a reply to mail with large photos still goes out. FORWARD also attaches the parent's other files (forwardAttachmentIds selects them; omitted means all; the inline images the quote carries inline are never attached again), and attaches the quoted inline images left out of the quote as regular attachments — in order, while they fit what remains of the 10 MB limit (encoded size, 4/3 of the file), whatever forwardAttachmentIds selects; a reply leaves them out. A message flagged as a virus is quoted without its images, and none of its files are forwarded. A text-only reply (text without html) is also rendered, escaped, in the HTML part. Replies and compose always go through the transactional stream, never tracked (no invisible image, no rewritten link, whatever the stream's settings), with correct In-Reply-To/References (for an outbound parent, the Message-ID it was sent with).
Agents and people: drafts and handoff
Agents (an app's API key, or a user's personal token, which is that user's own agent) have free transit: with reply permission they send directly and nothing needs approval. An agent claims the conversations it works on, so people see which are in hand; it may leave a draft or hand a conversation over, and people have one control. The client decides how its agents use them, through each key's or token's permissions.
| Method | Path | Who | Body | Response |
|---|---|---|---|---|
| POST | /api/v1/threads/{id}/drafts |
agents with INBOX_DRAFT or INBOX_REPLY |
{ "mode"?, "to"?, "cc"?, "bcc"?, "text"?, "html"?, "reason"? } |
201 Draft |
| GET | /api/v1/drafts |
INBOX_READ |
– | pending Drafts of the caller's addresses (no bodies) |
| POST | /api/v1/drafts/{id}/send |
a signed-in person with reply access | { "to"?, "cc"?, "bcc"?, "text"?, "html"? } or nothing |
202 { "messageId", "threadId" } |
| POST | /api/v1/drafts/{id}/discard |
a signed-in person with reply access | { "takeOver"? } |
204 |
| POST | /api/v1/threads/{id}/claim |
agents with INBOX_DRAFT or INBOX_REPLY |
{ "minutes"? } |
ThreadSummary |
| DELETE | /api/v1/threads/{id}/claim |
agents with INBOX_DRAFT or INBOX_REPLY |
– | ThreadSummary |
| POST | /api/v1/mailboxes/{id}/threads/claim-next |
agents with INBOX_DRAFT or INBOX_REPLY |
{ "minutes"? } |
ThreadSummary, or 204 when nothing waits |
| POST | /api/v1/threads/{id}/hand-over |
agents with INBOX_DRAFT or INBOX_REPLY |
{ "note"?, "assignee"? } (a teammate's email) |
ThreadSummary |
| POST | /api/v1/threads/{id}/assign |
a signed-in person with reply access | { "userId" } (null: back to the agents) |
ThreadSummary |
| POST | /api/v1/threads/{id}/take-over |
a signed-in person with reply access | – | ThreadSummary |
| POST | /api/v1/threads/{id}/hand-back |
a signed-in person with reply access | – | ThreadSummary |
- Claims. An agent claims a conversation before it works on it: it is then
WITH_AGENTwith the agent's name (claim), and other agents may read it but not claim, reply, leave a draft or hand it over (409 CONVERSATION_CLAIMED, naming the agent and until when; a reply orPOST /api/v1/messagesthreading into it is refused the same way and nothing is sent). One agent is one app (any of its keys) or one personal token. A claim lasts the address'sagentTimeoutMinutes, orminuteswhen shorter (more is capped; with no wait,null, it lasts until the agent replies or lets go, unlessminutesis given, at most 1440); claiming again renews it and keepsclaimedAt. It ends when any reply goes out from the address in the conversation, when the holder leaves a draft or hands the conversation over, onDELETE(only the holder's own claim; anyone else's is left as it is), when a person takes the conversation over, is assigned it or replies, when its time runs out, and as soon as its key or token no longer reaches the address as an agent (revoked or expired, a personal token whose person lost reply access to the address or whose authorization changed, personal tokens turned off for the organization, the app deleted). A claim only puts a conversation with the agent while it awaits a reply with no pending draft; a claim on an answered conversation waits for the next message. A conversation a person has cannot be claimed (409 CONVERSATION_WITH_PERSON); people never claim (403), they take over.claim-nextclaims the address's conversation that has waited longest with nobody on it (theUNCLAIMEDfilter), atomically: two agents never get the same one, and a conversation being claimed at that moment is skipped. Every decision on a claim holds the conversation's row, as a take-over and a send do, so a claim never lands on a conversation a person just took, and no other agent's reply slips past a live claim. Claiming is optional: an agent's reply without a claim is accepted as before, but the conversation stays inNEEDS_YOUuntil then. Claims are not audited. - Drafts. An agent leaves a reply for approval instead of sending it, with an optional
reason(up to 280 characters, shown to the person: "Held because it offers a refund"). It takes the reply's fields; emptytois filled as for a reply. A conversation has at most one pending draft: a newer one supersedes it (SUPERSEDED), and so does mail that goes out in the conversation — a reply, a compose orPOST /api/v1/messagesthat threads into it — in the same transaction (approving the older draft would answer twice). A draft is allowed even while a person has the conversation, not while another agent claims it. Its body is kept in object storage only while it is pending; a pending draft older than the organization's retention is deleted, also in a conversation still active (noconversationevent is sent for it). A session cannot create one (people's drafts stay in their browser):403. - Deciding. Only a signed-in person who may reply from the address decides a draft: send it as the agent wrote it, send it edited (any field given replaces the agent's; with no field it goes out exactly as stored, its own text part and images included, and is not marked edited), or discard it (
takeOver: truealso takes the conversation over, the web app's "I'll reply"). An agent or a personal token is refused (403), so an agent never approves a draft. The sent message is the agent's (its app'sappId, so the app receives its delivery webhooks), withapprovedByNameset. It answers the message the agent drafted against (parentMessageId: what it quotes, itsIn-Reply-To, and for a forward whose files go with it), even when newer messages arrived since (stale). Two people deciding at once: one wins, the other gets409 DRAFT_ALREADY_DECIDED, which any decision on a draft that is no longer pending also gets. Each decision is audited (draft.created,draft.sentwithedited,draft.discarded). - Who replies. A conversation is with the agents (
handledBy: AGENT) until a person takes it over or an agent hands it over (PERSON; an agent's hand-over leaves a person's take-over as it is). While a person has it, an inbox agent cannot put mail into it, whatever the path — a reply, a forward, a compose orPOST /api/v1/messageswhoseIn-Reply-To/Referencesor "Re:" subject threads into it:409 CONVERSATION_WITH_PERSON, and nothing is sent; drafts are still accepted. An inbox agent is a personal token, or an API key withINBOX_REPLYorINBOX_DRAFT; a key that only sends (an invoice from the same address) is not refused. Only a person hands it back. Assigning gives the conversation to a teammate who may reply from its address (an active admin, or a member withREPLY): they hold it as if they had taken it over (handledByUserId, theMINEfilter); themselves is a take-over,nullhands it back; anyone else is404. An agent's hand-over with anassigneeemail hands it to that teammate (404for anyone else), unless a person already holds it. Audited asthread.assigned. A person's own reply or new email (compose) takes the conversation over in the send's own transaction (so no agent reply lands in between), unless it setsagentCarriesOn: true, and only where an agent reaches the address. Audited asthread.taken_over,thread.handed_over(with the agent and its note) andthread.handed_back. - Lanes. A conversation awaiting a reply is
WITH_AGENTwhile an agent claims it, no person has it and no draft is pending; otherwise it isNEEDS_YOU, also where agents reach the address.agentOverduesays an agent claimed it and its time ran out (or its key or token went) before it answered. An agent reaches an address when an active API key or a working personal token that may reply or leave drafts includes it (its domain and mailbox restrictions; a token as its user does: an admin everywhere, a member where they may reply); the mailbox'shasAgentssays so, andagentTimeoutMinutes(15, 30 by default, 60 or 240;null: no limit) is how long an agent may hold a conversation there. A pending draft always puts the conversation inNEEDS_YOU. Computed when the list is read from what the conversation stores about its messages (kept current by the database on every write): nothing runs in the background. - Webhooks. An app that subscribes to the
conversationevent is told when its draft was sent, discarded or superseded, when a conversation was handed back (the app that handed it over, the one that last sent in it and the address's owning app), and when a person ended its claim (claim_ended) (webhooks).
// Draft
{ "id": "…", "threadId": "…", "mailboxId": "…", "mailboxAddress": "suporte@mail.cliente.pt", "subject": "Reembolso",
"mode": "REPLY", "to": ["joao@exemplo.pt"], "cc": [], "bcc": [], "reason": "Held because it offers a refund",
"agentName": "Helpdesk", "status": "PENDING", "createdAt": "…", "decidedAt": null, "decidedByName": null, "edited": false,
"sentMessageId": null, "stale": false, "text": "Devolvemos os 20 € hoje.", "html": null, "remoteImagesBlocked": null,
"parentMessageId": "…" }
text/html (sanitized for display, remote images blocked; remoteImagesBlocked says the sent message will carry some) come only with the pending draft of GET /api/v1/threads/{id}; stale says a message came in after the draft was written.
Messages
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /api/v1/messages |
SendRequest |
202 SendResult |
| POST | /api/v1/messages/batch |
SendRequest[] (≤ 500) |
200 [BatchItemResult] |
| GET | /api/v1/messages?q=&recipient=&tag=&messageId=&status=&stream=&direction=&since=&until=&from=&appId=&apiKeyId=&scope=&page=&size= |
– | page of MessageSummary, with totalCapped |
| GET | /api/v1/messages/{id} |
– | MessageDetail |
| POST | /api/v1/messages/{id}/resend |
{ "to"?, "idempotencyKey"? } |
202 SendResult (200 on an idempotent replay) |
// SendRequest
{
"channel": "email",
"from": "Faturas <faturas@mail.cliente.pt>",
"to": ["joao@exemplo.pt", "Maria <maria@exemplo.pt>"], "cc": [], "bcc": [], "replyTo": [],
"subject": "A sua fatura", "html": "<p>…</p>", "text": "…",
"stream": "transactional",
"tags": ["invoice"], "metadata": { "invoiceId": "123" },
"headers": [ { "name": "X-Custom", "value": "1" } ],
"attachments": [ { "filename": "fatura.pdf", "contentType": "application/pdf", "content": "<base64>", "contentId": null } ],
"idempotencyKey": "invoice-123", "trackOpens": null, "trackClicks": null
}
// SendResult
{ "id": "…", "status": "QUEUED", "submittedAt": "…", "to": ["joao@exemplo.pt"], "suppressedRecipients": [] }
// BatchItemResult
{ "index": 0, "result": SendResult, "error": null }
- Log search:
qmatches the subject, the sender and the recipients by substring (case-insensitive;%and_are plain characters), or exactly an id, a provider message id, aMessage-IDheader or a tag. Aqthat is a whole address finds the mail to, cc'd to, bcc'd to or from that address exactly (and a tag equal to it), through the recipient index; it no longer also matches longer addresses that contain it. It combines with the other filters. from: only the outbound mail from that sender address.appIdandapiKeyId: only what that app, or that key, sent (each send records its key).scope=sending: only outbound mail whose sender is not an address that receives, which is what the sending view lists; any other value butallis400.totalcounts up to 10,000: when more match, it is 10,000 andtotalCappedistrue(the web app says "More than 10,000"). Narrow the search withsince/untilto count further.statustakes one status or several, comma-separated (or the parameter repeated):status=BOUNCED,COMPLAINED,FAILED. Activity's state filters use it, one set of statuses per word.stream:transactional(default) orbroadcast(case-insensitive; the log filterstream=takesTRANSACTIONAL/BROADCAST). Thefromaddress must be on a verified domain of the caller's tenant (and within the API key's scope).- Instead of
subject,htmlandtext, a send can name a template:templateAliasortemplateId(its number), withmodel(an object) for its fields, rendered with the campaigns' template engine (Mustache, with Postmark's sections). An unknown template is422 TEMPLATE_NOT_FOUND. - At least one of
html/text. Up to 50 recipients in total per message; total size ≤ 10 MB after encoding (the request body itself: see the limits above). - Attachments:
contentTypemust be a MIME type (type/subtype, optionally with parameters such as; name="fatura.pdf"; missing meansapplication/octet-stream);contentIdmakes the part inline for<img src="cid:…">and must be an RFC 2392 id without angle brackets, spaces or line breaks (logoorlogo@mail.cliente.pt). Otherwise400 VALIDATION_FAILED(fieldattachments). - Suppressed recipients are removed and reported in
suppressedRecipients. If none remain, the request fails with422 ALL_RECIPIENTS_SUPPRESSED(aSUPPRESSEDmessage is still logged). - From a platform-mode domain, recipients whose domain takes no mail (it does not exist, has only a null MX, or has neither MX nor address record) or, for broadcast, is a disposable-address service are skipped like suppressed ones: reported in
suppressedRecipients, left out of the headers, not counted against the daily cap. A DNS lookup that times out or fails lets the recipient through: a send's domains are looked up together and it waits for DNS at most 3 s (recipient-checks.request-deadline), and a batch looks up the domains of all its messages once, within one such deadline. Transactional mail, inbox replies included, reaches a disposable address. If the checks leave no recipient, the request fails with422 ALL_RECIPIENTS_INVALIDand nothing is stored (in a batch, that item'serror). - Suppressions are checked again right before sending: a recipient suppressed while the message waited in the queue is left out and gets a
SUPPRESSEDevent; if no recipient is left, the message ends with statusSUPPRESSED. - A repeated
idempotencyKeyfor the same app returns the originalSendResultwith200, checked before the domain and stream (a retry while the stream is paused still gets it): the sameidandsubmittedAt,toandsuppressedRecipientsas first accepted, andstatusQUEUEDwhatever happened to the message since (an original422 ALL_RECIPIENTS_SUPPRESSEDis answered again). The same key with a different payload — any of sender, recipients,replyTo, subject, bodies, stream, tags, metadata, headers,trackClicksor an attachment's filename, type, content id or content — is409 IDEMPOTENCY_CONFLICT. - A batch answers
200with oneBatchItemResultper input, in order; each item succeeds or fails on its own. An unexpected server error for one item is reported as{ "code": "INTERNAL_ERROR", "message": "Unexpected error; retry this message" }for that item only: retry just the failed items. - Sending to a paused stream fails with
422 STREAM_PAUSED. - While platform admins paused the tenant's sending, every send is refused, whatever domain or account it uses (
422 TENANT_SENDING_PAUSED). - From every domain, a send with an API key that has a
dailyRecipientLimitis refused when its distinct recipients (after suppressions) would take the key past it today (422 KEY_DAILY_LIMIT_REACHED); see daily send limits. - From a platform-mode domain, a send is also refused as a broadcast from a starter address (
422 STARTER_BROADCAST_NOT_ALLOWED), and when its distinct recipients (after suppressions) would take the tenant past its daily cap or a starter address past 100 per day (422 DAILY_LIMIT_REACHED; per UTC day, counted when queued, not refunded). In a batch each item is judged on its own, so items after the cap is reached fail with that code. Recipients on the platform hard-bounce list (they hard-bounced for any tenant in a platform account) are reported insuppressedRecipientslike the tenant's own suppressions. - Broadcast from a platform-mode domain is dispatched in waves (the broadcast ramp): messages beyond the current wave stay
QUEUEDuntil the ramp allows them. Queued mail also waits while the tenant is suspended or paused by platform admins. trackOpens/trackClicks: the message's own choice, either way; omitted ornull, the stream's setting (its default). An open is recorded when the recipient's mail program loads a tiny invisible image in the HTML part, so it is an estimate: some programs block images, some load them ahead of the reader. A message that tracks neither carries no image and no rewritten link. One that tracks opens but not clicks keeps its links as they are; one that tracks clicks but not opens still carries the image (the provider tracks both through one setting), but its opens are not recorded.- Reserved headers that cannot be set:
From,To,Cc,Bcc,Subject,Reply-To,Sender,Message-ID,Date,MIME-Version,Content-*,List-Unsubscribe*,Return-Pathand the sending service's reserved headers (X-SES-*).In-Reply-ToandReferencesare accepted and used for threading. - Broadcast bodies may contain
{{unsubscribe_url}}(or Postmark's{{{ pm:unsubscribe }}}): each recipient's copy gets its own hosted unsubscribe link there, in addition to theList-Unsubscribeheaders. In other streams the placeholders are removed. - On a plan with the Mailroomly footer (Free), a broadcast's HTML gets a small Sent with Mailroomly line (in the organization's marketing language) before
</body>, and its text the same line at the end, linking tohttps://mailroomly.com/?ref=footer. It is stored with the message, soGET /api/v1/messages/{id}shows it. Transactional mail never carries it. - A broadcast message with several recipients is sent as one copy per recipient (each with its own unsubscribe link), but the
To/Ccheaders keep the addresses as submitted, so every recipient sees them. Send newsletters as one message per recipient (/api/v1/messages/batch) to keep recipients private.
Message status: QUEUED, SENDING, SENT, DELIVERED, DELAYED, OPENED, CLICKED, BOUNCED, COMPLAINED, REJECTED, FAILED, SUPPRESSED, CANCELED (canceled by an admin while queued: never sent, see send queue), RECEIVED (inbound).
// MessageSummary
{ "id": "…", "direction": "OUTBOUND", "streamType": "TRANSACTIONAL", "from": "faturas@mail.cliente.pt", "to": ["joao@exemplo.pt"],
"subject": "…", "status": "DELIVERED", "tags": ["invoice"], "appName": "CRM", "mailboxAddress": null,
"createdAt": "…", "sentAt": "…", "tracked": false } // tracked: it tracks opens or clicks; otherwise its route ends at DELIVERED
// MessageDetail = MessageSummary +
{ "cc": [], "bcc": [], "replyTo": [], "metadata": {}, "messageIdHeader": "<…>", "providerMessageId": "…",
"suppressedRecipients": [], "trackOpens": false, "trackClicks": false, "threadId": null, "lastError": null,
"appId": "…", "apiKey": { "id": "…", "name": "production", "prefix": "mp_live_Ab3d" }, "resentFrom": null, "resends": [],
"attachments": [ … ],
"events": [ { "id": "…", "type": "DELIVERED", "recipient": "joao@exemplo.pt", "occurredAt": "…",
"bounceType": null, "bounceSubtype": null, "diagnostic": null, "linkUrl": null, "userAgent": null } ] }
lastError and each event's diagnostic and bounceSubtype are in Mailroomly's words, never the email provider's codes, regions or resource names; the recipient server's own reply (such as smtp; 550 5.1.1 user unknown) is kept. Bounce subtypes: Suppressed for an address on a suppression list, InvalidAddress for one refused by validation, and the general SMTP kinds (General, NoEmail, MailboxFull, …). The provider's own text is never returned here (providerDetail was removed): it stays in the server log, and platform admins see it only with email addresses redacted, in the tenant's failures.
apiKey is the key the message was sent with (null for a person, a token or mail sent before 16 October 2026). resentFrom is the send it repeats, resends the sends that repeat it, oldest first.
Send it again (POST /api/v1/messages/{id}/resend, MESSAGES_SEND, and read access to the message as GET /api/v1/messages/{id} gives it): a new transactional send of the message's stored bodies, attachments, tags, metadata, headers (In-Reply-To and References included, so it stays in its email thread) and tracking, from its sender, through every check of a send (suppressions, limits, pauses, key scopes). Without to, to the original To, Cc and Bcc; with to, to those addresses only. It answers like a send (202 SendResult, 422 ALL_RECIPIENTS_SUPPRESSED), records resentFrom, and is audited message.resent. An API key sends it as its app; a person or a token keeps the original's app. Refused: received mail and marketing (broadcast) sends (422 RESEND_NOT_ALLOWED), a send still queued or sending (409 RESEND_NOT_YET), a send whose content retention deleted (422 RESEND_CONTENT_GONE). A repeated idempotencyKey replays the first answer: the key is the app's, or, for a resend with no app (a person's or a token's), the original send's.
Event type: QUEUED, SENT, DELIVERED, DELIVERY_DELAYED, OPENED, CLICKED, BOUNCED, COMPLAINED, REJECTED, FAILED, SUPPRESSED, UNSUBSCRIBED, CANCELED, RECEIVED, RENDERING_FAILED. A CANCELED event says who canceled it in diagnostic (Canceled in the queue by <name>, with 's AI agent for a personal token); for a broadcast there is one per recipient not sent yet. A FAILED event carries the error in diagnostic; for a broadcast message there is one per recipient that was not sent (recipient set). A broadcast copy the sending service refuses for that recipient alone (e.g. an invalid address) fails only that recipient and the other copies still go out — unless 3 copies in a row are refused with the same error before any copy of that dispatch was accepted (as happens for a sending address that is not ready), which fails the recipients left without trying them; a broadcast that got at least one copy out ends SENT (not FAILED) even when other recipients failed, and ends FAILED only when no copy was accepted. A SUPPRESSED event with a recipient marks a recipient left out because it was suppressed after the message was queued; that recipient is also added to the message's suppressedRecipients (the idempotent replay of the send still answers the recipients as first accepted).
Suppressions
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/suppressions?address=&reason=&streamId=&scope=&page=&size= |
– | page of Suppression |
| POST | /api/v1/suppressions |
{ "address", "scope", "streamId"?, "note"? } |
Suppression (reason MANUAL) |
| DELETE | /api/v1/suppressions/{id} |
– | 204 |
| GET | /api/v1/suppressions/summary |
– | { "total": 37, "byReason": { "HARD_BOUNCE": 29, "COMPLAINT": 3, "UNSUBSCRIBE": 5 } } |
// Suppression
{ "id": "…", "address": "joao@exemplo.pt", "scope": "STREAM", "streamId": "…", "streamLabel": "mail.cliente.pt · BROADCAST",
"reason": "UNSUBSCRIBE", "sourceMessageId": "…", "note": null, "createdAt": "…", "createdByName": null }
The summary counts the tenant's blocked addresses by reason, as the list counts them for the caller (a key restricted to some streams sees theirs only); the platform hard-bounce list is not in it.
An address the organization erased is refused too, but kept only as a hash, so it is not in this list (GET /api/v1/privacy/erasures/summary counts them).
scope: GLOBAL (the whole tenant) or STREAM. reason: HARD_BOUNCE, INVALID_ADDRESS (refused by validation before sending), SOFT_BOUNCE, COMPLAINT, UNSUBSCRIBE, MANUAL, and PLATFORM_HARD_BOUNCE (below).
Platform-mode streams are also checked against the platform hard-bounce list (addresses that hard-bounced for any tenant in a platform account, for 180 days). Its entries never say which tenant they came from and never appear in a partial search. An exact-address search (address a full email address, no streamId, reason empty or PLATFORM_HARD_BOUNCE, scope empty or GLOBAL) by a tenant with a platform-mode domain, and not by a domain-restricted API key, lists the entry first on page 0 (counted in total): reason PLATFORM_HARD_BOUNCE, scope GLOBAL, sourceMessageId, streamId and createdByName null, createdAt the first bounce, and an id derived from the address. DELETE of that id is 403 PLATFORM_SUPPRESSION only under the same conditions as the lookup — a tenant with a platform-mode domain, a caller that is not a domain-restricted API key — and while the entry is active; otherwise, and for an expired entry, it is 404. Platform admins manage the list.
Daily send limits
On top of the tenant's own caps (DAILY_LIMIT_REACHED, platform-mode domains only), two limits apply to every send, whatever the domain:
- Per user: a send made by a user — from the web inbox (reply, compose) or through a personal token, MCP included — counts its distinct recipients against the tenant's
userDailySendLimit(set by platform admins in the tenant limits;null: no limit). Over it:422 USER_DAILY_LIMIT_REACHED. A personal-token send counts for the token and its user. - Per API key: a send made with an API key counts against the key's
dailyRecipientLimit(set by tenant admins, see apps and API keys;null: no limit of its own). Over it:422 KEY_DAILY_LIMIT_REACHED(PostmarkErrorCode 405).
Both count distinct recipients after suppressions, per UTC day (they reset at 00:00 UTC), and are checked before anything is stored and reserved in the send transaction, so concurrent sends cannot overshoot and a refused send leaves nothing behind. In a batch each item is judged on its own. The first refusal of the day for a user or a key raises a WARNING alert (USER_SEND_LIMIT_REACHED or KEY_SEND_LIMIT_REACHED), emailed to the tenant's active admins only; later refusals that day raise nothing, even after the alert is acknowledged. The alert's subjectId is derived from the actor and the UTC day (the person's or key's own id is not exposed), so an unacknowledged alert of an earlier day stays until it is resolved.
Usage (tenant admins)
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/usage?days=30 |
– | UsageReport (PLAN_READ; days 1 to 90, otherwise 400 VALIDATION_FAILED) |
// UsageReport
{ "days": 30, "from": "2026-09-07", "to": "2026-10-06",
"series": [ { "day": "2026-10-06", "messagesTransactional": 3, "messagesBroadcast": 0, "recipients": 5,
"inbound": 2, "storedBytesAdded": 48211, "apiCalls": 14, "mcpCalls": 3 } ],
"totals": { "messagesTransactional": 3, "messagesBroadcast": 0, "recipients": 5, "inbound": 2,
"storedBytesAdded": 48211, "apiCalls": 14, "mcpCalls": 3, "storedBytesHeld": 1200000 },
"actors": [ { "type": "USER", "id": "…", "name": "Ana", "detail": "ana@cliente.pt", "active": true,
"recipientsToday": 3, "recipients": 5, "messages": 3, "apiCalls": 0, "mcpCalls": 0, "dailyLimit": 200 } ] }
- Counters are per UTC day and kept up to the platform cap (730 days), whatever the tenant's retention: they outlive the mail they count. Counting started with this release (no backfill).
serieshas one entry per day, oldest first, zeros included.storedBytesHeldis the latest nightly snapshot of the bytes the tenant's messages hold, whatever its day (it is not limited to the days asked for).- Messages, recipients, inbound and stored bytes are counted when a message is queued or stored (an idempotent replay, a refused send or a send whose recipients were all suppressed counts nothing).
apiCallscounts requests authenticated by an API key or personal token, MCP excepted;mcpCallscounts MCP tool calls; web app requests are not counted. Call counts are written every 30 seconds. actorslists every active user, unrevoked API key and active personal token (typeUSER,API_KEY,PERSONAL_TOKEN), plus any removed one with usage in the period (active: false,namenullwhen unknown).detailis the user's email, the key's app name or the token owner's name.dailyLimitis the limit in force for that actor (users: the tenant's per-user limit; keys: their own),nullwhen none; a personal token has none of its own: what it sends counts toward its user's, whose row carries the total.
Plans
An organization's plan sets its monthly emails, its prices and its limits; platform admins may override any limit for one organization.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/plan |
– | TenantPlan (tenant admins, PLAN_READ) |
| GET | /api/v1/receiving-pauses |
– | TenantReceivingPause[]: the pauses in force on the organization's addresses and domains that the caller may read (MAILBOXES_READ; a signed-in admin sees them all, anyone else only those of the mailboxes it can read and of their domains, within a key's or token's scopes) |
| GET | /api/v1/public/plans |
– | PublicPlan[], no authentication, Cache-Control: public, max-age=300; also served on the website's host (messaging.site-domain) for its pricing |
// PublicPlan: null limits are unlimited; prices in euros, excluding VAT; support DOCS, EMAIL or PRIORITY.
{ "id": "…", "name": "Solo", "priceMonthly": 12, "priceYearly": 120, "monthlyVolume": 10000, "extraVolumePrice": 1.00,
"usersIncluded": 1, "extraUserPrice": 6.00, "maxDomains": 5, "maxMailboxes": 25, "maxAgents": null,
"broadcastAllowed": true, "apiReadsPerMinute": 100, "retentionConversationDays": 180, "retentionUnansweredDays": 60,
"personalTokensEnabled": true, "support": "EMAIL", "maxContacts": 50000 }
// TenantPlan (plan null for an organization from before plans; limits and usage as in LimitsView)
{ "plan": PublicPlan, "limits": { … }, "usage": { … },
"volume": { "month": "2026-10-01", "resetsOn": "2026-11-01", "sent": 4100, "received": 2140, "total": 6240,
"limit": 10000, "stopsSending": false },
"extra": { "emails": 0, "emailsCost": 0, "people": 0, "peopleCost": 0, "total": 0 },
"catalogue": [ PublicPlan ], "maxPersonalTokensPerPerson": 20, "adjustedLimits": 0 }
- The month (
volume): sent recipients (after suppression, counted when queued, from any domain) plus received emails (once per organization, however many of its addresses one reached; failing spam, bounce reports and automatic replies don't count), per the organization's month: from the day of the month it began paying (00:00 UTC) to the same day the next month, or the calendar month for an organization that never paid.monthis that period's first day andresetsOnthe next one's.limitnullis unlimited.stopsSendingis true on a plan without an extra price: a send that would take the month pastlimitis refused with422 PLAN_VOLUME_REACHED(PostmarkErrorCode 405) untilresetsOn, and nothing is stored; the message says whether the month is used up or how many emails are left.adjustedLimitscounts the limits the Mailroomly team set for the organization apart from its plan (an override equal to the plan's own value doesn't count). Received mail is never refused. Otherwise the emails abovelimitare extra, andextrasays what they and the people beyondusersIncludedcome to at the plan's prices (emailsCost: per 1,000, to the cent). - Marketing sends: a broadcast from an organization whose plan has none is refused with
422 BROADCAST_NOT_IN_PLAN(PostmarkErrorCode 412); with them, it also needs the organization to have accepted the terms of service, which include consent before marketing (422 BROADCAST_CONSENT_REQUIRED). - Own domains and connected agents: adding a domain past
maxDomains(the starter domain does not count) is409 DOMAIN_LIMIT_REACHED; creating an API key or personal token pastmaxAgents(unrevoked keys plus active tokens) is409 AGENT_LIMIT_REACHED. - Contacts: adding a contact past
maxContacts(contacts that have not unsubscribed) is422 CONTACT_LIMIT_REACHED; see campaigns. - Reads per minute: every
GETauthenticated by an API key or personal token (MCP excepted) and every MCP read tool takes one of the organization'sapiReadsPerMinuteover the last 60 seconds. Past it:429 READ_RATE_LIMITEDwithRetry-After(seconds). Writes and sends are not reads, and the web app is not limited. Webhooks save reads: subscribe to events instead of polling. - Notices: the organization's admins are told at 80% (
PLAN_VOLUME_WARNING) and when the month is used up (PLAN_VOLUME_REACHED, critical when sending stops), once a month for a given volume: a new plan or volume during the month has its own. A notice that no longer holds (another volume, sending no longer stopped, an earlier month) is closed.
Billing
Paying for a plan with Stripe: hosted Checkout, the Customer Portal and changes of plan, for an organization's admins. Off while the platform has no Stripe keys: GET /api/v1/billing answers enabled: false and the rest 409 BILLING_OFF.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/billing |
– | Billing (tenant admins, PLAN_READ) |
| GET | /api/v1/billing/status |
– | { "enabled": true, "subscription": { … } }: the subscription as stored, nothing read from Stripe (every screen's past-due band; tenant admins, PLAN_READ: a member, an app's key or a platform admin gets 403) |
| POST | /api/v1/billing/checkout |
{ "planId": "…", "period": "monthly" } |
{ "url": "https://checkout.stripe.com/…" }: open it in the browser |
| POST | /api/v1/billing/portal |
– | { "url": "https://billing.stripe.com/…" }: the Customer Portal (card, billing details, VAT number, receipts, cancelling) |
| GET | /api/v1/billing/change/preview?planId=…&period=yearly |
– | { "now": true, "amount": 14.76, "currency": "eur" }: what confirming that change charges today, VAT included (a signed-in admin); now false and no amount for a change at the period end |
| POST | /api/v1/billing/change |
{ "planId": "…", "period": "yearly" } |
{ "now": true, "appliesAt": null, "billing": Billing } |
| DELETE | /api/v1/billing/change |
– | Billing: keeps the current plan (a change or an ending at the period end is undone) |
| POST | /api/v1/billing/stripe/events |
Stripe's event | 200 { "received": true }: Stripe's webhook, no sign-in, checked by Stripe-Signature |
// Billing: subscription null when the organization never paid; details read from Stripe (kept 5 minutes, never past a change of the subscription).
{ "enabled": true, "purchasable": [ { "planId": "…", "periods": ["MONTHLY", "YEARLY"] } ], "customer": true,
"subscription": { "planId": "…", "planName": "Solo", "period": "MONTHLY", "status": "active",
"currentPeriodEnd": "2026-11-01T00:00:00Z", "cancelAtPeriodEnd": false, "pastDueSince": null,
"scheduledPlanId": null, "scheduledPlanName": null, "scheduledPeriod": null,
"endedAt": null, "heldUntil": null, "extraPeople": 0 },
"details": { "available": true, "nextCharge": { "amount": 14.76, "currency": "eur", "date": "2026-11-01T00:00:00Z" },
"paymentMethod": { "brand": "visa", "last4": "4242", "expMonth": 12, "expYear": 2030 },
"receipts": [ { "id": "ch_…", "amount": 14.76, "currency": "eur", "paidAt": "…", "refunded": false, "url": "https://pay.stripe.com/receipts/…" } ] } }
- Who: paying and changing plan need a person signed in as an admin of the active organization: an agent's token gets
403 SIGNED_IN_ONLY, a member or an app403. - Checkout:
periodismonthlyoryearly(any case;400otherwise);planIda public paid plan (400). A plan with no Stripe price for that period is409 PLAN_NOT_FOR_SALE; an organization already paying,409 SUBSCRIPTION_EXISTS(change plan instead). A new Checkout expires the organization's earlier ones still open, so only the newest can be paid. Checkout charges VAT from the billing address (Stripe Tax), collects a VAT number, accepts promotion codes and returns to/plan?checkout=successor/plan?checkout=cancel. The first invoice is the full price, and the plan renews on the day it was bought, each month (or year); the organization's month counts from that day. The plan applies once Stripe confirms the payment (usually seconds). - Change: to a higher plan, or from monthly to yearly (at the same or a higher plan), now (
now: true), with the difference for the rest of the period charged at once (GET …/change/previewsays how much first); a declined card is402 PAYMENT_FAILEDand nothing changes. Monthly to yearly starts the year, and the organization's month, that day. To a lower plan, or from yearly to monthly, at the period end (now: false,appliesAt). To the default plan (Free; noperiodneeded), the subscription ends at the period end. Without a subscription:409 NO_SUBSCRIPTION(use Checkout). The portal without one:409 NO_BILLING_ACCOUNT. Stripe unreachable:502 BILLING_UNAVAILABLE, try again. - Statuses: Stripe's (
active,past_duewhile payments are retried,unpaid,canceled,incomplete…). When a paid plan ends, the organization is on Free:endedAtis set, andheldUntilis when its previous retention, kept meanwhile, ends. NoticesPAYMENT_FAILEDandPLAN_ENDED(subjectTENANT),EXTRAS_PAYMENT_FAILED(a month's extra emails billed on an invoice of their own, whose payment failed; subjectINVOICE, that invoice, closed once it is paid, voided or given up on; the plan isn't at stake) andVAT_NUMBER_REMOVED(subjectTENANT: VIES didn't confirm the VAT number, so it was removed from the billing details and VAT is charged). - Extra emails are billed once the organization's month is over, as a line of the renewal's invoice, or on an invoice of their own when no renewal follows (a yearly plan's months, a plan that ends).
- Receipts are Stripe's; the invoice is issued by YOMATICO and emailed to the billing address.
Webhook deliveries
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/webhook-deliveries?status=&appId=&eventType=&messageId=&page=&size= |
– | page of WebhookDelivery (messageId: only the deliveries about that message) |
| GET | /api/v1/webhook-deliveries/{id} |
– | WebhookDelivery + payload, lastResponseExcerpt |
| POST | /api/v1/webhooks/replay |
{ "ids"?: [], "status"?: "DEAD", "appId"? } |
{ "replayed": 3 } |
| GET | /api/v1/dev/webhook-sink |
– | [{ "id", "receivedAt", "headers", "body", "signatureValid" }] (non-prod only) |
A limited teammate reads and replays only their apps' deliveries about their domains. A delivery's domain is its address's or its message's, and one with neither is not shown to them.
| DELETE | /api/v1/dev/webhook-sink | – | 204 (non-prod only) |
// WebhookDelivery
{ "id": "…", "appId": "…", "appName": "CRM", "target": "APP", "url": "https://…", "format": "NATIVE", "eventType": "delivered",
"messageId": "…", "status": "PENDING", "attempts": 2, "nextAttemptAt": "…", "lastStatusCode": 500, "lastError": "…",
"deliveredAt": null, "createdAt": "…" }
status: PENDING, DELIVERED, DEAD. target: APP or MAILBOX. url is where the delivery was last sent: every attempt goes to the target's current URL. For inbound deliveries payload is a reference to the received message ({ "reference": "inbound-message", "id", "type", "messageId", "recipient", "occurredAt", "format" }); the body sent is rendered from the message at delivery time (webhooks). The development sink lists and clears only the caller tenant's requests.
Alerts and dashboard
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/alerts?open=true |
– | Alert[] |
| POST | /api/v1/alerts/{id}/acknowledge |
– | Alert |
| GET | /api/v1/dashboard |
– | Dashboard |
| GET | /api/v1/stats/daily?days=30&from=&scope= |
– | DailyStats |
| GET | /api/v1/senders?days=30 |
– | Senders |
// Alert
{ "id": "…", "severity": "WARNING", "kind": "BOUNCE_RATE_WARNING", "message": "…", "subjectType": "STREAM", "subjectId": "…", "subjectDomainId": "…",
"createdAt": "…", "acknowledgedAt": null, "reason": null }
// Dashboard
{ "accounts": [ { "id", "name", "healthStatus", "productionAccess", "maxSendRate", "max24HourSend", "sentLast24Hours" } ],
"streams": [ { "id", "domainName", "type", "paused", "sent", "bounceRate", "complaintRate" } ],
"queue": { "queued": 0, "failedLast24Hours": 0 },
"last24Hours": { "sent": 0, "delivered": 0, "bounced": 0, "complained": 0, "received": 0 },
"openAlerts": 0 }
// DailyStats
{ "days": 30,
"series": [ { "day": "2026-10-01", "sent": 120, "delivered": 117, "bounced": 1, "complained": 0, "opened": 40, "received": 6 } ],
"monthToDate": 2640 }
GET /api/v1/stats/daily (ALERTS_READ) returns the tenant's mail per UTC day, oldest first, for days from 1 to 90 (default 30). Days without mail are included, with zeros. from counts only the sends from that sender address; scope=sending only the sends not from an address that receives (the sending view's). Either leaves received mail out (received is 0). Closed days are read from a daily rollup, today is counted live: both count the same way (an event recorded after its day was rolled is counted within minutes).
Counted as the stream stats and guardrails count them:
sentcounts the distinct recipients of each message the provider accepted, suppressed ones excluded (for broadcast, the copies already accepted).deliveredandcomplainedcount those recipients' events.bouncedcounts hard bounces from a mailbox provider; refusals before sending (an address on a suppression list, or refused by validation) are left out, as in the bounce rate.openedcounts each recipient of a message once, on the day of the first open, so it never exceeds what was delivered by repeated opens.receivedcounts email that arrived.monthToDateissentsince the first day of the current UTC month.
GET /api/v1/senders (ALERTS_READ) lists the sending view's senders over days (1 to 90, default 30), most sent first: the tenant's addresses that only send (even before their first send) and the addresses outbound mail was sent from that are not addresses of the tenant (setUp: false, mailboxId: null), in the window or in the days the figures keep (with 0 for the window then). Addresses that receive are left out, strictly by the address.
// Senders
{ "days": 30, "items": [ { "address": "orders@mail.cliente.pt", "mailboxId": "…", "setUp": true,
"apps": [ { "id": "…", "name": "Shop" } ], "sent": 12480, "delivered": 12368, "bounced": 75, "complained": 1, "opened": 4100,
"lastSentAt": "…", "attention": { "bounced": 9, "complained": 0, "failed": 1 } } ] }
Counts as stats/daily counts them; attention covers the last 7 days (failed: messages that ended FAILED).
The web app's Home and plan meter read it. The meter's "today" figure is the daily cap's own count (GET /api/v1/sending), which counts every recipient as the send is accepted and does not give failures back; the month figure is what went out.
Acknowledging a BOUNCE_RATE_WARNING or COMPLAINT_RATE_WARNING sets a baseline: the warning is raised again only when mail sent after the acknowledgement crosses the threshold (with the minimum volume). Resuming a stream (PATCH /api/v1/streams/{id} with "paused": false) does the same for the stream's pause and warnings. The dashboard's stream sent counts recipients, like the stream stats.
Alert severity: INFO, WARNING, CRITICAL. subjectType: ACCOUNT, DOMAIN, MAILBOX, STREAM, APP, TENANT, PLATFORM, USER, API_KEY, NEWSLETTER, AUTOMATION, CONTACT_IMPORT, INVOICE (a billing invoice: its subjectId is an id Mailroomly derives from it, never Stripe's; the web app opens Plan). subjectDomainId is the domain of a STREAM subject (streams have no page of their own; the web app opens the domain), null otherwise. kind: BOUNCE_RATE_WARNING, COMPLAINT_RATE_WARNING, STREAM_AUTO_PAUSED, ACCOUNT_HEALTH_FAILED (critical: the account cannot send, its streams are paused), ACCOUNT_HEALTH_WARNING (a check failed but sending continues), INBOUND_RULE_SET_INACTIVE (critical: the account has inbound domains but messaging-platform is not its active receipt rule set), DNS_DRIFT, WEBHOOK_DEAD_LETTER (emailed to the tenant's admins only: the URL may carry a token), USER_SEND_LIMIT_REACHED (warning, subject USER: a person reached the per-user daily limit) and KEY_SEND_LIMIT_REACHED (warning, subject API_KEY: a key reached its own daily limit); these two are raised once per person or key and UTC day and emailed to the tenant's admins only (daily send limits); PLAN_VOLUME_WARNING (warning, subject TENANT: the month's emails reached 80% of the plan's) and PLAN_VOLUME_REACHED (subject TENANT: they reached it; critical when sending stopped), each once a month; RECEIVING_PAUSED (critical, subject the paused MAILBOX or DOMAIN: Mailroomly support paused receiving for it), resolved when receiving resumes (plans). Platform admins also get INBOUND_FAIR_USE (warning, no tenant, subject the organization as TENANT: it received far more than its fair use that day), at most once a day and one open at a time per organization.
Billing adds PAYMENT_FAILED (critical, subject TENANT), PLAN_ENDED (warning, subject TENANT), EXTRAS_PAYMENT_FAILED (warning, subject INVOICE) and VAT_NUMBER_REMOVED (warning, subject TENANT), emailed to the tenant's admins only (billing); platform admins also get BILLING_TWO_SUBSCRIPTIONS and BILLING_VAT_NUMBER_UNVERIFIED (warning, no tenant, subject the organization as TENANT).
Campaigns add AGENT_CAMPAIGN_STARTED (warning, subject the NEWSLETTER or AUTOMATION an app or an agent started: it waits 15 minutes), NEWSLETTER_PAUSED and AUTOMATION_PAUSED (warning: something stopped it; reason is the code of what, such as PLAN_VOLUME_REACHED, and the message says it in words), CONTACT_IMPORT_NOT_ADDED (warning, subject the CONTACT_IMPORT: some rows were not added) and CONTACT_LIMIT_REACHED (warning, subject TENANT). reason is null for every other kind.
The website's try by email adds two platform alerts (no tenant, platform admins only): TRY_ANSWERS_PAUSED (warning, subject TRY: the breaker paused the demo agent's answers) and TRY_PURGE_FAILED (warning: the 24-hour purge failed 10 runs in a row, subject TRY, details.sessions kept past their 24 hours; or SES refused to take a late bounce of an answer off an account's suppression list, subject that ACCOUNT). Neither ever names a visitor's address.
Resting organizations add ORGANIZATION_RESTING_SOON (info, subject TENANT: a Free organization rests on details.restsOn unless it is used before) and ORGANIZATION_WOKE (info, subject TENANT: it woke and its sending is being set up again), both emailed to its admins.
Platform sending adds, on the tenant (subjectType: TENANT): TENANT_BOUNCE_WARNING and TENANT_COMPLAINT_WARNING (the tenant's rates for one stream type near the pause level), TENANT_VALIDATION_WARNING (warning: validation refused 5 % or more of one stream type's recipients before sending; never a pause), TENANT_AUTO_PAUSED (critical: paused by its guardrails), BROADCAST_RAMP_STOPPED (critical: a broadcast wave bounced too much), REPUTATION_PAUSED (critical: its sending of one stream type was paused for its bounce or complaint rates; one open alert per stream type, details.streamType, resolved once that type is no longer paused where the tenant sends), TENANT_SENDING_PAUSED (critical: platform admins paused its sending, with their reason), TENANT_GRADUATED (info) and TENANT_DEMOTED (warning). A DNS_DRIFT alert on a platform-mode domain may also say that the domain is no longer verified for sending (check its DKIM record): its DKIM failed or it is no longer verified for sending; a temporary DKIM problem while the domain still sends raises none.
Sending status (tenant admins)
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/sending |
– | SendingStatus (DOMAINS_MANAGE; API keys and personal tokens get 403) |
// SendingStatus
{ "tier": "PROBATION", "tierChangedAt": "…", "paused": false, "pausedReason": null,
"dailyRecipientLimit": 1000, "sendRate": 2, "sentToday": 120,
"starterDailyLimit": 100, "starterSentToday": 12,
"ramp": { "waveSize": 400, "waveSent": 400, "cooldownUntil": "2026-09-30T10:35:00Z", "complete": false },
"clientAccountsEnabled": false,
"pausedStreams": [ { "streamId": "…", "domainId": "…", "domain": "mail.cliente.pt", "type": "BROADCAST", "reason": "AUTO_RAMP",
"pausedAt": "…", "platform": true } ],
"platformDomains": 2, "clientDomains": 0, "starterDomain": "starter.example.com", "starterAddress": "loja-nova.starter.example.com",
"levels": { "bounceWarning": 0.02, "complaintWarning": 0.0005 } }
tier:PROBATIONorTRUSTED.pausedandpausedReason: the platform admins' pause of the tenant's sending and their reason.dailyRecipientLimitandsendRateare what applies to the tenant's platform-mode mail: its own limits, otherwise its tier's defaults (null: no cap / the account's rate).sentTodayandstarterSentTodayare the recipients queued today (UTC) through platform-mode domains and through the starter address.ramp: the current broadcast wave's size and accepted copies, the end of the cooldown while one runs (otherwisenull), and whether the ramp is complete (waveSize: 0before the first platform-mode broadcast).pausedStreams: every paused stream of the tenant, with its domain, itspausedReason(see domains and streams) and whether its domain sends through the platform (platform).platformDomainsandclientDomains: how many domains send through the platform and through the tenant's own AWS accounts — the caps, rate and ramp above apply only to the first.starterDomainis the platform's starter domain andstarterAddressthe tenant's starter address (nullwithout one).levels: the bounce and complaint rates at which the tenant is warned — below them its sending is healthy. For platform-mode domains the platform's per-tenant guardrails, for its own AWS accounts the stream guardrails, the stricter when it has both.- The response never names the platform's accounts or pools.
Send queue (tenant admins)
What waits in the send queue, and cancelling it (Canceling what is waiting). DOMAINS_MANAGE: a signed-in admin, or a personal token with domains:manage; API keys get 403.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/sending/queue |
– | SendingQueue |
| POST | /api/v1/sending/queue/cancel |
{ "streamId"? } |
202 QueueCancellation |
| GET | /api/v1/sending/queue/cancellations/{id} |
– | QueueCancellation |
// SendingQueue
{ "total": 1250,
"streams": [ { "streamId": "…", "domainId": "…", "domain": "mail.cliente.pt", "type": "BROADCAST", "paused": true, "queued": 1200 } ],
"running": [ /* QueueCancellation */ ] }
// QueueCancellation
{ "id": "…", "streamId": null, "domain": null, "streamType": null, "total": 1250, "canceled": 400, "status": "RUNNING",
"requestedAt": "…", "finishedAt": null }
totalcounts every queued message of the tenant (also of a stream deleted since) but campaign copies (a newsletter or an automation is canceled in Campaigns) and mail from an address that receives (it belongs to its conversations, as in the sending view).POST …/cancelwith nostreamId(ornull) cancels everything queued; with one, that stream's (a stream of another tenant is404). It reaches what was queued when it was asked (total); mail queued later waits as usual. A cancellation of the same scope still running is extended to the new request and returned: it then also reaches what was queued since, andtotalgrows to count it.- The cancel runs in the background, in batches:
canceledgrows untilstatusisDONE. A message being sent at that moment finishes that attempt: what it sends still goes and is not counted, and if it waits again (a limit, a marketing email's next wave) the cancel takes it then, so the cancellation staysRUNNINGuntil the attempt ends. A broadcast whose copies went out in part endsSENT, its remaining recipients canceled. - A canceled message ends
CANCELEDwith aCANCELEDevent, and thecanceledwebhook for apps that asked for it (webhooks). Its recipients not sent go back to the month's emails they counted in, unless that month was already billed; the day's send counters stay. - Audited
sending.queue_canceled. When a personal token cancels, every admin gets anAGENT_QUEUE_CANCELEDnotice, closed only by a signed-in person.
Requests
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/requests?result=&channel=&appId=&apiKeyId=&tokenId=&since=&until=&page=&size= |
– | page of RequestEntry, with totalCapped |
| GET | /api/v1/requests/{id} |
– | RequestEntry |
Every REST call (the Postmark aliases included) and every MCP tool call made with an API key or a personal token is logged, with its result, for 30 days (messaging.requests.retention-days; less when the organization keeps mail for fewer days). Calls made in the web app with a session are not logged, nor a call refused because its key was not recognized.
// RequestEntry
{ "id": "…", "createdAt": "…", "channel": "API", "method": "POST", "route": "/email", "statusCode": 422,
"errorCode": "DOMAIN_NOT_VERIFIED", "errorText": "The sender domain shop.example.com is not verified yet", "durationMs": 38,
"app": { "id": "…", "name": "Shop" }, "apiKey": { "id": "…", "name": "production", "prefix": "mp_live_Ab3d" }, "token": null,
"userAgent": "node-fetch/3.3", "messageId": null }
channel:APIorMCP.route: the endpoint's template (/api/v1/messages/{id}), or the MCP tool's name (methodCALL).statusCode: the HTTP status, or for MCP the status a REST call would have had (200when the tool answered).errorCodeanderrorText: the error the caller read, in Mailroomly's words, with every email address replaced by[address]. Nothing of the request or the response body is kept;messageIdis the send the call created.result=okkeeps the calls below 400,result=failedthe others.sinceanduntilnarrow the 30 days.totalcounts up to 10,000 (totalCapped).- Who sees what: a caller with
APPS_MANAGE(an administrator, or an admin's token withapps:manage) sees every call of the organization; a person, the calls of their own tokens; a token, its own; an API key, its own. Another organization's entries, and entries past the retention, are404.
Audit log
Who changed what in the organization, for its admins and an admin's token with organization:read (AUDIT_READ); anyone else 403. Every plan. How people read it: the user manual's Audit log.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/organization/audit-log?from=&to=&actor=&area=&cursor=&size=&lang= |
– | { "items": [AuditEntry], "nextCursor" }, newest first |
| GET | /api/v1/organization/audit-log/actors?lang= |
– | [ { "value", "kind", "name", "agent" } ]: everyone in the log, to filter by |
| GET | /api/v1/organization/audit-log/export?from=&to=&actor=&area=&lang= |
– | the filtered log as CSV (text/csv, Content-Disposition: attachment), audited organization.audit_log_exported |
// AuditEntry
{ "id": "…", "at": "2026-10-20T10:00:00Z", "area": "DOMAINS", "action": "domain.created", "summary": "Added the domain acme.example",
"actor": { "kind": "PERSONAL_TOKEN", "name": "Ana Silva", "agent": "Claude" },
"target": { "type": "DOMAIN", "name": "acme.example" }, "details": { "name": "acme.example" } }
fromandto: ISO 8601 times.area:TEAM,AGENTS,APPS,DOMAINS,ADDRESSES,INBOX,SENDING,CAMPAIGNS,CONTACTS,ORGANIZATION,PRIVACY, repeated or comma-separated; none means every area.actor: avaluefrom/actors:person:<userId>(the person's changes, their agents' included),token:<id>,key:<id>,supportorsystem.cursor: the previous page'snextCursor(nullon the last page).size: 1 to 100, 50 by default.lang:enorpt-PT, the language ofsummaryand of names such as Mailroomly support; without it, the person's own, else the request'sAccept-Language, else English (an API key: English).actor.kind:PERSON,PERSONAL_TOKEN(agentis the token's name),API_KEY(nameis the app,agentthe key),SUPPORT(always Mailroomly support, never a name) orSYSTEM(Mailroomly). Someone no longer in the organization is A former member.target: what was changed, by its current name, or the name its entry kept when it is gone (a delete keeps it), else a bracketed word such as[deleted]. Only things the organization names have a target (people, apps, keys, tokens, domains, addresses, streams, campaigns' templates, lists, newsletters, automations, forms, images and imports, its own AWS accounts); anything else, such as a blocked address or a conversation, hasnull, so a recipient's address never leaves the server.detailsholds only whatsummarywas written from (names, counts, roles); the platform's entries about the organization and anything of the provider's are never shown.- The CSV's columns: time (UTC, ISO 8601), who, agent or key, area, action, what changed, headers in the reader's language; a cell that a spreadsheet would run as a formula starts with
'.
Campaigns
Contacts, lists, sign-up forms, images, templates, newsletters and automations. A tenant admin holds all of it; an API key needs CONTACTS (contacts, lists, forms) or CAMPAIGNS (everything, sending included); a personal token campaigns:manage or campaigns:send.
Contacts and lists
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/contacts?listId=&state=&q=&page=&size= |
– | page of Contact, total capped at 10,000 (totalCapped) |
| POST | /api/v1/contacts |
{ "email", "firstName"?, "lastName"?, "fields"?, "listIds"?, "optIn"?: { "note", "at"? }, "confirm"? } |
{ "contact": Contact, "created", "confirmationSent" } |
| GET | /api/v1/contacts/lookup?email= |
– | Contact (404 when not a contact) |
| GET | /api/v1/contacts/{id} |
– | Contact |
| PATCH | /api/v1/contacts/{id} |
{ "firstName"?, "lastName"?, "fields"?, "listIds"? } |
Contact |
| DELETE | /api/v1/contacts/{id} |
– | 204 (the contact goes; its mail and suppressions stay) |
| GET | /api/v1/contacts/{id}/newsletters |
– | the newsletters it was in, with what happened to its copy |
| POST | /api/v1/contacts/events |
{ "email", "event" } |
202 (starts the automations listening for event) |
| GET | /api/v1/lists |
– | { "members", "mailable", "countedAt", "lists": List[] } |
| POST | /api/v1/lists |
{ "name", "description"? } |
List |
| PATCH | /api/v1/lists/{id} |
{ "name"?, "description"? } |
List |
| DELETE | /api/v1/lists/{id} |
– | 204 (the contacts stay) |
| POST | /api/v1/lists/{id}/members |
{ "contactIds" } |
204 |
| POST | /api/v1/lists/{id}/members/remove |
{ "contactIds" } |
204 |
| GET, POST | /api/v1/contact-fields |
{ "key", "label" } |
the custom fields (at most 20) |
| DELETE | /api/v1/contact-fields/{id} |
– | 204 |
state:SUBSCRIBED(can be emailed),PENDING(waiting for confirmation),UNSUBSCRIBED,SUPPRESSED(blocked).qis a prefix of the address or a name.- A new contact needs
optIn(the evidence: how and when they agreed) orconfirm: true(they get an email to confirm and stayPENDINGuntil they do):422 OPT_IN_REQUIREDotherwise. An erased address is409 ADDRESS_ERASED. Past the plan's contact limit:422 CONTACT_LIMIT_REACHED.
// Contact
{ "id": "…", "email": "ana@exemplo.pt", "firstName": "Ana", "lastName": "Silva", "fields": { "company": "Padaria Ana" },
"state": "SUBSCRIBED", "optInSource": "FORM", "optIn": { "note": "Website sign-up form", "at": "…" }, "confirmedAt": "…",
"listIds": ["…"], "createdAt": "…", "updatedAt": "…" }
Imports
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /api/v1/contact-imports?fileName= |
the CSV file (text/csv, at most 200 MB and 1,000,000 rows) |
Import with its header, delimiter and preview |
| POST | /api/v1/contact-imports/{id}/start |
{ "mapping": { "email", "firstName"?, "lastName"?, "optInAt"?, "fields"? }, "listId"?, "optInNote", "optInAt"? } |
Import |
| GET | /api/v1/contact-imports, /api/v1/contact-imports/{id} |
– | Import[], Import |
| POST | /api/v1/contact-imports/{id}/cancel |
– | Import |
The mapping gives column numbers (from 0). Import has its status (UPLOADED, RUNNING, DONE, FAILED, CANCELED), the rows read, added, updated, notAdded, and the reasons with a count each (INVALID_EMAIL, INVALID_DATE, TOO_LONG, DUPLICATE, ERASED, CONTACT_LIMIT). The file is deleted when the import ends.
Sign-up forms
| Method | Path | Body | Response |
|---|---|---|---|
| GET, POST | /api/v1/forms |
{ "name", "listIds", "askFirstName"?, "askLastName"?, "title"?, "description"?, "buttonLabel"?, "consentText"?, "successMessage"?, "redirectUrl"?, "senderAddress", "senderName"?, "status"? } |
Form[], Form |
| GET, PUT, DELETE | /api/v1/forms/{id} |
as POST | Form |
Form adds its slug, url (the hosted page), embedHtml (a plain HTML form to paste in a website, no script), and how many sign-ups are pending and confirmed. A sign-up always confirms by email (public endpoints).
Brand, images and templates
| Method | Path | Body | Response |
|---|---|---|---|
| GET, PUT | /api/v1/organization/brand |
{ "companyName", "postalAddress", "logoUrl"?, "color"?, "language"?, "senderAddress"?, "senderName"? } |
Brand |
| POST | /api/v1/images |
multipart/form-data with file (PNG, JPEG, GIF or WebP, at most 10 MB) |
Image |
| GET | /api/v1/images, /api/v1/images/usage |
– | Image[], { "bytes", "limit" } |
| DELETE | /api/v1/images/{id} |
– | 204 |
| GET, POST | /api/v1/templates |
{ "name", "alias"?, "kind": "BLOCKS" | "HTML", "subject"?, "preheader"?, "blocks"?, "html"? } |
Template[], Template |
| GET, PUT, DELETE | /api/v1/templates/{id} |
as POST | Template |
| GET | /api/v1/templates/starters |
– | the starting designs (Newsletter, Announcement, Plain note) |
| POST | /api/v1/campaigns/preview |
{ "kind", "blocks"?, "html"?, "subject"?, "preheader"?, "contactId"?, "marketing"? } |
{ "subject", "html", "text" } |
| POST | /api/v1/campaigns/test |
as preview, with { "from", "to": [≤ 5] } |
202 |
- An image is optimized on upload: turned upright, metadata removed, at most 1,200 px wide, saved in the smaller of JPEG or PNG (an animated GIF up to 2 MB stays as it is; SVG is refused,
400).Imagehas itsurlon the image domain,width,height,bytesandoriginalBytes. Past 1 GB per organization:422 IMAGE_STORAGE_FULL. blocks:heading,text,button,image,divider,spacer(see the specification).html: at most 256 KB, cleaned on the server; without its own unsubscribe link or postal address it gets the standard footer.- Fields:
{{contact.first_name}},{{contact.last_name}},{{contact.email}},{{contact.fields.<key>}}, with a fallback{{#contact.first_name}}{{contact.first_name}}{{/contact.first_name}}{{^contact.first_name}}there{{/contact.first_name}}. Triple braces on a contact field are refused (400). aliasis unique in the organization (409 TEMPLATE_ALIAS_EXISTS); it and the template'snumbername it in a send and in Postmark's/email/withTemplate.
Newsletters
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/newsletters?status=&page=&size= |
– | page of Newsletter |
| POST | /api/v1/newsletters |
{ "name"?, "from", "fromName"?, "replyTo"?, "subject", "preheader"?, "kind"?, "blocks"?, "html"?, "templateId"?, "fromNewsletterId"?, "audience": "EVERYONE" | "LISTS", "listIds"? } |
Newsletter (a draft) |
| GET, PUT, DELETE | /api/v1/newsletters/{id} |
as POST (a draft or scheduled one) | Newsletter |
| GET | /api/v1/newsletters/{id}/review |
– | { "members", "mailable", "leftOut", "volumeLeft", "blockers": [{ "code", "message" }] } |
| POST | /api/v1/newsletters/{id}/send |
{ "at"?, "spreadHours"? (1–24), "spreadUntil"? } |
Newsletter (SCHEDULED or SENDING) |
| POST | /api/v1/newsletters/{id}/unschedule |
– | Newsletter (a draft again) |
| POST | /api/v1/newsletters/{id}/pause, /resume, /cancel |
– | Newsletter |
| POST | /api/v1/newsletters/{id}/release |
– | Newsletter (ends an agent hold now; signed-in person only) |
| GET | /api/v1/newsletters/{id}/results |
– | { "stats", "links", "progress" } |
| GET | /api/v1/newsletters/{id}/recipients?outcome=&page=&size= |
– | page of recipients, capped at 10,000 |
- A newsletter takes its content from
blocksorhtml, a template (templateId, copied) or an earlier newsletter (fromNewsletterId). Once sent it keeps its own copy. sendrefuses with422and the first blocker's code while the review has any:POSTAL_ADDRESS_REQUIRED,BROADCAST_NOT_IN_PLAN,BROADCAST_CONSENT_REQUIRED,BROADCAST_DMARC_REQUIRED,DOMAIN_NOT_VERIFIED,STREAM_PAUSED,TENANT_SENDING_PAUSED,STARTER_BROADCAST_NOT_ALLOWED,SCOPE_DENIED,SUBJECT_REQUIRED,CONTENT_REQUIRED,NO_AUDIENCE,EMPTY_AUDIENCE,PLAN_VOLUME_REACHED. A blocker that appears while it goes out pauses it, with the code aspausedReason.- Started with an API key or a personal token, a newsletter waits 15 minutes (
holdUntil), and every admin is told, with Cancel.startedByisPERSON,APPorAGENT. stats:recipients,notSent,queued,sent,delivered,opened(unique, an estimate),clicked(unique),bounced,complained,unsubscribed,failed,computedAt(recomputed every minute or so).links: the 10 most clicked, without their query strings.progress:audienceTotal,copiesMade,skipped,startedAt,spreadUntil.outcome:delivered,opened,clicked,bounced,complained,unsubscribed,not_sent,waiting. Each recipient has itsmessageId, whichGET /api/v1/messages/{id}opens.- Each contact gets one copy, even when on several of the lists. Copies are left out of the message list and the sending figures; a copy's
MessageDetailhascampaign: { "kind": "NEWSLETTER", "id", "name" }.
Automations
| Method | Path | Body | Response |
|---|---|---|---|
| GET, POST | /api/v1/automations |
{ "name", "trigger": { "kind": "LIST_JOINED" | "EVENT", "listId"?, "event"? }, "from", "fromName"?, "replyTo"?, "steps": [{ "kind": "SEND" | "WAIT", "waitSeconds"?, "subject"?, "preheader"?, "contentKind"?, "blocks"?, "html"?, "templateId"? }] } |
Automation[], Automation |
| GET, PUT, DELETE | /api/v1/automations/{id} |
as POST | Automation |
| POST | /api/v1/automations/{id}/activate |
{ "includeCurrentMembers"? } |
Automation (ACTIVE) |
| POST | /api/v1/automations/{id}/pause |
– | Automation (PAUSED) |
A contact goes through an automation once; it leaves when it unsubscribes, is blocked or erased, or leaves the list. Each step has its stats; enrolled counts the people active, completed and exited. Activating with a key or a token waits 15 minutes, as a newsletter does.
Overview
| Method | Path | Response |
|---|---|---|
| GET | /api/v1/campaigns/overview |
contacts (and the limit), newsletters by status, the last newsletter's figures, the last 30 days, the first steps done |
| GET | /api/v1/campaigns/person?email= |
{ "contactId", "state", "newsletters" }: whether an address is a contact, for the inbox's people panel and the sending view's search |
Public endpoints (no authentication)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/public/plans |
The public plans and their prices, for the website (see plans) |
| GET | /f/{slug} |
A sign-up form's hosted page (in the organization's brand and the browser's language) |
| POST | /f/{slug} |
A sign-up, from the hosted page or a plain HTML form on any site (email, first_name?, last_name?, the honeypot website and the signed t); answers the success page or redirects |
| GET, POST | /f/c/{token} |
Confirming a sign-up: the page shows a Confirm button, its POST confirms |
| GET | /u/{token} |
Hosted unsubscribe page (pt-PT or English from Accept-Language) |
| POST | /u/{token} |
Unsubscribe: RFC 8058 one-click (List-Unsubscribe=One-Click) or the page's button |
| GET | /a/{token} |
Signed attachment download used in inbound webhooks (valid 7 days); always application/octet-stream as an attachment (original type in X-Original-Content-Type), nosniff, sandboxing CSP; 409 VIRUS_DETECTED for mail flagged as a virus |
| GET | /a/inline/{token} |
Inline image of a message body, linked from GET /api/v1/messages/{id}/body (PNG, JPEG, GIF or WebP; valid one to two days); 409 VIRUS_DETECTED for mail flagged as a virus |
| POST | /dev/webhook-sink |
Receiver for the development webhook sink (non-prod only) |
| GET | /sim/open/{token}, /sim/click/{token} |
Simulated open/click tracking (non-prod only) |
| POST | /api/v1/public/try |
Opens a session of the website's try by email (see below) |
| GET | /api/v1/public/try/{token} |
What arrived for that session (see below) |
| GET | /api/v1/public/contact/challenge |
The website's contact form's proof of work (see below) |
| POST | /api/v1/public/contact |
A message to the team from the website's contact form (see below) |
The website's contact form
"Talk to us" on the website: the page solves a proof of work, then sends the message, which goes to the team with the visitor's address as its Reply-To. No authentication and no CSRF token; also served on the website's host. Nothing is sent to the visitor, and nothing is stored.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/v1/public/contact/challenge |
– | 200 { "algorithm": "SHA-256", "challenge", "maxnumber", "salt", "signature", "minimumFillMillis": 3000 } (the signup form's proof of work, which works while signups are paused), Cache-Control: no-store; 503 CONTACT_UNAVAILABLE while the form is off or past its hourly cap |
| POST | /api/v1/public/contact |
ContactForm |
202 {} |
// ContactForm
{ "topic": "PARTNER", "name": "Rita Sousa", "email": "rita@quintadorio.pt", "company": "Quinta do Rio",
"message": "We run email for twelve clients…", "website": "", "locale": "pt-PT",
"challenge": { "algorithm": "SHA-256", "challenge": "…", "number": 31337, "salt": "…", "signature": "…" } }
topic:PLANS,PARTNER,MOVINGorOTHER;name1 to 100 characters;emailat most 254;companyoptional, at most 100;message10 to 5,000 characters with at most 3 links (eachhttp://,https://orwww.);localeenorpt-PT. Otherwise400 VALIDATION_FAILED, with adetailsentry per field; the challenge is not spent.challenge: the solved challenge, the browser trying each number up tomaxnumberuntilSHA-256(salt + number)ischallenge; missing, altered, expired or used already:400 CHALLENGE_FAILED. The page waitsminimumFillMillisafter receiving it before sending.websiteis the hidden field people never fill. Filled, or the form sent sooner thanminimumFillMillisafter the challenge was issued:202 {}all the same, and nothing is sent.400 CONTACT_EMAIL_INVALID: the address's domain takes no mail, or is a disposable-address service.429 RATE_LIMITEDwithRetry-After(seconds): past 5 messages per client network an hour (an IPv4 address, an IPv6 /64), or 3 per sender address a day (a+tagcounts as the same address).503 CONTACT_UNAVAILABLE: the form is off (messaging.contact.enabled), past 50 messages from everyone this hour (until the hour ends), or the message could not be sent.
Try it by email
The website's demo: the page opens a session, the visitor emails the try address with its code in the subject, and the page polls until the email shows. No authentication and no CSRF token (the endpoints grant nothing; the token is the page's own). Both answers carry Cache-Control: no-store and X-Robots-Tag: noindex, nofollow; also served on the website's host.
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /api/v1/public/try |
none, or { "lang": "pt-PT" } (the page's language; otherwise Accept-Language) |
201 { "code": "MR-7F3K-Q2W9", "token": "…", "address": "try@mailroomly.com", "expiresAt": "…", "lifetimeSeconds": 900 }; 429 RATE_LIMITED past 5 sessions per client network an hour (an IPv4 address, an IPv6 /64); 503 TRY_DISABLED while the try is off |
| GET | /api/v1/public/try/{token} |
– | TrySession; 404 for any other token, and once the session is deleted (24 hours after its email arrived, or after it was opened) |
// TrySession, waiting (EXPIRED once the code's 15 minutes passed without an email)
{ "state": "WAITING", "receivedAt": null, "subject": null, "preview": null, "sender": null, "answer": null }
// TrySession, arrived
{ "state": "ARRIVED", "receivedAt": "2026-10-19T10:00:04Z", "subject": "Experimentar MR-7F3K-Q2W9",
"preview": "Olá, quero ver o Mailroomly a funcionar.", "sender": "r•••@quintadorio.pt", "answer": "SENT" }
code:MR-and 8 random characters of Crockford's base 32 (40 bits) in two groups; it matches one email, case and dashes aside, within 15 minutes (expiresAt;lifetimeSecondsis that lifetime,messaging.try.code-lifetime, so a page times its wait on its own clock whatever its time). The token is 128 random bits, base64url (22 characters).subjectandprevieware plain text (preview: the first 280 characters of the plain-text body, or of the HTML's text when there is none, line breaks kept); never HTML.senderis the From address masked.answer:PENDING(being decided, for a moment),SENT(the demo agent's answer is on its way to the From address),NOT_VERIFIED(the email failed DMARC, named several senders, came from an auto-responder or from Mailroomly itself: never answered),ALREADY_ANSWERED(that sender got an answer in the last 24 hours),PAUSED(answers are paused or at a cap, or the address is blocked: not answered).