The account page at /account (on the Zappocity host, such as zappo.city) calls these. A Zappocity account is one sign-in for every Zappocity product.
Sessions. POST /api/account/session (or a verification link) sets an HttpOnly, Secure, SameSite=Lax cookie (__Host-zappocity_account) and returns a CSRF token, which every request other than GET must send in X-CSRF-Token. Sessions last 30 days and end after 7 days unused. Bodies must be application/json.
Protection. Ten wrong passwords in a row lock an account for 15 minutes, and sign-in, sign-up and reset requests are throttled by address. The captcha (when the operator turns it on) is answered in captcha. Answers never say whether an email has an account: sign-up and reset answer the same either way, and a locked account answers like a throttled one.
See docs/kb/zappocity-accounts.md.
Account
/api/account/invites
The person's invitation link and the invitations they sent
link is the person's own sign-up link (#signup&invite=CODE); people who sign up with it are recorded as invited by them (for referral rewards). joined counts them.
200The link and invitations.{ "items": [ { "email": "bob@example.com", "joined": true, "sentAt": "2026-10-08T12:00:00Z" } ], "joined": 1, "link": "https://zappo.city/account#signup\u0026invite=k3j9m2xp4q" }401Not signed in, or the CSRF token is missing or wrong.
/api/account/invites
Email an invitation
Needs a confirmed address; at most 20 a day; not to someone who already has an account.
{
"email": "bob@example.com"
}
204Sent.401Not signed in, or the CSRF token is missing or wrong.403The person's own address is not confirmed.409That address already has an account.422Not an email address429Enough invitations for today.
/api/account/me
The account and its organizations
Owners and billing members also see each organization's subscriptions.
200The account.{ "orgs": [ { "orgId": "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f", "orgName": "Acme", "role": "owner", "subscriptions": [ { "period": "year", "plan": "team", "product": "mailocity", "status": "active" } ] } ], "person": { "email": "amy@example.com", "emailVerifiedAt": "2026-10-08T12:00:00Z", "id": "6c1f0b7e-2d4a-4b8e-9f3c-1a2b3c4d5e6f", "name": "Amy" } }401Not signed in, or the CSRF token is missing or wrong.
/api/account/me
Change your name
200The account.401Not signed in, or the CSRF token is missing or wrong.422A value is not acceptable, such as a password under 12 characters.
/api/account/orgs/{orgId}/billing
An organization's balance, history and invoices
For its owners and billing members.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
200The billing.{ "billing": { "autoPay": true, "balanceCents": 2500, "free": false }, "invoices": [], "ledger": [], "org": { "name": "Acme" } }401Not signed in, or the CSRF token is missing or wrong.404Not an organization whose billing you handle.
/api/account/orgs/{orgId}/billing
Pay invoices automatically, or not
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
200The billing profile.401Not signed in, or the CSRF token is missing or wrong.404Not an organization whose billing you handle.
/api/account/orgs/{orgId}/invoices/{invoiceId}
One invoice with its lines
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
invoiceId * | path | string |
200The invoice.401Not signed in, or the CSRF token is missing or wrong.404No such invoice of this organization.
/api/account/orgs/{orgId}/invoices/{invoiceId}/pay
Pay an open invoice now
Answers the provider's page to pay it (any enabled method); the method used is saved for next time.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
invoiceId * | path | string |
200Where to go.401Not signed in, or the CSRF token is missing or wrong.404No such invoice of this organization.409The invoice has nothing to pay.503Card payments are not set up.
/api/account/orgs/{orgId}/payment-method
Save a payment method
Answers the payment provider's page (url); the browser comes back to /account#billing. The method then pays renewals automatically.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
200Where to go.{ "url": "https://checkout.stripe.com/c/pay/cs_..." }401Not signed in, or the CSRF token is missing or wrong.404Not an organization whose billing you handle.503Card payments are not set up.
/api/account/orgs/{orgId}/payment-method
Remove the saved payment method
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
204Removed.401Not signed in, or the CSRF token is missing or wrong.404Not an organization whose billing you handle.503Card payments are not set up.
/api/account/password
Change your password
Needs the current one. Every other session ends, and the address is told by email.
204Changed.401Not signed in, or the CSRF token is missing or wrong.403The current password is not right (it counts as a failed sign-in).422A value is not acceptable, such as a password under 12 characters.
/api/account/security-alerts
Whether sign-ins from new devices are emailed
A sign-in from an address and browser not seen in the last 90 days is emailed when newSignIn is on (the default). Changes to the password, two-step sign-in and security keys are always emailed.
200The setting.{ "newSignIn": true }401Not signed in, or the CSRF token is missing or wrong.
/api/account/security-alerts
Turn sign-in alerts on or off
{
"newSignIn": false
}
200The setting.401Not signed in, or the CSRF token is missing or wrong.
/api/account/security-keys
The account's security keys and passkeys
Hardware keys (YubiKey and others) and passkeys (1Password, iCloud Keychain, Google Password Manager, Windows Hello) registered with WebAuthn. Any of them makes signing in take a second step; a passkey (passkey: true) can also sign in on its own.
200The keys.{ "items": [ { "createdAt": "2026-10-08T12:00:00Z", "id": "q8V2...", "lastUsed": "2026-10-09T08:30:00Z", "name": "YubiKey 5C", "passkey": false } ] }401Not signed in, or the CSRF token is missing or wrong.
/api/account/security-keys
Finish adding a key or passkey
Send the challengeId from POST /api/account/security-keys/register and the browser's navigator.credentials.create() result (as JSON). The first key turns two-step sign-in on: every other device is signed out and recovery codes are returned once. The address is told by email.
{
"challengeId": "5b0b4c1e-7d1a-4f5e-9a3b-1c2d3e4f5a6b",
"credential": {
"id": "q8V2...",
"rawId": "q8V2...",
"response": {
"attestationObject": "o2Nm...",
"clientDataJSON": "eyJ0..."
},
"type": "public-key"
},
"name": "YubiKey 5C"
}
201Added.{ "id": "q8V2...", "name": "YubiKey 5C", "passkey": false, "recoveryCodes": [ "k3j9-2mxp", "..." ] }400The challenge expired or was used.401Not signed in, or the CSRF token is missing or wrong.409The key is already registered422The key's answer did not check out.
/api/account/security-keys/register
Start adding a key or passkey
Needs the current password. Returns options for navigator.credentials.create() (publicKey) and a challengeId, good for ten minutes and once. With passkey: true the authenticator must keep a discoverable credential and verify the user (PIN or biometrics), so it can sign in without a password.
{
"currentPassword": "correct horse battery",
"passkey": true
}
200The options.{ "challengeId": "5b0b4c1e-7d1a-4f5e-9a3b-1c2d3e4f5a6b", "options": { "publicKey": { "challenge": "Wm9...", "pubKeyCredParams": [ { "alg": -7, "type": "public-key" } ], "rp": { "id": "zappo.city", "name": "Zappocity" } } } }401Not signed in, or the CSRF token is missing or wrong.403Wrong password.
/api/account/security-keys/{keyId}
Rename a key
| Parameter | In | Type | |
|---|---|---|---|
keyId * | path | string | The key's id (base64url). |
{
"name": "Backup YubiKey"
}
204Renamed.401Not signed in, or the CSRF token is missing or wrong.404No such key.
/api/account/security-keys/{keyId}
Remove a key or passkey
Needs the current password. With no key and no authenticator app left, two-step sign-in is off and the recovery codes go. The address is told by email.
| Parameter | In | Type | |
|---|---|---|---|
keyId * | path | string | The key's id (base64url). |
{
"currentPassword": "correct horse battery"
}
204Removed.401Not signed in, or the CSRF token is missing or wrong.403Wrong password.404No such key.
/api/account/sessions
Where you are signed in
200Most recently used first.{ "items": [ { "createdAt": "2026-10-08T12:00:00Z", "current": true, "expiresAt": "2026-11-07T12:00:00Z", "id": 4, "ip": "203.0.113.9", "lastSeen": "2026-10-08T12:30:00Z", "userAgent": "Firefox" } ] }401Not signed in, or the CSRF token is missing or wrong.
/api/account/sessions
Sign out everywhere else
204Done.401Not signed in, or the CSRF token is missing or wrong.
/api/account/signins
Your recent sign-in attempts
200Newest first, at most 100.{ "items": [ { "at": "2026-10-08T12:00:00Z", "email": "amy@example.com", "ip": "203.0.113.9", "reason": "password", "success": false, "userAgent": "Firefox" } ] }401Not signed in, or the CSRF token is missing or wrong.
/api/account/two-factor
Whether two-step sign-in is on
200The state.{ "enabled": true, "recoveryCodesLeft": 9 }401Not signed in, or the CSRF token is missing or wrong.
/api/account/two-factor
Turn two-step sign-in off
Needs the password and a current code (or a recovery code). The address is told by email.
204Off.401Not signed in, or the CSRF token is missing or wrong.403The password or code is not right (it counts as a failed sign-in).
/api/account/two-factor/enable
Turn it on with a first code
Returns ten recovery codes, shown once. Every other session ends and the address is told by email.
200The recovery codes.{ "recoveryCodes": [ "abcd-efgh-jkmn" ] }401Not signed in, or the CSRF token is missing or wrong.409Setup was not started.422The code is not right.
/api/account/two-factor/recovery-codes
Replace the recovery codes
200The new codes401Not signed in, or the CSRF token is missing or wrong.403The password is not right.409Two-step sign-in is off.
/api/account/two-factor/setup
Start setting up an authenticator app
Returns a new secret as text, an otpauth URI and a QR code. Nothing changes until it is enabled with a code.
200The secret.{ "qrCode": "data:image/png;base64,...", "secret": "JBSWY3DPEHPK3PXP", "uri": "otpauth://totp/Zappocity:amy@example.com?secret=..." }401Not signed in, or the CSRF token is missing or wrong.403The password is not right.409It is already on.
Sign-in
/api/account/session/passkey
Sign in with a passkey (start)
No email or password; the passkey names its account. Returns options for navigator.credentials.get() and a challengeId.
200The options and a challengeId.
/api/account/session/passkey/finish
Sign in with a passkey (finish)
The browser's navigator.credentials.get() result. The passkey must have verified the user (PIN or biometrics), so the session counts as two-step for every product signed in to through Zappocity.
{
"challengeId": "5b0b4c1e-7d1a-4f5e-9a3b-1c2d3e4f5a6b",
"credential": {
"id": "q8V2...",
"response": {
"authenticatorData": "SZYN...",
"clientDataJSON": "eyJ0...",
"signature": "MEUC...",
"userHandle": "dXNl..."
},
"type": "public-key"
}
}
201Signed in; as POST /api/account/session.400The challenge expired or was used.401The passkey did not check out.
/api/account/session/security-key
Sign in with a password and a security key (start)
When POST /api/account/session answers twoFactorRequired with securityKey: true, send the email and password here for options for navigator.credentials.get().
{
"email": "amy@example.com",
"password": "correct horse battery"
}
200The options and a challengeId.401Wrong email or password.409The account has no security key.429Too many failed sign-ins.
/api/account/session/security-key/finish
Sign in with a password and a security key (finish)
The browser's navigator.credentials.get() result. A key whose signature counter went backwards (a copy) is refused.
{
"challengeId": "5b0b4c1e-7d1a-4f5e-9a3b-1c2d3e4f5a6b",
"credential": {
"id": "q8V2...",
"response": {
"authenticatorData": "SZYN...",
"clientDataJSON": "eyJ0...",
"signature": "MEUC..."
},
"type": "public-key"
}
}
201Signed in; as POST /api/account/session.400The challenge expired or was used.401The key did not check out.
Signing in
/api/account/reset
Email a password reset link
The answer is the same whether or not the address has an account. Links work once, for an hour.
202If the address has an account403The captcha answer is missing or wrong (`captchaRequired` is true).429Too many attempts from this address, or too many recent links, or the account is locked.
/api/account/reset/confirm
Set a new password from a reset link
The link also confirms the address. Every session ends.
200The password is set.400The link has expired or was already used.422A value is not acceptable, such as a password under 12 characters.
/api/account/session
Who is signed in, and the CSRF token
200Signed in.{ "csrfToken": "Yk3...", "expiresAt": "2026-11-07T12:00:00Z", "person": { "email": "amy@example.com", "emailVerifiedAt": null, "id": "6c1f0b7e-2d4a-4b8e-9f3c-1a2b3c4d5e6f", "name": "Amy" } }401Not signed in, or the CSRF token is missing or wrong.
/api/account/session
Sign in
201Signed in.{ "csrfToken": "Yk3...", "expiresAt": "2026-11-07T12:00:00Z", "person": { "email": "amy@example.com", "emailVerifiedAt": null, "id": "6c1f0b7e-2d4a-4b8e-9f3c-1a2b3c4d5e6f", "name": "Amy" } }400Not JSON, or unknown fields.401Wrong email or password.403The account is suspended (only said after the right password)429Too many attempts from this address, or too many recent links, or the account is locked.
/api/account/session
Sign out
204Signed out.
Signing up
/api/account/signup
Create an account
Makes an unconfirmed account and emails a link to confirm the address (which also signs in). If the address already has an account, its owner is emailed instead; the answer is the same.
{
"email": "amy@example.com",
"name": "Amy",
"password": "correct horse battery"
}
202Check your email.400Not JSON, or unknown fields.403The captcha answer is missing or wrong (`captchaRequired` is true).422A value is not acceptable, such as a password under 12 characters.429Too many attempts from this address, or too many recent links, or the account is locked.
/api/account/verify
Confirm an email address from its link, and sign in
200Confirmed and signed in (as Session), or, for an account with two-step sign-in, confirmed with signIn true: sign in the usual way.400The link has expired or was already used.
/api/account/verify/resend
Send a new confirmation link
202Sent.401Not signed in, or the CSRF token is missing or wrong.409The address is already confirmed.429Too many attempts from this address, or too many recent links, or the account is locked.
Support
/api/account/support/queues
What a request can be about
The active, public queues (support, billing, abuse...).
200The queues.{ "items": [ { "allowContacts": true, "description": "Invoices, payments, plans and refunds.", "name": "Billing", "slug": "billing" } ] }401Not signed in, or the CSRF token is missing or wrong.
/api/account/tickets
Your support requests
Opened by you, or sent from or naming your confirmed address as a contact. Newest activity first.
200The tickets.{ "items": [ { "number": 41, "priority": "normal", "queue": "billing", "requesterEmail": "amy@example.com", "status": "pending", "subject": "Invoice question", "updatedAt": "2026-10-08T12:00:00Z" } ] }401Not signed in, or the CSRF token is missing or wrong.
/api/account/tickets
Open a support request
You get a confirmation by email; contacts (when the queue takes them) also get the replies.
{
"body": "Why was I charged twice?",
"queue": "billing",
"subject": "Invoice question"
}
201The ticket.401Not signed in, or the CSRF token is missing or wrong.422A value is not acceptable, such as a password under 12 characters.
/api/account/tickets/{number}
A request with its messages and contacts
Internal notes are never shown. queue says whether contacts can be added and whether you may close it.
| Parameter | In | Type | |
|---|---|---|---|
number * | path | string | The number, with or without ZC-. |
200OK.401Not signed in, or the CSRF token is missing or wrong.404No such ticket
/api/account/tickets/{number}/close
Close your request
Where the queue lets customers; abuse reports, for one, stay open until staff close them (403).
| Parameter | In | Type | |
|---|---|---|---|
number * | path | string |
200OK.401Not signed in, or the CSRF token is missing or wrong.403Only staff close requests in this queue.404No such ticket
/api/account/tickets/{number}/contacts
Add someone to the replies
Only where the queue allows contacts (409 otherwise); at most 20.
| Parameter | In | Type | |
|---|---|---|---|
number * | path | string |
200OK.401Not signed in, or the CSRF token is missing or wrong.404No such ticket409The queue does not take contacts
/api/account/tickets/{number}/contacts/{email}
Take someone off the replies
| Parameter | In | Type | |
|---|---|---|---|
number * | path | string | |
email * | path | string |
200OK.401Not signed in, or the CSRF token is missing or wrong.404No such ticket
/api/account/tickets/{number}/messages
Reply
Reopens a resolved request; a closed one takes no more (409).
| Parameter | In | Type | |
|---|---|---|---|
number * | path | string |
201The ticket with its messages.401Not signed in, or the CSRF token is missing or wrong.404No such ticket409The request is closed.
/api/support/links/{token}
Open a request from a notification link, without signing in
Each notification link is for one address and works for 30 days, where the queue allows replying on the web without signing in. Otherwise the answer is 403 with signIn true and the ticket's number.
| Parameter | In | Type | |
|---|---|---|---|
token * | path | string |
200The ticket with its messages.403The queue needs signing in.404The link has expired.
/api/support/links/{token}/messages
Reply from a notification link
| Parameter | In | Type | |
|---|---|---|---|
token * | path | string |
201The ticket with its messages.403The queue needs signing in.404The link has expired.409The request is closed.