Mailocity webmail API

version 2.0.0 OpenAPI spec (YAML)

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

GET /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.

Responses
  • 200 The 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
    }
GET /api/forum/forums/{forumId}

A forum and its topics (pinned first, then the latest)

ParameterInType
forumId *pathstring
Responses
  • 200 The 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"
        }
      ]
    }
  • 404 No such forum
POST /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.

ParameterInType
forumId *pathstring
X-CSRF-Token *headerstring
Request
{
  "body": "The office is closed this **Friday**.",
  "title": "Office closed Friday"
}
Responses
  • 201 The topic.
  • 401 Not signed in.
  • 403 Not allowed to start topics here (not a member
  • 404 No such forum.
  • 422 A title (up to 200 characters) and a first post (up to 20000) are needed.
  • 429 Too many posts in the last hour.
PATCH /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.

ParameterInType
postId *pathinteger
X-CSRF-Token *headerstring
Responses
  • 204 Done.
  • 403 Not the author or a moderator.
  • 404 No such post.
GET /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.

ParameterInType
tenantId *pathstring
Responses
  • 200 The forums.
    {
      "forums": [
        {
          "id": "6f1c2a9e-1b0e-4f3a-9a52-2f4d1e0c7b11",
          "name": "Announcements",
          "topics": 2,
          "visibility": "public"
        }
      ],
      "member": false,
      "signedIn": false
    }
GET /api/forum/topics/{topicId}

A topic and its posts

Posts are Markdown. A hidden post's text shows only to moderators.

ParameterInType
topicId *pathinteger
Responses
  • 200 The 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"
      }
    }
  • 404 No such topic
PATCH /api/forum/topics/{topicId}

Pin, lock or hide a topic (the team's owners and admins)

ParameterInType
topicId *pathinteger
X-CSRF-Token *headerstring
Request
{
  "locked": false,
  "pinned": true
}
Responses
  • 204 Done.
  • 403 Not a moderator.
  • 404 No such topic.
POST /api/forum/topics/{topicId}/posts

Reply in a topic

ParameterInType
topicId *pathinteger
X-CSRF-Token *headerstring
Request
{
  "body": "Thanks!"
}
Responses
  • 201 The post.
  • 403 Not allowed to reply here
  • 404 No such topic.
  • 422 A reply is 1 to 20000 characters.
  • 429 Too many posts in the last hour.
GET /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The deletion state.
    {
      "confirmWith": "password",
      "deleteAfter": null,
      "graceDays": 14,
      "requestedBy": ""
    }
  • 401 Not signed in.
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 Scheduled.
    {
      "deleteAfter": "2026-10-23T12:00:00Z",
      "requestedBy": "pat@mailo.city (themselves)"
    }
  • 204 Deleted at once.
  • 401 Not signed in.
  • 403 Wrong password or address
  • 409 Under a legal hold
  • 429 Too many wrong passwords.
DELETE /api/mail/account/delete

Keep my mailbox

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 204 No longer to be deleted.
  • 401 Not signed in.
GET /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The aliases.
    {
      "aliases": [
        "sales@acme.example"
      ],
      "canAdd": true,
      "domains": [
        "acme.example",
        "mailo.city"
      ],
      "max": 10,
      "maxShared": 3,
      "usedShared": 0
    }
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 201 Added.
  • 403 Not part of the plan.
  • 409 Taken
  • 422 Not a valid name
DELETE /api/mail/aliases/{address}

Remove one of the signed-in user's aliases

ParameterInType
address *pathstring
X-CSRF-Token *headerstring
Responses
  • 204 Removed.
  • 403 Not part of the plan.
  • 404 The user has no such alias.
POST /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).

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "currentPassword": "correct horse battery",
  "name": "Phone mail app"
}
Responses
  • 201 Made.
  • 403 Wrong current password; no recent Zappocity confirmation (confirmUrl says where); or missing CSRF token.
  • 409 The account already has 25.
  • 422 Missing or too long name.
  • 429 Too many wrong passwords.
DELETE /api/mail/app-passwords/{id}

Remove an app password

Mail apps signed in with it are signed out within a minute.

