Webmail at /mail signs in here, then speaks JMAP (RFC 8620 and RFC 8621) at /jmap/api, /jmap/upload/{accountId} and /jmap/download/{accountId}/{blobId}/{name} with the session cookie instead of HTTP Basic. Mail apps keep using Basic; a request with an Authorization header is judged by that header alone.
The cookie is __Host-zappocity_mail (zappocity_mail without Secure in dev): HttpOnly, Secure, SameSite=Strict, Path=/. It lasts 7 days and ends after 24 hours without use. Only its SHA-256 is stored.
CSRF. Every JMAP request made with the cookie, and DELETE /api/mail/session, must carry the session's csrfToken in X-CSRF-Token; without it the answer is 403. GET /api/mail/session needs only the cookie and returns the token. No CORS headers are ever sent, so other sites cannot read it.
Who may sign in. Any active account on an active tenant whose plan includes the webmail entitlement. The account and plan are checked again on every request, so suspending an account or tenant, or removing webmail from the plan, ends its sessions at once. A new password signs the account out everywhere. Failed sign-ins share the throttle with IMAP, SMTP and Basic JMAP: 10 failures per address or source in 15 minutes answer 429.
Sending uses JMAP Identity/get, Email/set (create) and EmailSubmission/set under the urn:ietf:params:jmap:submission capability; see docs/kb/webmail.md. Errors use RFC 9457 problem details.
Passwords. A signed-in user changes their password, or sets a recovery address outside the platform, with their current password. A new recovery address counts only once the link mailed to it is opened. A signed-out user can ask for a reset link, which goes only to the confirmed recovery address. Links put a one-time token in the URL fragment (#reset=... or #verify=...); a reset link lasts an hour, a confirmation link 24 hours, and an account gets at most 3 of each per hour. See docs/kb/password-reset.md.
Two-step sign-in. A user can turn on codes from an authenticator app (TOTP, RFC 6238: SHA-1, 6 digits, 30 seconds). Signing in then takes two calls: the password alone answers 401 with twoFactorRequired: true, and the same request again with code signs in. A code is a 6-digit TOTP code, used once, or one of ten single-use recovery codes. A wrong code counts as a failed sign-in for the throttle. With two-step sign-in on, IMAP, SMTP, ManageSieve and JMAP Basic refuse the account password and take app passwords instead. Turning it on, off, or making new recovery codes or app passwords needs the current password; turning it on also needs a code from the new secret. The secret is sealed with the server key. A notice goes to the recovery address for each change. An administrator can turn it off with DELETE /v1/tenants/{tenantId}/accounts/{accountId}/two-factor. See docs/kb/two-step-sign-in.md.
General
/api/forum/forums
The forums the reader can see
The platform's forums, and for a signed-in member (with the cookie and X-CSRF-Token) their team's. No sign-in is needed to read public forums; a request without the token is treated as signed out.
200The forums.{ "moderator": false, "platform": [ { "closed": false, "id": "6f1c2a9e-1b0e-4f3a-9a52-2f4d1e0c7b11", "name": "General", "slug": "general", "topics": 4, "visibility": "public" } ], "signedIn": true, "team": [], "teamCanHaveForums": false }
/api/forum/forums/{forumId}
A forum and its topics (pinned first, then the latest)
| Parameter | In | Type | |
|---|---|---|---|
forumId * | path | string |
200The forum, its topics, and whether the reader may start one or moderate.{ "canPost": true, "forum": { "name": "General" }, "moderator": false, "topics": [ { "author": "Pat", "hidden": false, "id": 7, "lastPostAt": "2026-10-09T12:00:00Z", "locked": false, "mine": false, "pinned": true, "posts": 3, "title": "Welcome" } ] }404No such forum
/api/forum/forums/{forumId}/topics
Start a topic
Posts show the author's display name (or the part of the address before @), never the address. 30 posts an hour at most.
| Parameter | In | Type | |
|---|---|---|---|
forumId * | path | string | |
X-CSRF-Token * | header | string |
{
"body": "The office is closed this **Friday**.",
"title": "Office closed Friday"
}
201The topic.401Not signed in.403Not allowed to start topics here (not a member404No such forum.422A title (up to 200 characters) and a first post (up to 20000) are needed.429Too many posts in the last hour.
/api/forum/posts/{postId}
Edit or hide a post
Its author edits it (not in a locked topic) or hides it; moderators hide or show any post in their team's forums.
| Parameter | In | Type | |
|---|---|---|---|
postId * | path | integer | |
X-CSRF-Token * | header | string |
204Done.403Not the author or a moderator.404No such post.
/api/forum/teams/{tenantId}
A team's forums the reader can see
Its public forums, or all of them for a signed-in member (with the cookie and X-CSRF-Token), while its plan has forums.
| Parameter | In | Type | |
|---|---|---|---|
tenantId * | path | string |
200The forums.{ "forums": [ { "id": "6f1c2a9e-1b0e-4f3a-9a52-2f4d1e0c7b11", "name": "Announcements", "topics": 2, "visibility": "public" } ], "member": false, "signedIn": false }
/api/forum/topics/{topicId}
A topic and its posts
Posts are Markdown. A hidden post's text shows only to moderators.
| Parameter | In | Type | |
|---|---|---|---|
topicId * | path | integer |
200The topic.{ "canReply": true, "moderator": false, "posts": [ { "author": "Pat", "body": "Hello **all**", "createdAt": "2026-10-09T12:00:00Z", "editedAt": null, "hidden": false, "id": 21, "mine": true } ], "topic": { "id": 7, "title": "Welcome" } }404No such topic
/api/forum/topics/{topicId}
Pin, lock or hide a topic (the team's owners and admins)
| Parameter | In | Type | |
|---|---|---|---|
topicId * | path | integer | |
X-CSRF-Token * | header | string |
{
"locked": false,
"pinned": true
}
204Done.403Not a moderator.404No such topic.
/api/forum/topics/{topicId}/posts
Reply in a topic
| Parameter | In | Type | |
|---|---|---|---|
topicId * | path | integer | |
X-CSRF-Token * | header | string |
{
"body": "Thanks!"
}
201The post.403Not allowed to reply here404No such topic.422A reply is 1 to 20000 characters.429Too many posts in the last hour.
/api/mail/account/delete
Whether the mailbox is to be deleted, and how to confirm a deletion
deleteAfter is when the mailbox goes (null: it does not); graceDays is the wait a new deletion gets; confirmWith is password, or address for a mailbox signed in to with a Zappocity account (which has no password here). Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The deletion state.{ "confirmWith": "password", "deleteAfter": null, "graceDays": 14, "requestedBy": "" }401Not signed in.
/api/mail/account/delete
Delete my mailbox
After graceDays the mailbox and everything in it are deleted; until then it works as before and DELETE cancels. With a grace period of 0 it goes at once and the session ends (204). Not from a session staff opened as the user.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Scheduled.{ "deleteAfter": "2026-10-23T12:00:00Z", "requestedBy": "pat@mailo.city (themselves)" }204Deleted at once.401Not signed in.403Wrong password or address409Under a legal hold429Too many wrong passwords.
/api/mail/account/delete
Keep my mailbox
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204No longer to be deleted.401Not signed in.
/api/mail/aliases
The signed-in user's aliases
The user's aliases, how many they may have (max), how many of those may be on shared domains (maxShared, with usedShared already there), whether they may add their own (canAdd, the self_aliases entitlement) and the domains they may put them on: their tenant's domains and the shared domains open for signup. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The aliases.{ "aliases": [ "sales@acme.example" ], "canAdd": true, "domains": [ "acme.example", "mailo.city" ], "max": 10, "maxShared": 3, "usedShared": 0 }
/api/mail/aliases
Add an alias for the signed-in user
For plans with self_aliases. The name before @ may use letters, digits, '.', '_' and '-' (no '+'). Within the plan's aliases per user (or the user's own limit) and the tenant's total; on a shared domain, reserved names are refused. The alias can be sent from at once.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
201Added.403Not part of the plan.409Taken422Not a valid name
/api/mail/aliases/{address}
Remove one of the signed-in user's aliases
| Parameter | In | Type | |
|---|---|---|---|
address * | path | string | |
X-CSRF-Token * | header | string |
204Removed.403Not part of the plan.404The user has no such alias.
/api/mail/app-passwords
Make an app password
A password for one mail app: 16 letters, shown only now. It signs in to IMAP, SMTP, ManageSieve, CalDAV/CardDAV and JMAP Basic, never to webmail. At most 25 per account. Changing the account password deletes them all. Confirmed with currentPassword, or, when GET /api/mail/security says appPasswordConfirm is zappocity, by a Zappocity confirmation in the last 10 minutes (currentPassword is then ignored; without one, 403 with confirmUrl).
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"currentPassword": "correct horse battery",
"name": "Phone mail app"
}
201Made.403Wrong current password; no recent Zappocity confirmation (confirmUrl says where); or missing CSRF token.409The account already has 25.422Missing or too long name.429Too many wrong passwords.
/api/mail/app-passwords/{id}
Remove an app password
Mail apps signed in with it are signed out within a minute.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string | |
id * | path | string |
204Removed.404No such app password on this account.
/api/mail/bimi/{domain}
Load a sender's BIMI logo
For a domain named in an Email's bimiDomain property. Needs the session cookie (it is used in <img>, so no CSRF token). The server fetches the logo from the domain's BIMI record over https on port 443 only, through the same public-address-only dialer as remote images, rebuilds the SVG keeping only shapes and gradients (32 KB at most) and caches it for a day. Answered only for accounts that received mail marked with the domain.
| Parameter | In | Type | |
|---|---|---|---|
domain * | path | string |
200The logo, as image/svg+xml with a sandboxing Content-Security-Policy.401Not signed in.404No usable logo
/api/mail/calendar-links
Your calendar subscription links, and the calendars you can make one for
Subscription links are secret iCalendar addresses of one of your own calendars that any calendar app can subscribe to, for people without a Mailocity account. detail is full or busy (times only, every event called "Busy"). Events marked private show as busy in either; secret ones are left out.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The links (without their addresses) and your calendars.{ "calendars": [ { "id": "K12", "name": "Work" } ], "items": [ { "calendarId": "K12", "calendarName": "Work", "createdAt": "2026-10-08T15:00:00Z", "detail": "busy", "id": "3d0c8e52-7a41-4f7e-b6a1-0c2e9d5f8a34", "label": "For clients", "lastUsed": null } ] }
/api/mail/calendar-links
Make a subscription link
The answer has the link's url, shown this once: only a hash of it is kept. At most 50 links each. Not while in someone else's mailbox.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"calendarId": "K12",
"detail": "busy",
"label": "For clients"
}
201The link, with its address.{ "calendarId": "K12", "calendarName": "Work", "createdAt": "2026-10-08T15:00:00Z", "detail": "busy", "id": "3d0c8e52-7a41-4f7e-b6a1-0c2e9d5f8a34", "label": "For clients", "lastUsed": null, "url": "https://mail.mailo.city/cal/cs_cal_Qm9vZ3JhZ3V0aW5nIHRoZSBjYWxlbmRhciBmZWVk.ics" }403In someone else's mailbox.422Not one of your calendars
/api/mail/calendar-links/{id}
Revoke a subscription link
The address stops working at once.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Revoked.404You have no link with that id.
/api/mail/client-settings
What to enter in a mail app, with the signed-in user's username
The IMAP, SMTP (submission), ManageSieve and JMAP settings for mail apps, and the CalDAV and CardDAV settings for calendar and contacts apps (calDav, cardDav). appPassword is true when two-step sign-in is on, so mail apps need an app password rather than the account password. Ports and security follow the server's configuration (None only on a server without TLS, such as a development one). With TLS, smtp is submission over implicit TLS (465) and altPort/altSecurity name STARTTLS on 587 for networks that block 465.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Mail app settings.{ "appPassword": false, "autoconfigUrl": "https://mail.mailo.city/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=bob@acme.test", "calDav": { "calendarsUrl": "https://mail.mailo.city/dav/6f1c2b9e-0d4a-4c71-9a55-2d7e8f3b1a20/calendars/", "server": "mail.mailo.city", "serverUrl": "https://mail.mailo.city/.well-known/caldav", "username": "bob@acme.test" }, "cardDav": { "contactsUrl": "https://mail.mailo.city/dav/6f1c2b9e-0d4a-4c71-9a55-2d7e8f3b1a20/contacts/", "server": "mail.mailo.city", "serverUrl": "https://mail.mailo.city/.well-known/carddav", "username": "bob@acme.test" }, "imap": { "authentication": "Normal password", "host": "mail.mailo.city", "port": 993, "security": "SSL/TLS", "username": "bob@acme.test" }, "jmap": { "sessionUrl": "https://mail.mailo.city/jmap/session", "username": "bob@acme.test", "webSocketUrl": "wss://mail.mailo.city/jmap/ws", "wellKnownUrl": "https://mail.mailo.city/.well-known/jmap" }, "manageSieve": { "authentication": "Normal password", "host": "mail.mailo.city", "port": 4190, "security": "STARTTLS", "username": "bob@acme.test" }, "smtp": { "altPort": 587, "altSecurity": "STARTTLS", "authentication": "Normal password", "host": "mail.mailo.city", "port": 465, "security": "SSL/TLS", "username": "bob@acme.test" }, "username": "bob@acme.test" }
/api/mail/contact-photo/{email}
The photo of the user's contact with this address
For an img element, so without the CSRF header (the session cookie is SameSite=Strict). Only JPEG, PNG, GIF and WebP photos kept in the card are served.
| Parameter | In | Type | |
|---|---|---|---|
email * | path | string |
200The image.404No contact with this address has a photo.
/api/mail/contact-photos
The addresses of the user's contacts that have a photo
For the message list's sender pictures. Photos are kept in the cards (JSContact media of kind photo).
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The addresses, lower case.{ "emails": [ "amy@example.com" ] }
/api/mail/domain-blocks
The user's domain blocks, and their team's
Mail from other servers that a block matches is refused or filed in Junk: domain matches the sender's domain and its subdomains, mx the mail servers of the sender's domain, and named any domain the message names (From, Sender, Reply-To, links). items are the user's own; team are the team's, which apply too but only team admins change. See docs/kb/domain-blocking.md.
200The blocks.{ "items": [ { "action": "junk", "createdAt": "2026-10-09T12:00:00Z", "createdBy": "bob@acme.test", "id": 12, "kind": "domain", "note": "", "value": "pest.example" } ], "team": [ { "action": "reject", "createdAt": "2026-10-09T11:00:00Z", "createdBy": "admin@acme.test", "id": 7, "kind": "named", "note": "Invoice scam", "tenantId": "0b9f5a52-1f7e-4b0e-8d3c-5e2a9c6d4f10", "value": "phish.example" } ] }
/api/mail/domain-blocks
Block a domain for the signed-in user
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"action": "junk",
"kind": "mx",
"value": "bulkhost.example"
}
201The block.400Not the JSON above.409That is blocked already.422Not a domain
/api/mail/domain-blocks/{id}
Remove one of the user's own blocks
| Parameter | In | Type | |
|---|---|---|---|
id * | path | integer | |
X-CSRF-Token * | header | string |
204Removed.404Not one of the user's own blocks.
/api/mail/emails/{emailId}/sender
The Zappocity profile and badges of a message's proven sender
For a message in the signed-in user's mailbox whose From this server proved (DMARC passed, or its owner sent it here) and whose address is a mailbox here linked to a Zappocity account. profile is null otherwise, or when the person shows nothing. With their profile hidden only the Staff and Administrator badges show, so mail from staff is easy to tell from mail that only claims to be. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
emailId * | path | string | |
X-CSRF-Token * | header | string |
200The profile, or null.{ "profile": { "badges": [ { "color": "primary", "icon": "user", "key": "staff", "name": "Staff", "rule": "staff" } ], "bio": "Support lead", "handle": "pat", "name": "Pat Lee", "pronouns": "they/them", "visible": true, "website": "https://pat.example" } }401Not signed in.404No such message in this mailbox.
/api/mail/image
Load one remote image through the server
Follows a link from POST /api/mail/images; no cookie is needed because the link is signed. The server fetches the image itself, so the sender sees the server's address, not the reader's. Only public internet addresses are contacted (redirects included, at most 3), only PNG, JPEG, GIF, WebP, AVIF, BMP and ICO are passed on (never SVG), up to 10 MB within 15 seconds.
| Parameter | In | Type | |
|---|---|---|---|
u * | query | string | The image URL. |
a * | query | string | The account the link was made for. |
e * | query | integer | Expiry |
s * | query | string | Signature. |
200The image.403The link is altered or expired502The image could not be fetched or is not an allowed image.
/api/mail/images
Get links that load a message's remote images through the server
Webmail calls this when the reader presses Load images. Each http or https URL on port 80 or 443 gets a signed /api/mail/image link for the signed-in account, valid for an hour (or until the server restarts). Other URLs are left out and stay blocked. Needs the session cookie and X-CSRF-Token.
{
"urls": [
"https://img.example.com/logo.png"
]
}
200Proxy links keyed by the original URL.{ "links": { "https://img.example.com/logo.png": "/api/mail/image?a=...\u0026e=...\u0026s=...\u0026u=https%3A%2F%2Fimg.example.com%2Flogo.png" } }400Not the expected JSON401Not signed in.403Missing or wrong CSRF token.
/api/mail/impersonate
Open a support session from a one-time link
The token from a link made with POST /v1/tenants/{tenantId}/accounts/{accountId}/impersonate. Answers like a sign-in, with impersonatedBy and impersonationReason, and sets the cookie for a one-hour session that cannot change sign-in settings (403 there).
201The session.400The link was used or is more than five minutes old.403The account cannot sign in.
/api/mail/imports
The user's imports from other mail servers
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Newest first; fields as MailImport in api/openapi.yaml.{ "items": [ { "foldersDone": 1, "foldersTotal": 6, "host": "imap.example.com", "id": "9c1e...", "messagesDone": 120, "messagesTotal": 900, "status": "running", "username": "amy@example.com" } ] }
/api/mail/imports
Import mail from another server over IMAP
Copies every remote folder in the background: INBOX, Sent, Drafts, Junk, Trash and Archive (by their special-use flags or usual names) into the matching folders here, other folders into folders of the same name. Read, starred, answered and draft marks and the received date come along; deleted messages and virtual folders (All Mail, Starred) are left out. An import resumes where it stopped and skips messages already here (same Message-ID and size), so running it again copies only what is new. It stops when the mailbox is full. One import per account at a time; the user is emailed when it ends.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"host": "imap.gmail.com",
"password": "app password",
"skipFolders": [
"[Gmail]/Spam"
],
"username": "amy@gmail.com"
}
201Queued.409An import is already queued or running.422A value is not valid.
/api/mail/imports/{id}
Cancel a queued or running import
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Cancelled; what was copied stays.404No such import.409It has already ended.
/api/mail/invites
The signed-in user's invitations
How many invitation links the user can still make (after adding what the drip has brought), when more arrive, and the links they made with their status. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The allowance and the links.{ "allowance": { "enabled": true, "left": 1, "max": 5, "nextTopUp": "2026-11-06T20:17:28Z", "perPeriod": 2 }, "invitations": [ { "code": "JsR4bKVKV51X3IFseJvu2g", "createdAt": "2026-10-07T21:17:24Z", "expiresAt": "2026-10-21T21:17:24Z", "link": "https://invite.zappo.city/?invite=JsR4bKVKV51X3IFseJvu2g", "note": "for dave", "plan": "free", "revokedAt": null, "status": "open", "usedAt": null, "usedBy": null } ] }
/api/mail/invites
Make one invitation link
Uses up one of the user's invitations. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
201The new invitation403No invitations left
/api/mail/invites/{code}
Cancel one of the user's unused links
The invitation goes back to the user's allowance. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
code * | path | string | |
X-CSRF-Token * | header | string |
204Cancelled.404No unused invitation of the user's has that code.
/api/mail/later
The signed-in user's snoozed messages and muted conversations
snoozed maps each snoozed message's JMAP id to when it comes back; muted lists the JMAP thread ids of muted conversations. Scheduled sends are JMAP: EmailSubmission/get with undoStatus "pending".
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Snoozes and mutes.{ "muted": [ "T17" ], "snoozed": { "E42": "2026-10-09T08:00:00Z" } }
/api/mail/lists
The team's mailing lists the user is on or may join
Lists of the user's team whose joining is team or anyone, and every list the user's own address is on. See docs/kb/mailing-lists.md.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The lists, by name.{ "items": [ { "address": "team@acme.test", "description": "", "id": "6f1c2e8a-3b5d-4c7e-9f10-2a3b4c5d6e7f", "joining": "team", "kind": "discussion", "member": true, "moderator": false, "name": "Team" } ] }401Not signed in.
/api/mail/lists/{id}/membership
Join a list with the user's own address
Only a list of the user's team whose joining is team or anyone.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Joined.404No such list409The list is full (the plan's maxListMembers).
/api/mail/lists/{id}/membership
Leave a list
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Left.404The user is not on the list.
/api/mail/login-history
The user's recent sign-ins to every service
Up to 200, newest first, within the days the platform keeps (days).
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The sign-ins.{ "days": 90, "items": [ { "at": "2026-10-08T11:59:00Z", "id": 7, "ip": "192.0.2.10", "login": "alice@example.com", "reason": "password", "service": "imap", "success": false, "userAgent": "" } ] }
/api/mail/messages/{emailId}/reaction
React to a message
Sets the user's reaction to the message to this emoji (one at a time, as in Outlook); the same emoji again takes it back. It is sent as an RFC 9078 reaction message to the message's sender and its other recipients (not the user), who see it on the message if their mail supports reactions, or as a short message if not. With account, the reaction is from a shared mailbox the user may send as.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string | |
emailId * | path | string | |
account | query | string |
{
"emoji": "👍"
}
200The message's reactions now.{ "reactions": [ { "address": "amy@acme.com", "emoji": [ "👍" ], "mine": true, "name": "Amy" } ] }400Not the JSON above.404No such message (or it has no Message-ID), or no such account the user may send as.422Not one emoji
/api/mail/mute
Mute or unmute a conversation
New mail in a muted conversation is marked read and filed in Archive instead of the inbox, on whichever MX server receives it. Mail already there is not moved.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"muted": true,
"threadId": "T17"
}
204Changed.400Not the JSON above.404No such conversation in the user's account.
/api/mail/notes
Your notes and the team's
Your personal notes and your team's notes (which every member reads and edits), pinned first, then the latest changed, with a short preview. q searches titles and text. See docs/kb/notes.md.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string | |
q | query | string |
200The notes.{ "items": [ { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "mine": true, "owner": "amy@acme.test", "pinned": true, "preview": "Network: Acme-Guest…", "team": true, "title": "Office wifi", "updatedAt": "2026-10-09T15:00:00Z", "updatedBy": "bob@acme.test", "version": 3 } ] }
/api/mail/notes
Write a note
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"body": "Network: **Acme-Guest**",
"team": true,
"title": "Office wifi"
}
201The note409You keep as many notes as you can (5000).422A value is not valid.
/api/mail/notes/{id}
A note
With its Markdown text rendered (html, escaped; links only to http(s) and mail).
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
200The note.404No such note
/api/mail/notes/{id}
Change a note
The whole note, with the version it was made to. If someone changed it meanwhile the answer is 409 with the current note (current), so nothing is lost. Only its owner moves it between personal and team.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
{
"body": "Network: **Acme-Guest**, password on the fridge",
"team": true,
"title": "Office wifi",
"version": 3
}
200The note.404No such note.409Changed meanwhile; current is the note as it is now.422A value is not valid.
/api/mail/notes/{id}
Delete a note
Your own, or (team admins) any team note.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Deleted.404No such note
/api/mail/password
Change the signed-in user's password
Needs the cookie and X-CSRF-Token. Every session ends, mail apps included; this browser gets a new session cookie and CSRF token in the answer. A notice goes to the recovery address if there is one. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Changed. Sets a new session cookie.401Not signed in.403Wrong current password422The new password is too short or too long.429Too many wrong passwords.
/api/mail/password-reset
Ask for a password reset link
Always answers 202 the same way, whether or not the account exists or has a recovery address, so the answer does not reveal accounts. Suspended accounts get no link. At most 10 requests per source address and per account address an hour. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
202A link was sent if the account has a recovery address.415Not JSON.429Too many requests.
/api/mail/password-reset/confirm
Set a new password from a reset link
The token from #reset= works once, for an hour, and only while the account is active and its recovery address is unchanged. Every session ends; sign in with the new password. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
200The password is set.{ "address": "alice@example.com" }400The link expired or was already used.422The new password is too short or too long; the link still works.
/api/mail/pgp-key
The user's published OpenPGP public key, if any
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The key (null when none).{ "key": { "armored": "-----BEGIN PGP PUBLIC KEY BLOCK-----...", "fingerprint": "8D0C6E3A...", "published": true, "userIds": [ "Pat Lee \u003cpat@acme.test\u003e" ], "wkdUrl": "https://openpgpkey.acme.test/.well-known/openpgpkey/acme.test/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=pat" } }
/api/mail/pgp-key
Publish a public key, or publish or unpublish the one kept
key is an OpenPGP public key, armored or binary (64 KB at most), with a user ID naming the mailbox's address; a secret key is refused. It is served in the Web Key Directory while published (the default). Without key, published turns publishing on or off.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The key.403A session opened by staff as the user.404No key to publish or unpublish.422Not a public key
/api/mail/pgp-key
Remove the key
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204Removed.
/api/mail/plus-mode
Choose what happens to the user's name+tag@ mail
folders files it in a folder named after the tag (only with the plus_folders entitlement), inbox keeps it in the inbox, refuse turns it away as an unknown address, and an empty string follows the domain's setting.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"mode": "refuse"
}
204Saved.400Not the JSON above.403`folders` without the plus_folders entitlement.422Not one of the modes.
/api/mail/reactions
Reactions to messages
Who reacted to these messages with what (RFC 9078 reactions received, and the user's own), by email id; mine marks the user's. Reaction messages themselves carry the $reaction keyword, so clients can leave them out of lists. account names a mailbox shared with the user instead of their own.
| Parameter | In | Type | |
|---|---|---|---|
ids * | query | string | Email ids, comma-separated, up to 200. |
account | query | string |
200The reactions, and the choices webmail offers.{ "choices": [ "👍", "❤️", "😂", "😮", "😢", "🎉" ], "items": { "E17": [ { "address": "bob@acme.com", "emoji": [ "👍" ], "mine": false, "name": "Bob" } ] } }404No such account (or not shared with the user).
/api/mail/recovery
Set or remove the recovery address
Needs the cookie, X-CSRF-Token and the current password. An empty email removes the recovery address at once. Any other address gets a confirmation link and replaces the old one only when the link is opened. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"currentPassword": "correct horse battery",
"email": "alice.personal@example.net"
}
200Removed.202Confirmation link sent.{ "pending": "alice.personal@example.net", "recoveryEmail": null }403Wrong current password422Not one plain address429Too many wrong passwords502The confirmation could not be sent.
/api/mail/recovery/verify
Confirm a recovery address from its link
No session needed; the token from #verify= proves the mail arrived. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
200Confirmed.{ "address": "alice@example.com", "recoveryEmail": "alice.personal@example.net" }400The link expired or was already used.
/api/mail/report
Report messages as spam or phishing
Each message is copied into a report for the team's administrators (and the platform operator) and moved to Junk, which also teaches the spam filter. Messages not in the user's account are skipped. Up to 100 at a time and 200 a day.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"emailIds": [
"E42"
],
"kind": "phishing"
}
200How many were reported.{ "junkId": "M4", "reported": 1 }400Not the JSON above.429Too many reports today.
/api/mail/resources
The team's rooms and equipment, with the times each is booked
For picking a room in the event editor: busy times only (not what or by whom) between from and to (RFC 3339, at most 31 days; today and the next week by default). Book one by adding its address to the event's participants.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string | |
from | query | string | |
to | query | string |
200The resources.{ "from": "2026-11-02T00:00:00Z", "items": [ { "address": "board@acme.test", "booking": "open", "busy": [ { "end": "2026-11-02T11:00:00Z", "start": "2026-11-02T10:00:00Z" } ], "capacity": 10, "description": "", "id": "6f1c2e8a-3b5d-4c7e-9f10-2a3b4c5d6e7f", "kind": "room", "location": "3rd floor", "maxMinutes": 240, "name": "Board room" } ], "to": "2026-11-03T00:00:00Z" }400from or to are not valid.
/api/mail/security
Two-step sign-in state and app passwords
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The signed-in user's security settings.{ "appPasswordConfirm": "zappocity", "appPasswords": [], "confirmUrl": "/api/mail/sso/start?confirm=1\u0026next=%2Fmail%23%2Fsettings%2Fapp-passwords", "recoveryCodesLeft": 0, "twoFactor": false }401Not signed in.403Missing or wrong CSRF token.
/api/mail/security-alerts
Which security events the user is emailed about
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The choices.{ "failed": true, "newSignin": true }
/api/mail/security-alerts
Choose which security events are emailed
newSignin: a sign-in from an address the account was not used from before. failed: repeated wrong passwords and lockouts. Alerts go to the account and its recovery address.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204Saved.
/api/mail/send
Send a plain-text message as a mailbox, with its API token
For other services' own mail (Zappocity's confirmation and reset links). Authenticate with Authorization: Bearer cs_mt_..., a mailbox API token with the send scope, made in the admin panel. The message is from the mailbox's address and passes the same checks and limits as mail its user sends (the plan's sending limits apply), and it is archived like it; it is not filed in Sent. Each recipient gets a message of their own. The same tokens with the jmap scope sign in to JMAP as the mailbox, as Authorization: Bearer.
{
"fromName": "Zappocity",
"subject": "Confirm your email address",
"text": "Open this link...",
"to": [
"amy@example.com"
]
}
202Sent (or queued for other domains).{ "sent": 1 }400Not JSON with the documented fields.401No mailbox API token with the send scope422Too many recipients, a header on two lines, or the mailbox may not send this (its limits, for example).
/api/mail/session
The signed-in account and its CSRF token
Needs the cookie only.
200Signed in.401No session
/api/mail/session
Sign in to webmail
Body must be application/json; anything else answers 415. When the password is a temporary one an administrator set, the session says passwordTemporary: true, and every other call answers 403 with passwordChangeRequired: true until a new password is set with POST /api/mail/password.
{
"address": "alice@example.com",
"password": "correct horse battery"
}
201Signed in. Sets the session cookie.401Wrong address or password, or the account cannot sign in. The same answer for unknown addresses. With the right password for an account with two-step sign-in, the problem has `twoFactorRequired: true`: send the request again with `code`.{ "detail": "Enter the code from your authenticator app", "status": 401, "twoFactorRequired": true, "type": "about:blank" }403The password was right but the plan does not include webmail.415Not JSON.429Too many failed sign-ins.
/api/mail/session
Sign out
Ends the session on the server and clears the cookie; a session signed in with Zappocity also signs out of zappo.city (and so everything signed in from there). Needs X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204Signed out.401No session.403Missing or wrong CSRF token.
/api/mail/sessions
Where the user is signed in to webmail
Each session's address, browser, start and last use; current marks the one asking. id is a short hash, never the token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The sessions.{ "items": [ { "createdAt": "2026-10-08T09:00:00Z", "current": true, "expiresAt": "2026-10-15T09:00:00Z", "id": "3f2a9c1b7d4e5f60", "ip": "192.0.2.10", "lastSeen": "2026-10-08T12:00:00Z", "userAgent": "Mozilla/5.0 ..." } ] }
/api/mail/sessions/{id}
Sign out one session, or every other one
The id others signs out every session but the one asking.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Signed out.404No such session of the user.
/api/mail/smime
The user's S/MIME certificate, if any
keysAccepted says the server can keep a private key (to sign).
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The certificate (null when none).{ "certificate": { "canSign": true, "fingerprint": "3A1F...", "issuer": "CN=Example CA", "notAfter": "2027-10-09T00:00:00Z", "pem": "-----BEGIN CERTIFICATE-----...", "subject": "CN=Pat Lee" }, "keysAccepted": true }
/api/mail/smime
Keep a certificate (and optionally its private key), or forget the key
certificate is PEM, with its chain; it must be for the mailbox's address, valid now and for email protection. privateKey (unencrypted PEM, matching the certificate) is sealed with the server's key and lets EmailSubmission/set sign with smimeSign. forgetKey alone removes the key and keeps the certificate.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The certificate.403A session opened by staff as the user.422Not a certificate for this address
/api/mail/smime
Remove the certificate and key
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204Removed.
/api/mail/snooze
Snooze messages until a time
Moves each message out of mailboxId to the Snoozed folder (made when first needed) and brings it back to mailboxId, unread, at until, which must be in the next year. Messages not in the user's account are skipped.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"emailIds": [
"E42"
],
"mailboxId": "M1",
"until": "2026-10-09T08:00:00Z"
}
200Snoozed.{ "mailboxId": "M7", "snoozedUntil": "2026-10-09T08:00:00Z" }400Not the JSON above.422`until` is not in the next year, or `mailboxId` is not one of the user's folders.
/api/mail/snooze/{emailId}
Bring a snoozed message back now
| Parameter | In | Type | |
|---|---|---|---|
emailId * | path | string | |
X-CSRF-Token * | header | string |
204Back in its folder404No such message
/api/mail/spam-policy
The user's spam settings and what applies to them
junkScore, allow and block are the user's own; effectiveJunkScore and rejectScore are what applies after the team's and the platform's settings. enabled says whether spam filtering is on at all.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The settings.{ "allow": [ "news.example" ], "block": [], "effectiveJunkScore": 6, "enabled": true, "junkScore": null, "rejectScore": 15 }
/api/mail/spam-policy
Replace the user's own junk score and sender lists
junkScore (null for the team's) is the score at which mail goes to Junk. allow and block hold up to 1000 addresses or domains each: allowed senders are never filed in Junk as spam, blocked ones always are.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"allow": [
"news.example"
],
"block": [
"spammy.example"
],
"junkScore": 5
}
204Saved.400Not the JSON above.422A list entry is not an address or domain
/api/mail/sso
Which ways of signing in to webmail are open
200Whether Sign in with Zappocity is offered, and whether the password sign-in still works.{ "passwordSignin": true, "sso": true }
/api/mail/sso/callback
Where Zappocity sends the browser back
| Parameter | In | Type | |
|---|---|---|---|
code | query | string | |
state | query | string |
302Signed in, to next; or to /mail#sso-error=... (expired, unverified, no-mailbox, suspended, not-in-plan).
/api/mail/sso/start
Sign in to webmail (or the team manager) with Zappocity
Sends the browser to Zappocity's sign-in; it comes back to /api/mail/sso/callback. The mailbox linked to the Zappocity account (the one with its email as address first) is signed in, when Zappocity has confirmed the email. next is /mail or /manage (or a place in them, after #). With confirm=1, Zappocity asks the person to prove who they are again (prompt=login) and the session it comes back to is marked confirmed, as making an app password needs.
| Parameter | In | Type | |
|---|---|---|---|
next | query | string | |
confirm | query | string |
302To Zappocity
/api/mail/team-templates
The team's templates
Names and subjects of the templates the user's team keeps, the most used first, for Templates… when writing. canManage says whether the user may share and delete them (team admins, and members whose role has the templates permission).
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The templates.{ "canManage": false, "items": [ { "id": "0b8e5d2c-1a3f-4e6d-9c7b-2f1e0d9c8b7a", "name": "Quote follow-up", "subject": "Your quote", "uses": 12 } ] }
/api/mail/team-templates/{id}
A team template, to insert
Its subject, html and text. Counted as a use. Webmail cleans the HTML to plain formatting before inserting it.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
200The template.{ "createdBy": "lead@acme.test", "html": "\u003cp\u003eHi,\u003c/p\u003e", "id": "0b8e5d2c-1a3f-4e6d-9c7b-2f1e0d9c8b7a", "name": "Quote follow-up", "subject": "Your quote", "text": "", "uses": 13 }404No such template in the user's team.
/api/mail/team-templates/{id}
Delete a team template
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Deleted.403The user may not manage team templates.404No such template in the user's team.
/api/mail/tickets
The user's tickets
teamAdmins says the team has other owners or admins to ask.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Their tickets, as Ticket in api/openapi.yaml.{ "items": [ { "id": 12, "priority": "normal", "queue": "team", "status": "pending", "subject": "Phone will not sync" } ], "teamAdmins": true }
/api/mail/tickets
Ask the team's admins or Zappocity support
A team ticket emails the team's owners and admins (up to 20 open tickets per user). to: platform opens a request on the Zappocity help desk instead, as the user, and answers zappocityRef (ZC-41) and url, where the user follows it on their Zappocity account; the user is emailed. On a plan with priority support it starts at high priority.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
201The team ticket, or for Zappocity support its number and link.{ "url": "https://zappo.city/account#ticket=41", "zappocityRef": "ZC-41" }422A value is missing.429Too many open tickets.
/api/mail/tickets/{id}
One of the user's tickets with its replies (never internal notes)
| Parameter | In | Type | |
|---|---|---|---|
id * | path | integer | |
X-CSRF-Token * | header | string |
200The ticket and its messages.404Not one of the user's tickets.
/api/mail/tickets/{id}/close
Close one of the user's tickets
Abuse tickets are closed by the platform's staff only (403).
| Parameter | In | Type | |
|---|---|---|---|
id * | path | integer | |
X-CSRF-Token * | header | string |
204Closed.403An abuse ticket.
/api/mail/tickets/{id}/messages
Reply on a ticket; it opens again
| Parameter | In | Type | |
|---|---|---|---|
id * | path | integer | |
X-CSRF-Token * | header | string |
204Sent.409The ticket is closed.
/api/mail/trackers
Make a read tracker for a message about to be sent
For plans with the read_tracking entitlement (see readTracking in the session). Returns a token and the URL of a 1x1 image to put in the message's HTML; each load of that image counts as an open. At most 1,000 per account per day. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The new tracker.{ "token": "qOgc5Ee4_GC8gG98OT-Sew", "url": "https://mail.example.com/t/qOgc5Ee4_GC8gG98OT-Sew.gif" }403The plan does not include read tracking.429Too many trackers made today.
/api/mail/trackers/stats
Get the open counts of the signed-in user's trackers
Tokens that are not the account's own are left out. proxiedOpens counts loads through a mail provider's image proxy (Gmail, Yahoo), which can happen without anyone reading. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Counts keyed by token.{ "trackers": { "qOgc5Ee4_GC8gG98OT-Sew": { "createdAt": "2026-10-07T20:50:30Z", "firstOpen": "2026-10-07T20:50:32Z", "lastOpen": "2026-10-07T20:50:32Z", "opens": 1, "proxiedOpens": 0, "token": "qOgc5Ee4_GC8gG98OT-Sew" } } }400Not the expected JSON
/api/mail/two-factor/disable
Turn two-step sign-in off
The password alone signs in again, in webmail and mail apps. Recovery codes are deleted; app passwords stay. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204Off.403Wrong current password429Too many wrong passwords.
/api/mail/two-factor/enable
Turn two-step sign-in on
Takes a code from the secret made by setup. Returns ten recovery codes, shown only now. Every other session ends, mail apps included; from now on mail apps need app passwords. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"code": "492039"
}
200On.409No setup is waiting422The code is wrong.429Too many wrong codes.
/api/mail/two-factor/recovery-codes
Replace the recovery codes
The old codes stop working. The new ones are shown only now. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200New codes.403Wrong current password409Two-step sign-in is off.429Too many wrong passwords.
/api/mail/two-factor/setup
Make a new authenticator secret
Makes a new secret and returns it as text, an otpauth URI and a QR code. Signing in does not change until enable confirms a code from it. Calling it again replaces the secret that is waiting. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The new secret. Shown only now.403Wrong current password409Two-step sign-in is already on.429Too many wrong passwords.503The server has no key to seal secrets with.
/api/mail/unsubscribe
Unsubscribe from a mailing list message
For one of the user's messages with a List-Unsubscribe header (Email/get's listUnsubscribe extension says what it offers). When it offers RFC 8058 one-click, the server POSTs List-Unsubscribe=One-Click to the https link itself, through the same public-address-only dialer as remote images, and answers done. Otherwise it returns the mailto address (and subject) or the web page for the user to use. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Done, or what to do instead.{ "done": false, "mailto": "leave@list.example", "subject": "unsubscribe", "url": "https://list.example/u/123" }404No such message
/api/mail/usage
Storage and sending used by the signed-in user
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Use and limits.{ "quotaBytes": 32212254720, "sendPerDay": 1000, "sentLastDay": 3, "storageBytes": 16179 }
/api/sites/here
The team site this host name is
A team's forums (kind forum) or wiki (kind wiki) shown at the root of its own host name, or null.
200The site.{ "site": { "kind": "forum", "team": "acme", "tenantId": "3c1d9a7e-55b2-4b8e-9e1f-0a2b3c4d5e6f" } }
/api/wiki/mine
The signed-in member's team and whether it has a wiki
200The team.{ "enabled": true, "signedIn": true, "team": "acme", "tenantId": "3c1d9a7e-55b2-4b8e-9e1f-0a2b3c4d5e6f" }
/api/wiki/teams/{tenantId}
A team's wiki and its pages
For the team's members (with the cookie and X-CSRF-Token), or anyone when the wiki is public; only when the team's plan has the wiki feature.
| Parameter | In | Type | |
|---|---|---|---|
tenantId * | path | string |
200The settings, the pages (without text) and what the reader may do.{ "admin": false, "canEdit": true, "pages": [ { "revision": 3, "slug": "home", "title": "Home", "updatedAt": "2026-10-09T12:00:00Z", "updatedBy": "Pat" } ], "signedIn": true, "wiki": { "editors": "members", "enabled": true, "public": false, "team": "acme" } }404No such wiki
/api/wiki/teams/{tenantId}/pages/{slug}
A page, with its text rendered (or an old revision with ?revision=N)
[text](other-page) links to another page of the wiki; other links go to web and mail addresses only.
| Parameter | In | Type | |
|---|---|---|---|
tenantId * | path | string | |
slug * | path | string | |
revision | query | integer |
200The page.{ "admin": false, "canEdit": true, "page": { "body": "Welcome. See [onboarding](onboarding).", "html": "\u003cp\u003eWelcome. See \u003ca href=\"#/p/onboarding\"\u003eonboarding\u003c/a\u003e.\u003c/p\u003e", "revision": 3, "slug": "home", "title": "Home", "updatedBy": "Pat" } }404No such page or revision.
/api/wiki/teams/{tenantId}/pages/{slug}
Create a page (base 0) or save a new revision
base is the revision the edit started from; if someone saved since, the save is refused (409) so nothing is overwritten.
| Parameter | In | Type | |
|---|---|---|---|
tenantId * | path | string | |
slug * | path | string | |
X-CSRF-Token * | header | string |
{
"base": 3,
"body": "Welcome!",
"note": "Typo",
"title": "Home"
}
200The page as saved.403The reader cannot edit this wiki.404No such page (a base other than 0 for a new one).409Someone saved the page since base.422A bad name
/api/wiki/teams/{tenantId}/pages/{slug}
Delete a page and its history (owners and admins)
| Parameter | In | Type | |
|---|---|---|---|
tenantId * | path | string | |
slug * | path | string | |
X-CSRF-Token * | header | string |
204Deleted.403Not an owner or admin.404No such page.
/api/wiki/teams/{tenantId}/pages/{slug}/history
A page's revisions, newest first
| Parameter | In | Type | |
|---|---|---|---|
tenantId * | path | string | |
slug * | path | string |
200The revisions (without text).{ "revisions": [ { "author": "Pat", "createdAt": "2026-10-09T12:00:00Z", "note": "Typo", "revision": 3, "title": "Home" } ] }404No such page.
/t/{file}
The read-tracking image
Public: loaded by the recipient's mail app. file is {token}.gif. Counts an open when the token exists and always answers with the same transparent 1x1 GIF, uncached. Only the count and times are kept, never the reader's address or browser.
| Parameter | In | Type | |
|---|---|---|---|
file * | path | string |
200A 1x1 transparent GIF.