Zappocity account API

version 1.6.0 OpenAPI spec (YAML)

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

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

Responses
  • 200 The 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"
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
POST /api/account/invites

Email an invitation

Needs a confirmed address; at most 20 a day; not to someone who already has an account.

Request
{
  "email": "bob@example.com"
}
Responses
  • 204 Sent.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 The person's own address is not confirmed.
  • 409 That address already has an account.
  • 422 Not an email address
  • 429 Enough invitations for today.
GET /api/account/me

The account and its organizations

Owners and billing members also see each organization's subscriptions.

Responses
  • 200 The 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"
      }
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
PATCH /api/account/me

Change your name

Responses
  • 200 The account.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 422 A value is not acceptable, such as a password under 12 characters.
GET /api/account/orgs/{orgId}/billing

An organization's balance, history and invoices

For its owners and billing members.

ParameterInType
orgId *pathstring
Responses
  • 200 The billing.
    {
      "billing": {
        "autoPay": true,
        "balanceCents": 2500,
        "free": false
      },
      "invoices": [],
      "ledger": [],
      "org": {
        "name": "Acme"
      }
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 Not an organization whose billing you handle.
PATCH /api/account/orgs/{orgId}/billing

Pay invoices automatically, or not

ParameterInType
orgId *pathstring
Responses
  • 200 The billing profile.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 Not an organization whose billing you handle.
GET /api/account/orgs/{orgId}/invoices/{invoiceId}

One invoice with its lines

ParameterInType
orgId *pathstring
invoiceId *pathstring
Responses
  • 200 The invoice.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 No such invoice of this organization.
POST /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.

ParameterInType
orgId *pathstring
invoiceId *pathstring
Responses
  • 200 Where to go.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 No such invoice of this organization.
  • 409 The invoice has nothing to pay.
  • 503 Card payments are not set up.
POST /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.

ParameterInType
orgId *pathstring
Responses
  • 200 Where to go.
    {
      "url": "https://checkout.stripe.com/c/pay/cs_..."
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 Not an organization whose billing you handle.
  • 503 Card payments are not set up.
DELETE /api/account/orgs/{orgId}/payment-method

Remove the saved payment method

ParameterInType
orgId *pathstring
Responses
  • 204 Removed.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 Not an organization whose billing you handle.
  • 503 Card payments are not set up.
POST /api/account/password

Change your password

Needs the current one. Every other session ends, and the address is told by email.

Responses
  • 204 Changed.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 The current password is not right (it counts as a failed sign-in).
  • 422 A value is not acceptable, such as a password under 12 characters.
GET /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.

Responses
  • 200 The setting.
    {
      "newSignIn": true
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
PUT /api/account/security-alerts

Turn sign-in alerts on or off

Request
{
  "newSignIn": false
}
Responses
  • 200 The setting.
  • 401 Not signed in, or the CSRF token is missing or wrong.
GET /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.

Responses
  • 200 The keys.
    {
      "items": [
        {
          "createdAt": "2026-10-08T12:00:00Z",
          "id": "q8V2...",
          "lastUsed": "2026-10-09T08:30:00Z",
          "name": "YubiKey 5C",
          "passkey": false
        }
      ]
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
POST /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.

Request
{
  "challengeId": "5b0b4c1e-7d1a-4f5e-9a3b-1c2d3e4f5a6b",
  "credential": {
    "id": "q8V2...",
    "rawId": "q8V2...",
    "response": {
      "attestationObject": "o2Nm...",
      "clientDataJSON": "eyJ0..."
    },
    "type": "public-key"
  },
  "name": "YubiKey 5C"
}
Responses
  • 201 Added.
    {
      "id": "q8V2...",
      "name": "YubiKey 5C",
      "passkey": false,
      "recoveryCodes": [
        "k3j9-2mxp",
        "..."
      ]
    }
  • 400 The challenge expired or was used.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 409 The key is already registered
  • 422 The key's answer did not check out.
POST /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.

Request
{
  "currentPassword": "correct horse battery",
  "passkey": true
}
Responses
  • 200 The options.
    {
      "challengeId": "5b0b4c1e-7d1a-4f5e-9a3b-1c2d3e4f5a6b",
      "options": {
        "publicKey": {
          "challenge": "Wm9...",
          "pubKeyCredParams": [
            {
              "alg": -7,
              "type": "public-key"
            }
          ],
          "rp": {
            "id": "zappo.city",
            "name": "Zappocity"
          }
        }
      }
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Wrong password.
PATCH /api/account/security-keys/{keyId}

Rename a key

ParameterInType
keyId *pathstringThe key's id (base64url).
Request
{
  "name": "Backup YubiKey"
}
Responses
  • 204 Renamed.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 No such key.
DELETE /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.

ParameterInType
keyId *pathstringThe key's id (base64url).
Request
{
  "currentPassword": "correct horse battery"
}
Responses
  • 204 Removed.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Wrong password.
  • 404 No such key.
GET /api/account/sessions

Where you are signed in

Responses
  • 200 Most 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"
        }
      ]
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
DELETE /api/account/sessions

Sign out everywhere else

Responses
  • 204 Done.
  • 401 Not signed in, or the CSRF token is missing or wrong.
GET /api/account/signins

Your recent sign-in attempts

Responses
  • 200 Newest 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"
        }
      ]
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
GET /api/account/two-factor

Whether two-step sign-in is on

Responses
  • 200 The state.
    {
      "enabled": true,
      "recoveryCodesLeft": 9
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
DELETE /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.

Responses
  • 204 Off.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 The password or code is not right (it counts as a failed sign-in).
POST /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.

Responses
  • 200 The recovery codes.
    {
      "recoveryCodes": [
        "abcd-efgh-jkmn"
      ]
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 409 Setup was not started.
  • 422 The code is not right.
POST /api/account/two-factor/recovery-codes

Replace the recovery codes

Responses
  • 200 The new codes
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 The password is not right.
  • 409 Two-step sign-in is off.
POST /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.

Responses
  • 200 The secret.
    {
      "qrCode": "data:image/png;base64,...",
      "secret": "JBSWY3DPEHPK3PXP",
      "uri": "otpauth://totp/Zappocity:amy@example.com?secret=..."
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 The password is not right.
  • 409 It is already on.

Sign-in

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

Responses
  • 200 The options and a challengeId.
POST /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.

Request
{
  "challengeId": "5b0b4c1e-7d1a-4f5e-9a3b-1c2d3e4f5a6b",
  "credential": {
    "id": "q8V2...",
    "response": {
      "authenticatorData": "SZYN...",
      "clientDataJSON": "eyJ0...",
      "signature": "MEUC...",
      "userHandle": "dXNl..."
    },
    "type": "public-key"
  }
}
Responses
  • 201 Signed in; as POST /api/account/session.
  • 400 The challenge expired or was used.
  • 401 The passkey did not check out.
POST /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().

Request
{
  "email": "amy@example.com",
  "password": "correct horse battery"
}
Responses
  • 200 The options and a challengeId.
  • 401 Wrong email or password.
  • 409 The account has no security key.
  • 429 Too many failed sign-ins.
POST /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.

Request
{
  "challengeId": "5b0b4c1e-7d1a-4f5e-9a3b-1c2d3e4f5a6b",
  "credential": {
    "id": "q8V2...",
    "response": {
      "authenticatorData": "SZYN...",
      "clientDataJSON": "eyJ0...",
      "signature": "MEUC..."
    },
    "type": "public-key"
  }
}
Responses
  • 201 Signed in; as POST /api/account/session.
  • 400 The challenge expired or was used.
  • 401 The key did not check out.

Signing in

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

Responses
  • 202 If the address has an account
  • 403 The captcha answer is missing or wrong (`captchaRequired` is true).
  • 429 Too many attempts from this address, or too many recent links, or the account is locked.
POST /api/account/reset/confirm

Set a new password from a reset link

The link also confirms the address. Every session ends.

Responses
  • 200 The password is set.
  • 400 The link has expired or was already used.
  • 422 A value is not acceptable, such as a password under 12 characters.
GET /api/account/session

Who is signed in, and the CSRF token

Responses
  • 200 Signed in.
    {
      "csrfToken": "Yk3...",
      "expiresAt": "2026-11-07T12:00:00Z",
      "person": {
        "email": "amy@example.com",
        "emailVerifiedAt": null,
        "id": "6c1f0b7e-2d4a-4b8e-9f3c-1a2b3c4d5e6f",
        "name": "Amy"
      }
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
POST /api/account/session

Sign in

Responses
  • 201 Signed in.
    {
      "csrfToken": "Yk3...",
      "expiresAt": "2026-11-07T12:00:00Z",
      "person": {
        "email": "amy@example.com",
        "emailVerifiedAt": null,
        "id": "6c1f0b7e-2d4a-4b8e-9f3c-1a2b3c4d5e6f",
        "name": "Amy"
      }
    }
  • 400 Not JSON, or unknown fields.
  • 401 Wrong email or password.
  • 403 The account is suspended (only said after the right password)
  • 429 Too many attempts from this address, or too many recent links, or the account is locked.
DELETE /api/account/session

Sign out

Responses
  • 204 Signed out.

Signing up

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

Request
{
  "email": "amy@example.com",
  "name": "Amy",
  "password": "correct horse battery"
}
Responses
  • 202 Check your email.
  • 400 Not JSON, or unknown fields.
  • 403 The captcha answer is missing or wrong (`captchaRequired` is true).
  • 422 A value is not acceptable, such as a password under 12 characters.
  • 429 Too many attempts from this address, or too many recent links, or the account is locked.
POST /api/account/verify

Confirm an email address from its link, and sign in

Responses
  • 200 Confirmed and signed in (as Session), or, for an account with two-step sign-in, confirmed with signIn true: sign in the usual way.
  • 400 The link has expired or was already used.
POST /api/account/verify/resend

Send a new confirmation link

Responses
  • 202 Sent.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 409 The address is already confirmed.
  • 429 Too many attempts from this address, or too many recent links, or the account is locked.

Support

GET /api/account/support/queues

What a request can be about

The active, public queues (support, billing, abuse...).

Responses
  • 200 The queues.
    {
      "items": [
        {
          "allowContacts": true,
          "description": "Invoices, payments, plans and refunds.",
          "name": "Billing",
          "slug": "billing"
        }
      ]
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
GET /api/account/tickets

Your support requests

Opened by you, or sent from or naming your confirmed address as a contact. Newest activity first.

Responses
  • 200 The tickets.
    {
      "items": [
        {
          "number": 41,
          "priority": "normal",
          "queue": "billing",
          "requesterEmail": "amy@example.com",
          "status": "pending",
          "subject": "Invoice question",
          "updatedAt": "2026-10-08T12:00:00Z"
        }
      ]
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
POST /api/account/tickets

Open a support request

You get a confirmation by email; contacts (when the queue takes them) also get the replies.

Request
{
  "body": "Why was I charged twice?",
  "queue": "billing",
  "subject": "Invoice question"
}
Responses
  • 201 The ticket.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 422 A value is not acceptable, such as a password under 12 characters.
GET /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.

ParameterInType
number *pathstringThe number, with or without ZC-.
Responses
  • 200 OK.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 No such ticket
POST /api/account/tickets/{number}/close

Close your request

Where the queue lets customers; abuse reports, for one, stay open until staff close them (403).

ParameterInType
number *pathstring
Responses
  • 200 OK.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Only staff close requests in this queue.
  • 404 No such ticket
POST /api/account/tickets/{number}/contacts

Add someone to the replies

Only where the queue allows contacts (409 otherwise); at most 20.

ParameterInType
number *pathstring
Responses
  • 200 OK.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 No such ticket
  • 409 The queue does not take contacts
DELETE /api/account/tickets/{number}/contacts/{email}

Take someone off the replies

ParameterInType
number *pathstring
email *pathstring
Responses
  • 200 OK.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 No such ticket
POST /api/account/tickets/{number}/messages

Reply

Reopens a resolved request; a closed one takes no more (409).

ParameterInType
number *pathstring
Responses
  • 201 The ticket with its messages.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 No such ticket
  • 409 The request is closed.
GET /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.

ParameterInType
token *pathstring
Responses
  • 200 The ticket with its messages.
  • 403 The queue needs signing in.
  • 404 The link has expired.
POST /api/support/links/{token}/messages

Reply from a notification link

ParameterInType
token *pathstring
Responses
  • 201 The ticket with its messages.
  • 403 The queue needs signing in.
  • 404 The link has expired.
  • 409 The request is closed.