ParameterInType
X-CSRF-Token *headerstring
id *pathstring
Responses
  • 204 Removed.
  • 404 No such app password on this account.
GET /api/mail/calendar-shares

Who one of the user's own calendars is shared with

ParameterInType
calendarId *querystringThe calendar's JMAP id.
Responses
  • 200 The shares.
    {
      "items": [
        {
          "access": "read",
          "address": "carol@other.example",
          "granteeId": "4f1c...",
          "name": "Carol"
        }
      ]
    }
  • 404 Not one of the user's own calendars.
PUT /api/mail/calendar-shares

Share one of the user's own calendars, by address

With anyone who has a mailbox on this server, on the user's team or another; only an exact address is looked up. access is freebusy, read or write, or empty to stop sharing. Team calendars are shared by team admins.

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "access": "read",
  "address": "carol@other.example",
  "calendarId": "K12"
}
Responses
  • 204 Done.
  • 404 Not one of the user's own calendars.
  • 422 No mailbox with that address
GET /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 Mail 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"
    }
GET /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.

ParameterInType
email *pathstring
Responses
  • 200 The image.
  • 404 No contact with this address has a photo.
GET /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).

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The addresses, lower case.
    {
      "emails": [
        "amy@example.com"
      ]
    }
GET /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.

Responses
  • 200 The 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"
        }
      ]
    }
POST /api/mail/domain-blocks

Block a domain for the signed-in user

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "action": "junk",
  "kind": "mx",
  "value": "bulkhost.example"
}
Responses
  • 201 The block.
  • 400 Not the JSON above.
  • 409 That is blocked already.
  • 422 Not a domain
DELETE /api/mail/domain-blocks/{id}

Remove one of the user's own blocks

ParameterInType
id *pathinteger
X-CSRF-Token *headerstring
Responses
  • 204 Removed.
  • 404 Not one of the user's own blocks.
GET /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.

ParameterInType
emailId *pathstring
X-CSRF-Token *headerstring
Responses
  • 200 The 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"
      }
    }
  • 401 Not signed in.
  • 404 No such message in this mailbox.
GET /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.

ParameterInType
u *querystringThe image URL.
a *querystringThe account the link was made for.
e *queryintegerExpiry
s *querystringSignature.
Responses
  • 200 The image.
  • 403 The link is altered or expired
  • 502 The image could not be fetched or is not an allowed image.
POST /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).

Responses
  • 201 The session.
  • 400 The link was used or is more than five minutes old.
  • 403 The account cannot sign in.
GET /api/mail/imports

The user's imports from other mail servers

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 Newest 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"
        }
      ]
    }
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "host": "imap.gmail.com",
  "password": "app password",
  "skipFolders": [
    "[Gmail]/Spam"
  ],
  "username": "amy@gmail.com"
}
Responses
  • 201 Queued.
  • 409 An import is already queued or running.
  • 422 A value is not valid.
DELETE /api/mail/imports/{id}

Cancel a queued or running import

ParameterInType
id *pathstring
X-CSRF-Token *headerstring
Responses
  • 204 Cancelled; what was copied stays.
  • 404 No such import.
  • 409 It has already ended.
GET /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The 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
        }
      ]
    }
POST /api/mail/invites

Make one invitation link

Uses up one of the user's invitations. Needs the cookie and X-CSRF-Token.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 201 The new invitation
  • 403 No invitations left
DELETE /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.

ParameterInType
code *pathstring
X-CSRF-Token *headerstring
Responses
  • 204 Cancelled.
  • 404 No unused invitation of the user's has that code.
GET /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".

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 Snoozes and mutes.
    {
      "muted": [
        "T17"
      ],
      "snoozed": {
        "E42": "2026-10-09T08:00:00Z"
      }
    }
GET /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The 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"
        }
      ]
    }
  • 401 Not signed in.
PUT /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.

ParameterInType
id *pathstring
X-CSRF-Token *headerstring
Responses
  • 204 Joined.
  • 404 No such list
  • 409 The list is full (the plan's maxListMembers).
DELETE /api/mail/lists/{id}/membership

Leave a list

ParameterInType
id *pathstring
X-CSRF-Token *headerstring
Responses
  • 204 Left.
  • 404 The user is not on the list.
GET /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).

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The 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": ""
        }
      ]
    }
POST /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.

ParameterInType
X-CSRF-Token *headerstring
emailId *pathstring
accountquerystring
Request
{
  "emoji": "👍"
}
Responses
  • 200 The message's reactions now.
    {
      "reactions": [
        {
          "address": "amy@acme.com",
          "emoji": [
            "👍"
          ],
          "mine": true,
          "name": "Amy"
        }
      ]
    }
  • 400 Not the JSON above.
  • 404 No such message (or it has no Message-ID), or no such account the user may send as.
  • 422 Not one emoji
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "muted": true,
  "threadId": "T17"
}
Responses
  • 204 Changed.
  • 400 Not the JSON above.
  • 404 No such conversation in the user's account.
GET /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.

ParameterInType
X-CSRF-Token *headerstring
qquerystring
Responses
  • 200 The 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
        }
      ]
    }
POST /api/mail/notes

Write a note

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "body": "Network: **Acme-Guest**",
  "team": true,
  "title": "Office wifi"
}
Responses
  • 201 The note
  • 409 You keep as many notes as you can (5000).
  • 422 A value is not valid.
GET /api/mail/notes/{id}

A note

With its Markdown text rendered (html, escaped; links only to http(s) and mail).

ParameterInType
id *pathstring
X-CSRF-Token *headerstring
Responses
  • 200 The note.
  • 404 No such note
PUT /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.

ParameterInType
id *pathstring
X-CSRF-Token *headerstring
Request
{
  "body": "Network: **Acme-Guest**, password on the fridge",
  "team": true,
  "title": "Office wifi",
  "version": 3
}
Responses
  • 200 The note.
  • 404 No such note.
  • 409 Changed meanwhile; current is the note as it is now.
  • 422 A value is not valid.
DELETE /api/mail/notes/{id}

Delete a note

Your own, or (team admins) any team note.

ParameterInType
id *pathstring
X-CSRF-Token *headerstring
Responses
  • 204 Deleted.
  • 404 No such note
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 Changed. Sets a new session cookie.
  • 401 Not signed in.
  • 403 Wrong current password
  • 422 The new password is too short or too long.
  • 429 Too many wrong passwords.
POST /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.

Responses
  • 202 A link was sent if the account has a recovery address.
  • 415 Not JSON.
  • 429 Too many requests.
POST /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.

Responses
  • 200 The password is set.
    {
      "address": "alice@example.com"
    }
  • 400 The link expired or was already used.
  • 422 The new password is too short or too long; the link still works.
GET /api/mail/pgp-key

The user's published OpenPGP public key, if any

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The 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"
      }
    }
PUT /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The key.
  • 403 A session opened by staff as the user.
  • 404 No key to publish or unpublish.
  • 422 Not a public key
DELETE /api/mail/pgp-key

Remove the key

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 204 Removed.
PUT /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.

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "mode": "refuse"
}
Responses
  • 204 Saved.
  • 400 Not the JSON above.
  • 403 `folders` without the plus_folders entitlement.
  • 422 Not one of the modes.
GET /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.

ParameterInType
ids *querystringEmail ids, comma-separated, up to 200.
accountquerystring
Responses
  • 200 The reactions, and the choices webmail offers.
    {
      "choices": [
        "👍",
        "❤️",
        "😂",
        "😮",
        "😢",
        "🎉"
      ],
      "items": {
        "E17": [
          {
            "address": "bob@acme.com",
            "emoji": [
              "👍"
            ],
            "mine": false,
            "name": "Bob"
          }
        ]
      }
    }
  • 404 No such account (or not shared with the user).
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "currentPassword": "correct horse battery",
  "email": "alice.personal@example.net"
}
Responses
  • 200 Removed.
  • 202 Confirmation link sent.
    {
      "pending": "alice.personal@example.net",
      "recoveryEmail": null
    }
  • 403 Wrong current password
  • 422 Not one plain address
  • 429 Too many wrong passwords
  • 502 The confirmation could not be sent.
POST /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.

Responses
  • 200 Confirmed.
    {
      "address": "alice@example.com",
      "recoveryEmail": "alice.personal@example.net"
    }
  • 400 The link expired or was already used.
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "emailIds": [
    "E42"
  ],
  "kind": "phishing"
}
Responses
  • 200 How many were reported.
    {
      "junkId": "M4",
      "reported": 1
    }
  • 400 Not the JSON above.
  • 429 Too many reports today.
GET /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.

ParameterInType
X-CSRF-Token *headerstring
fromquerystring
toquerystring
Responses
  • 200 The 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"
    }
  • 400 from or to are not valid.
GET /api/mail/security

Two-step sign-in state and app passwords

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The 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
    }
  • 401 Not signed in.
  • 403 Missing or wrong CSRF token.
GET /api/mail/security-alerts

Which security events the user is emailed about

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The choices.
    {
      "failed": true,
      "newSignin": true
    }
PUT /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 204 Saved.
POST /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.

Request
{
  "fromName": "Zappocity",
  "subject": "Confirm your email address",
  "text": "Open this link...",
  "to": [
    "amy@example.com"
  ]
}
Responses
  • 202 Sent (or queued for other domains).
    {
      "sent": 1
    }
  • 400 Not JSON with the documented fields.
  • 401 No mailbox API token with the send scope
  • 422 Too many recipients, a header on two lines, or the mailbox may not send this (its limits, for example).
GET /api/mail/session

The signed-in account and its CSRF token

Needs the cookie only.

Responses
  • 200 Signed in.
  • 401 No session
POST /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.

Request
{
  "address": "alice@example.com",
  "password": "correct horse battery"
}
Responses
  • 201 Signed in. Sets the session cookie.
  • 401 Wrong 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"
    }
  • 403 The password was right but the plan does not include webmail.
  • 415 Not JSON.
  • 429 Too many failed sign-ins.
DELETE /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 204 Signed out.
  • 401 No session.
  • 403 Missing or wrong CSRF token.
GET /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The 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 ..."
        }
      ]
    }
DELETE /api/mail/sessions/{id}

Sign out one session, or every other one

The id others signs out every session but the one asking.

ParameterInType
id *pathstring
X-CSRF-Token *headerstring
Responses
  • 204 Signed out.
  • 404 No such session of the user.
GET /api/mail/shares

Who the user shares their mailbox with, whose is shared with them, and their teammates

A mailbox shared with the user shows in the JMAP session as another account (accountId here), read only (read), to change (write) or also to send from (send). Shares are only between teammates.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 Shares and teammates.
    {
      "sharedByMe": [
        {
          "access": "send",
          "address": "assistant@acme.test",
          "name": "Sam",
          "since": "2026-10-08T15:00:00Z"
        }
      ],
      "sharedWithMe": [
        {
          "access": "read",
          "accountId": "2b7f1d6e-3c4a-4e8b-9f10-5a6b7c8d9e0f",
          "address": "sales@acme.test",
          "name": "Sales",
          "since": "2026-10-01T09:00:00Z"
        }
      ],
      "teammates": [
        {
          "address": "assistant@acme.test",
          "name": "Sam"
        },
        {
          "address": "sales@acme.test",
          "name": "Sales"
        }
      ]
    }
PUT /api/mail/shares/{address}

Share the mailbox with a teammate, or change how

Refused (403) in a mailbox opened by staff or a team admin.

ParameterInType
address *pathstringThe teammate's address.
X-CSRF-Token *headerstring
Request
{
  "access": "write"
}
Responses
  • 200 The share.
  • 403 Opened by someone else.
  • 404 Nobody on the team has that address.
  • 422 access is not read
DELETE /api/mail/shares/{address}

Stop sharing the mailbox with a teammate

ParameterInType
address *pathstringThe teammate's address.
X-CSRF-Token *headerstring
Responses
  • 204 No longer shared.
  • 404 Not shared with that teammate.
GET /api/mail/smime

The user's S/MIME certificate, if any

keysAccepted says the server can keep a private key (to sign).

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The 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
    }
PUT /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The certificate.
  • 403 A session opened by staff as the user.
  • 422 Not a certificate for this address
DELETE /api/mail/smime

Remove the certificate and key

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 204 Removed.
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "emailIds": [
    "E42"
  ],
  "mailboxId": "M1",
  "until": "2026-10-09T08:00:00Z"
}
Responses
  • 200 Snoozed.
    {
      "mailboxId": "M7",
      "snoozedUntil": "2026-10-09T08:00:00Z"
    }
  • 400 Not the JSON above.
  • 422 `until` is not in the next year, or `mailboxId` is not one of the user's folders.
DELETE /api/mail/snooze/{emailId}

Bring a snoozed message back now

ParameterInType
emailId *pathstring
X-CSRF-Token *headerstring
Responses
  • 204 Back in its folder
  • 404 No such message
GET /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The settings.
    {
      "allow": [
        "news.example"
      ],
      "block": [],
      "effectiveJunkScore": 6,
      "enabled": true,
      "junkScore": null,
      "rejectScore": 15
    }
PUT /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.

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "allow": [
    "news.example"
  ],
  "block": [
    "spammy.example"
  ],
  "junkScore": 5
}
Responses
  • 204 Saved.
  • 400 Not the JSON above.
  • 422 A list entry is not an address or domain
GET /api/mail/sso

Which ways of signing in to webmail are open

Responses
  • 200 Whether Sign in with Zappocity is offered, and whether the password sign-in still works.
    {
      "passwordSignin": true,
      "sso": true
    }
GET /api/mail/sso/callback

Where Zappocity sends the browser back

ParameterInType
codequerystring
statequerystring
Responses
  • 302 Signed in, to next; or to /mail#sso-error=... (expired, unverified, no-mailbox, suspended, not-in-plan).
GET /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.

ParameterInType
nextquerystring
confirmquerystring
Responses
  • 302 To Zappocity
GET /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).

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The templates.
    {
      "canManage": false,
      "items": [
        {
          "id": "0b8e5d2c-1a3f-4e6d-9c7b-2f1e0d9c8b7a",
          "name": "Quote follow-up",
          "subject": "Your quote",
          "uses": 12
        }
      ]
    }
POST /api/mail/team-templates

Share a template with the team

Team admins and members with the templates permission only. The name is unique in the team.

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "html": "\u003cp\u003eHi,\u003c/p\u003e\u003cp\u003eFollowing up on the quote we sent...\u003c/p\u003e",
  "name": "Quote follow-up",
  "subject": "Your quote"
}
Responses
  • 201 Shared.
  • 403 The user may not manage team templates.
  • 409 The team has a template with that name.
  • 422 A value is not valid.
GET /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.

ParameterInType
id *pathstring
X-CSRF-Token *headerstring
Responses
  • 200 The 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
    }
  • 404 No such template in the user's team.
DELETE /api/mail/team-templates/{id}

Delete a team template

ParameterInType
id *pathstring
X-CSRF-Token *headerstring
Responses
  • 204 Deleted.
  • 403 The user may not manage team templates.
  • 404 No such template in the user's team.
GET /api/mail/tickets

The user's tickets

teamAdmins says the team has other owners or admins to ask.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 Their tickets, as Ticket in api/openapi.yaml.
    {
      "items": [
        {
          "id": 12,
          "priority": "normal",
          "queue": "team",
          "status": "pending",
          "subject": "Phone will not sync"
        }
      ],
      "teamAdmins": true
    }
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 201 The team ticket, or for Zappocity support its number and link.
    {
      "url": "https://zappo.city/account#ticket=41",
      "zappocityRef": "ZC-41"
    }
  • 422 A value is missing.
  • 429 Too many open tickets.
GET /api/mail/tickets/{id}

One of the user's tickets with its replies (never internal notes)

ParameterInType
id *pathinteger
X-CSRF-Token *headerstring
Responses
  • 200 The ticket and its messages.
  • 404 Not one of the user's tickets.
POST /api/mail/tickets/{id}/close

Close one of the user's tickets

Abuse tickets are closed by the platform's staff only (403).

ParameterInType
id *pathinteger
X-CSRF-Token *headerstring
Responses
  • 204 Closed.
  • 403 An abuse ticket.
POST /api/mail/tickets/{id}/messages

Reply on a ticket; it opens again

ParameterInType
id *pathinteger
X-CSRF-Token *headerstring
Responses
  • 204 Sent.
  • 409 The ticket is closed.
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The new tracker.
    {
      "token": "qOgc5Ee4_GC8gG98OT-Sew",
      "url": "https://mail.example.com/t/qOgc5Ee4_GC8gG98OT-Sew.gif"
    }
  • 403 The plan does not include read tracking.
  • 429 Too many trackers made today.
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 Counts 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"
        }
      }
    }
  • 400 Not the expected JSON
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 204 Off.
  • 403 Wrong current password
  • 429 Too many wrong passwords.
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Request
{
  "code": "492039"
}
Responses
  • 200 On.
  • 409 No setup is waiting
  • 422 The code is wrong.
  • 429 Too many wrong codes.
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 New codes.
  • 403 Wrong current password
  • 409 Two-step sign-in is off.
  • 429 Too many wrong passwords.
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 The new secret. Shown only now.
  • 403 Wrong current password
  • 409 Two-step sign-in is already on.
  • 429 Too many wrong passwords.
  • 503 The server has no key to seal secrets with.
POST /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.

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 Done, or what to do instead.
    {
      "done": false,
      "mailto": "leave@list.example",
      "subject": "unsubscribe",
      "url": "https://list.example/u/123"
    }
  • 404 No such message
GET /api/mail/usage

Storage and sending used by the signed-in user

ParameterInType
X-CSRF-Token *headerstring
Responses
  • 200 Use and limits.
    {
      "quotaBytes": 32212254720,
      "sendPerDay": 1000,
      "sentLastDay": 3,
      "storageBytes": 16179
    }
GET /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.

Responses
  • 200 The site.
    {
      "site": {
        "kind": "forum",
        "team": "acme",
        "tenantId": "3c1d9a7e-55b2-4b8e-9e1f-0a2b3c4d5e6f"
      }
    }
GET /api/wiki/mine

The signed-in member's team and whether it has a wiki

Responses
  • 200 The team.
    {
      "enabled": true,
      "signedIn": true,
      "team": "acme",
      "tenantId": "3c1d9a7e-55b2-4b8e-9e1f-0a2b3c4d5e6f"
    }
GET /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.

ParameterInType
tenantId *pathstring
Responses
  • 200 The 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"
      }
    }
  • 404 No such wiki
GET /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.

ParameterInType
tenantId *pathstring
slug *pathstring
revisionqueryinteger
Responses
  • 200 The 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"
      }
    }
  • 404 No such page or revision.
PUT /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.

ParameterInType
tenantId *pathstring
slug *pathstring
X-CSRF-Token *headerstring
Request
{
  "base": 3,
  "body": "Welcome!",
  "note": "Typo",
  "title": "Home"
}
Responses
  • 200 The page as saved.
  • 403 The reader cannot edit this wiki.
  • 404 No such page (a base other than 0 for a new one).
  • 409 Someone saved the page since base.
  • 422 A bad name
DELETE /api/wiki/teams/{tenantId}/pages/{slug}

Delete a page and its history (owners and admins)

ParameterInType
tenantId *pathstring
slug *pathstring
X-CSRF-Token *headerstring
Responses
  • 204 Deleted.
  • 403 Not an owner or admin.
  • 404 No such page.
GET /api/wiki/teams/{tenantId}/pages/{slug}/history

A page's revisions, newest first

ParameterInType
tenantId *pathstring
slug *pathstring
Responses
  • 200 The revisions (without text).
    {
      "revisions": [
        {
          "author": "Pat",
          "createdAt": "2026-10-09T12:00:00Z",
          "note": "Typo",
          "revision": 3,
          "title": "Home"
        }
      ]
    }
  • 404 No such page.
GET /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.

ParameterInType
file *pathstring
Responses
  • 200 A 1x1 transparent GIF.