Zappocity account API

version 1.23.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/delete

Whether the account is to be deleted

confirmWith is password, or email for an account without one (passkeys only).

Responses
  • 200 The deletion state.
    {
      "confirmWith": "password",
      "deleteAfter": null
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
POST /api/account/delete

Delete my Zappocity account

After the grace period the account is erased: email, name, password, profile, two-step sign-in, security keys, sessions, sign-in history, memberships, badges and invitations go; billing, points and audit records keep a "deleted" placeholder. Until then it works as before and DELETE cancels. mailboxes true also deletes the Mailocity mailboxes signed in to with this account, on the same day; otherwise they stay and are signed in to with their own passwords. Refused while the person is the only owner of an organization that has other members or products.

Responses
  • 202 Scheduled.
    {
      "deleteAfter": "2026-10-23T12:00:00Z",
      "status": "active"
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Wrong password or email
  • 409 Refused, with the reason in detail: the only owner of an organization, or a mailbox under a legal hold or its team's last owner.
DELETE /api/account/delete

Keep my Zappocity account (and the mailboxes scheduled with it)

Responses
  • 204 No longer to be deleted.
  • 401 Not signed in, or the CSRF token is missing or wrong.
POST /api/account/desks

Make a supporto.city help desk

Makes an organization the signed-in person owns, named name, subscribed to supporto.city's Free plan: its desk is ready at once with the person as its administrator, at url (its customer page; the agent app is /desk/{slug} on the same host). Needs a confirmed email. A person can make up to 3 (409 after).

Request
{
  "name": "Acme Support"
}
Responses
  • 201 The desk.
    {
      "name": "Acme Support",
      "slug": "acme-support",
      "url": "https://acme-support.supporto.city/support"
    }
  • 400 Not JSON, or unknown fields.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 The email is not confirmed.
  • 409 Too many desks.
  • 422 A value is not acceptable, such as a password under 12 characters.
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. person.number is the customer number (shown as C-17), to give when contacting support.

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",
        "number": 17
      }
    }
  • 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}/coupons

The organization's coupons

For its owners and billing members. Every coupon it redeemed, newest first; active says it is still in force, and invoices and discountCents are what it has taken off so far.

ParameterInType
orgId *pathstring
Responses
  • 200 The redemptions.
    {
      "items": [
        {
          "active": true,
          "by": "pat@acme.test",
          "coupon": {
            "code": "WELCOME20",
            "description": "20% off your first three months",
            "kind": "percent",
            "percentOff": 20
          },
          "discountCents": 200,
          "endReason": null,
          "endedAt": null,
          "endsAt": "2027-01-09T12:00:00Z",
          "id": "0b8e5f43-2d6c-4c55-8f1e-7c9a3e2d1b00",
          "invoices": 1,
          "invoicesLeft": null,
          "redeemedAt": "2026-10-09T12:00:00Z"
        }
      ]
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 No such organization
POST /api/account/orgs/{orgId}/coupons

Redeem a code

Discounts come off the next invoices it covers; a boost raises the product's limits at once. Wrong codes are limited to 10 an hour.

ParameterInType
orgId *pathstring
Request
{
  "code": "WELCOME20"
}
Responses
  • 201 The redemption.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Missing or wrong X-CSRF-Token header.
  • 404 No such code (or organization).
  • 409 Refused, with the reason in detail (expired, used up, first order only...).
  • 429 Too many wrong codes.
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.
PATCH /api/account/orgs/{orgId}/subscriptions/{subscriptionId}

Change a self-serve subscription's plan, seats or billing period

For the organization's owners and billing members (403 for others), and only for products customers change themselves (supporto.city, dnso.city; 404 for others). A dearer plan and more seats are invoiced at once for the rest of the period already paid (the difference in price, in proportion to the time left); a cheaper plan or fewer seats take effect without a refund. period (month or year) applies from the next renewal: yearly is cheaper (dnso.city's monthly prices are 20% more). A plan, seats or period the resource does not fit (more agents, queues, zones or records than allowed) or that is not offered is refused with 409 and the reason.

ParameterInType
orgId *pathstring
subscriptionId *pathstring
Responses
  • 200 The subscription.
  • 400 Not JSON, or unknown fields.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Not an owner or billing member.
  • 404 No such subscription here.
  • 409 Refused
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/plans/{product}

A product's plans and prices

For products customers change themselves (supporto.city); 404 for others.

ParameterInType
product *pathstring
Responses
  • 200 Its active plans.
    {
      "currency": "USD",
      "plans": [
        {
          "active": true,
          "code": "team",
          "features": [
            "help_center",
            "sla"
          ],
          "limits": {
            "agents": -1,
            "agentsBySeats": 1,
            "queues": -1
          },
          "name": "Team",
          "perSeat": true,
          "prices": {
            "month": 2280,
            "year": 22800
          }
        }
      ]
    }
  • 404 Not a self-serve product.
GET /api/account/points

The person's points, their history and what the program gives

program says what earns points (per dollar paid, per referral that becomes a customer, and so on) and what they buy: credit at pointValue hundredths of a cent each (at least minRedeemPoints at a time), and Mailocity invitations at inviteCostPoints each when invitations is true.

Responses
  • 200 The points.
    {
      "items": [
        {
          "at": "2026-10-09T12:00:00Z",
          "balance": 520,
          "by": "Zappocity loyalty",
          "description": "Referral: bob@example.com became a customer",
          "id": 7,
          "kind": "referral",
          "points": 500,
          "reference": ""
        }
      ],
      "points": 520,
      "program": {
        "enabled": true,
        "invitations": true,
        "inviteCostPoints": 100,
        "inviteUsedPoints": 20,
        "minRedeemPoints": 500,
        "pointValue": 100,
        "pointsPerDollar": 1,
        "referralPoints": 500,
        "welcomePoints": 200
      }
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
POST /api/account/points/credit

Turn points into credit for an organization

For an organization the person is an owner or billing member of. The credit is on its balance and pays its next invoices.

Request
{
  "orgId": "3c1d9a7e-55b2-4b8e-9e1f-0a2b3c4d5e6f",
  "points": 500
}
Responses
  • 201 The points entry and the credit entry.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Missing or wrong X-CSRF-Token header.
  • 404 No such organization
  • 409 Refused, with the reason in detail: the program is off, fewer than minRedeemPoints, or not enough points.
POST /api/account/points/invitations

Get more Mailocity invitations for points

Adds them to the person's Mailocity mailbox (the one they sign in to with this account); 1 to 50 at a time.

Request
{
  "count": 2
}
Responses
  • 201 The points entry and how many invitations the mailbox now has.
    {
      "entry": {
        "balance": 320,
        "kind": "invitations",
        "points": -200
      },
      "invitationsLeft": 3
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Missing or wrong X-CSRF-Token header.
  • 409 Refused, with the reason in detail: not offered, not enough points, or no Mailocity mailbox (the points are given back).
GET /api/account/profile

The person's profile and badges

Responses
  • 200 The profile and badges.
    {
      "badges": [
        {
          "color": "success",
          "icon": "star",
          "key": "early-adopter",
          "name": "Early adopter",
          "rule": "early_adopter"
        }
      ],
      "profile": {
        "bio": "Support lead",
        "createdAt": "2026-10-09T12:00:00Z",
        "handle": "pat",
        "name": "Pat Lee",
        "personId": "19adb6dd-1eac-4d74-8134-2668d30e9561",
        "pronouns": "they/them",
        "visible": true,
        "website": "https://pat.example"
      }
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
PATCH /api/account/profile

Change the profile

handle is 3 to 30 lower-case letters, digits, '.', '_' and '-' (null removes it; some names are kept for the platform); website is an https:// address. visible lets others see it: in webmail next to mail from you, and by handle. Badges are given by staff or their rules.

Request
{
  "bio": "Support lead",
  "handle": "pat",
  "name": "Pat Lee",
  "pronouns": "they/them",
  "visible": true,
  "website": "https://pat.example"
}
Responses
  • 200 The profile and badges.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Missing or wrong X-CSRF-Token header.
  • 409 That handle is taken.
  • 422 A value is not valid.
GET /api/account/profiles/{handle}

Someone's visible profile

For people signed in; only profiles made visible (and the person's own).

ParameterInType
handle *pathstring
Responses
  • 200 The profile and the badges that show.
    {
      "badges": [
        {
          "color": "primary",
          "icon": "user",
          "key": "staff",
          "name": "Staff",
          "rule": "staff"
        }
      ],
      "bio": "Support lead",
      "handle": "pat",
      "name": "Pat Lee",
      "pronouns": "they/them",
      "visible": true
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 No such visible profile.
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.

DNS

GET /api/account/dns

Your DNS accounts

The dnso.city DNS accounts of the organizations you are a member of; owners change their zones, other members read them.

Responses
  • 200 The accounts.
    {
      "items": [
        {
          "canEdit": true,
          "id": "1a2b3c4d-5e6f-4708-9a1b-2c3d4e5f6a7b",
          "name": "Acme",
          "orgId": "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f",
          "orgName": "Acme",
          "plan": "free",
          "status": "active",
          "zones": 1
        }
      ]
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
POST /api/account/dns

Start a dnso.city DNS account

Makes an organization you own, named name, subscribed to dnso.city's Free plan (3 zones). Needs a confirmed email and dnso.city available; up to 3 per person (409 after).

Request
{
  "name": "Acme"
}
Responses
  • 201 The DNS account.
  • 400 Not JSON, or unknown fields.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 The email is not confirmed.
  • 409 Not open yet
  • 422 A value is not acceptable, such as a password under 12 characters.
GET /api/account/dns/{accountId}/zones

A DNS account's zones

The zones dnso.city serves for the account, with their serials and record counts, and the account itself.

ParameterInType
accountId *pathstring
Responses
  • 200 The zones.
    {
      "account": {
        "id": "1a2b3c4d-5e6f-4708-9a1b-2c3d4e5f6a7b",
        "name": "Acme",
        "plan": "personal",
        "status": "active"
      },
      "items": [
        {
          "id": "2b3c4d5e-6f70-4182-93a4-b5c6d7e8f901",
          "name": "example.com",
          "records": 2,
          "serial": 2026100901,
          "status": "active"
        }
      ]
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 No such DNS account
POST /api/account/dns/{accountId}/zones

Serve a zone

Adds a domain for dnso.city to serve, within the plan's zones. A domain is served for one account only (409 when it is already). Point the domain's name servers at dnso.city's at its registrar for the answers to be used.

ParameterInType
accountId *pathstring
Responses
  • 201 The zone.
  • 400 Not JSON, or unknown fields.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Only the organization's owners change its zones.
  • 404 No such DNS account
  • 409 The zone is served already, or the plan's limit is reached.
  • 422 The record or zone is not valid
GET /api/account/dns/{accountId}/zones/{zoneId}

A zone and its records

With its DNSSEC: every zone is signed with a key of its own; dnssec.ds is the DS record to give the domain's registrar to turn DNSSEC on. With its secondary DNS: transfers lists the addresses it may be transferred to and its TSIG key (never the secret). A secondary zone has kind secondary, its records as last transferred (read only), and secondary: its primaries, key name, serial and the state of its checks.

ParameterInType
accountId *pathstring
zoneId *pathstring
Responses
  • 200 The zone and its records.
    {
      "dnssec": {
        "algorithm": 13,
        "ds": "24680 13 2 9A1B...",
        "dsRecord": "example.com.\t3600\tIN\tDS\t24680 13 2 9A1B...",
        "keyTag": 24680,
        "ready": true
      },
      "records": [
        {
          "data": "10 mail.example.com.",
          "id": "3c4d5e6f-7081-4293-a4b5-c6d7e8f90123",
          "name": "@",
          "ttl": 3600,
          "type": "MX"
        }
      ],
      "transfers": {
        "addresses": [],
        "key": null
      },
      "zone": {
        "accountId": "1a2b3c4d-5e6f-4708-9a1b-2c3d4e5f6a7b",
        "id": "2b3c4d5e-6f70-4182-93a4-b5c6d7e8f901",
        "name": "example.com",
        "records": 2,
        "serial": 2026100901,
        "status": "active"
      }
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 No such DNS account
DELETE /api/account/dns/{accountId}/zones/{zoneId}

Stop serving a zone

Its records go with it.

ParameterInType
accountId *pathstring
zoneId *pathstring
Responses
  • 204 Removed.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Only the organization's owners change its zones.
  • 404 No such DNS account
  • 409 The zone is served already, or the plan's limit is reached.
  • 422 The record or zone is not valid
GET /api/account/dns/{accountId}/zones/{zoneId}/delegation

Check the zone's delegation

Asks the zone's parent (the registry, for most domains) directly, not a resolver's cache: status is delegated (dnso.city's name servers and no others), partly (some of them, or others too: usual for a secondary zone), elsewhere (none of them), unregistered (the parent has no such name) or unknown (the parent could not be asked; error says why). ds is on (the parent's DS record is the zone's: DNSSEC works), mismatch (DS records, none the zone's: resolvers that check DNSSEC fail; replace it at the registrar), off or unknown. Kept a minute.

ParameterInType
accountId *pathstring
zoneId *pathstring
Responses
  • 200 The parent's answer.
    {
      "delegation": {
        "checkedAt": "2026-10-09T12:00:00Z",
        "ds": "off",
        "missing": [
          "ns2.dnso.city"
        ],
        "nameServers": [
          "ns1.dnso.city",
          "ns1.oldhost.net"
        ],
        "parent": "com",
        "status": "partly"
      },
      "expected": [
        "ns1.dnso.city",
        "ns2.dnso.city"
      ],
      "zone": "example.com"
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 404 No such DNS account or zone here.
  • 503 Delegation is not checked by this server.
PUT /api/account/dns/{accountId}/zones/{zoneId}/primaries

Set a secondary zone's primaries

The name servers a secondary zone is transferred from (IP addresses on the internet, 1 to 10, asked on port 53), and, when key is given, the TSIG key they want (null: none; left out: unchanged). The zone is checked with them within a minute. Answers the zone as GET does.

ParameterInType
accountId *pathstring
zoneId *pathstring
Request
{
  "addresses": [
    "192.0.2.53"
  ],
  "key": {
    "algorithm": "hmac-sha256",
    "name": "transfer.example.org",
    "secret": "c2VjcmV0LXNlY3JldC1zZWNyZXQtc2VjcmV0LTAxMjM="
  }
}
Responses
  • 200 The zone
  • 400 Not JSON, or unknown fields.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Only the organization's owners change its zones.
  • 404 No such DNS account or zone here.
  • 409 The zone is not a secondary.
  • 422 An address or the key is not valid.
POST /api/account/dns/{accountId}/zones/{zoneId}/records

Add a record

Checked as DNS reads it, and with the zone's other records: a name with a CNAME has nothing else, the zone itself is never a CNAME (use ALIAS), its own NS records are dnso.city's. The serial goes up. Answers the zone and its records.

ParameterInType
accountId *pathstring
zoneId *pathstring
Request
{
  "data": "10 mail.example.com.",
  "name": "@",
  "ttl": 3600,
  "type": "MX"
}
Responses
  • 201 The zone and its records.
    {
      "dnssec": {
        "algorithm": 13,
        "ds": "24680 13 2 9A1B...",
        "dsRecord": "example.com.\t3600\tIN\tDS\t24680 13 2 9A1B...",
        "keyTag": 24680,
        "ready": true
      },
      "records": [
        {
          "data": "10 mail.example.com.",
          "id": "3c4d5e6f-7081-4293-a4b5-c6d7e8f90123",
          "name": "@",
          "ttl": 3600,
          "type": "MX"
        }
      ],
      "zone": {
        "accountId": "1a2b3c4d-5e6f-4708-9a1b-2c3d4e5f6a7b",
        "id": "2b3c4d5e-6f70-4182-93a4-b5c6d7e8f901",
        "name": "example.com",
        "records": 2,
        "serial": 2026100901,
        "status": "active"
      }
    }
  • 400 Not JSON, or unknown fields.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Only the organization's owners change its zones.
  • 404 No such DNS account
  • 409 The zone is served already, or the plan's limit is reached.
  • 422 The record or zone is not valid
PUT /api/account/dns/{accountId}/zones/{zoneId}/records/{recordId}

Replace a record

ParameterInType
accountId *pathstring
zoneId *pathstring
recordId *pathstring
Request
{
  "data": "10 mail.example.com.",
  "name": "@",
  "ttl": 3600,
  "type": "MX"
}
Responses
  • 200 The zone and its records.
  • 400 Not JSON, or unknown fields.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Only the organization's owners change its zones.
  • 404 No such DNS account
  • 409 The zone is served already, or the plan's limit is reached.
  • 422 The record or zone is not valid
DELETE /api/account/dns/{accountId}/zones/{zoneId}/records/{recordId}

Remove a record

ParameterInType
accountId *pathstring
zoneId *pathstring
recordId *pathstring
Responses
  • 200 The zone and its records.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Only the organization's owners change its zones.
  • 404 No such DNS account
  • 409 The zone is served already, or the plan's limit is reached.
  • 422 The record or zone is not valid
POST /api/account/dns/{accountId}/zones/{zoneId}/refresh

Check a secondary zone now

Asks the primaries for the zone's SOA and transfers the zone if its serial moved. What happened is in secondary (lastError empty when it went well). Otherwise this happens on the schedule the zone's SOA sets (refresh, and retry after a failure) and when a primary sends NOTIFY to dnso.city's name servers.

ParameterInType
accountId *pathstring
zoneId *pathstring
Responses
  • 200 The zone, its records and its secondary state.
    {
      "dnssec": {
        "ready": false,
        "secondary": true
      },
      "records": [
        {
          "data": "192.0.2.7",
          "name": "www",
          "ttl": 300,
          "type": "A"
        }
      ],
      "secondary": {
        "expire": 1209600,
        "keyName": "transfer.example.org.",
        "lastCheck": "2026-10-09T12:00:00Z",
        "lastError": "",
        "lastOk": "2026-10-09T12:00:00Z",
        "lastTransfer": "2026-10-09T12:00:00Z",
        "nextCheck": "2026-10-09T13:00:00Z",
        "primaries": [
          "192.0.2.53"
        ],
        "primarySerial": 7,
        "records": 3,
        "refresh": 3600,
        "retry": 600
      },
      "transfers": {
        "addresses": [],
        "key": null
      },
      "zone": {
        "id": "2b3c4d5e-6f70-4182-93a4-b5c6d7e8f901",
        "kind": "secondary",
        "name": "example.org",
        "records": 3,
        "serial": 7,
        "status": "active"
      }
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Only the organization's owners change its zones.
  • 404 No such DNS account or zone here.
  • 409 The zone is not a secondary.
PUT /api/account/dns/{accountId}/zones/{zoneId}/transfers

Set the addresses the zone is transferred to

Secondary DNS, on plans that include it: the zone may be transferred (AXFR, or IXFR, answered with the whole zone) to these addresses, over TCP and only with a request signed with the zone's TSIG key (HMAC-SHA256); they are sent a NOTIFY when the zone changes. Addresses are on the internet (no private or loopback ones), at most 10; an empty list stops transfers. The first time, the zone gets its key, and the answer carries its secret: shown this once, so keep it. The zone goes out unsigned (dnso.city signs as it answers), so while the registrar has the zone's DS record, every name server for it must be dnso.city's. ALIAS records are not transferred. The serial goes up.

ParameterInType
accountId *pathstring
zoneId *pathstring
Request
{
  "addresses": [
    "192.0.2.53",
    "2001:db8::53"
  ]
}
Responses
  • 200 The zone's transfers; `key.secret` only when the key was just made.
    {
      "addresses": [
        "192.0.2.53",
        "2001:db8::53"
      ],
      "key": {
        "algorithm": "hmac-sha256",
        "createdAt": "2026-10-09T12:00:00Z",
        "name": "xfr.example.com.",
        "secret": "q2Lw...base64...="
      }
    }
  • 400 Not JSON, or unknown fields.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Only the organization's owners change its zones, or the plan does not include secondary DNS.
  • 404 No such DNS account or zone here.
  • 422 An address is not valid, not on the internet, or there are more than 10.
POST /api/account/dns/{accountId}/zones/{zoneId}/transfers/key

Replace the zone's TSIG key

A new key for transfers; the old one stops working once the name servers next read the zone (within a minute). The new secret is in this answer only.

ParameterInType
accountId *pathstring
zoneId *pathstring
Responses
  • 200 The zone's transfers with the new key's secret.
    {
      "addresses": [
        "192.0.2.53"
      ],
      "key": {
        "algorithm": "hmac-sha256",
        "createdAt": "2026-10-09T12:00:00Z",
        "name": "xfr.example.com.",
        "secret": "b7Rk...base64...="
      }
    }
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Only the organization's owners change its zones, or the plan does not include secondary DNS.
  • 404 No such DNS account or zone here.

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/handoff

A one-time code for a desk's host

For the signed-in person, a code that signs them in on one supporto.city desk's host, once, within two minutes, and only in the browser holding the state the host started with. Answers where to send the browser. 422 when the host is not a desk's.

Request
{
  "host": "acme.supporto.city",
  "next": "/desk/acme",
  "state": "q1w2e3..."
}
Responses
  • 200 Where to go.
    {
      "url": "https://acme.supporto.city/api/account/handoff/redeem?code=...\u0026next=%2Fdesk%2Facme"
    }
  • 400 Not JSON, or unknown fields.
  • 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/handoff/redeem

Use a handoff code

On the desk's host: with this browser's state cookie, uses up the code, opens a session here (two-step sign-in carried over) and redirects to next. 400 when the code is wrong, used, expired, for another host or another browser.

ParameterInType
code *querystring
nextquerystring
Responses
  • 302 Signed in; to the desk.
  • 400 The code cannot be used here.
  • 403 The account is suspended.
GET /api/account/handoff/start

Sign in on a supporto.city desk's host

On a desk's own host (acme.supporto.city): keeps a state in a cookie of this host (__Host-zappocity_handoff, ten minutes) and redirects to the Zappocity account page with #handoff=<host>&state=<state>&next=<next>, where the signed-in page asks for a code. On any other host it redirects to /account#next=<next>. next is a desk's agent app (/desk or /desk/{desk}); anything else becomes /desk.

ParameterInType
nextquerystring
Responses
  • 302 To the account page.
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

Ends this session, and the webmail, panel and help desk sessions signed in through it. On a help desk's own address, it ends the zappo.city session it was handed off from too.

Responses
  • 204 Signed out.
POST /api/account/session/confirm

Prove who you are again

For an application that asked (OpenID Connect prompt=login, or a max_age the session's last proof is older than): the password, and the code when two-step sign-in is on. The session stays the same; its next ID tokens carry the new auth_time. A wrong password or code counts like one at sign-in. Needs X-CSRF-Token.

Request
{
  "code": "123456",
  "password": "correct horse battery staple"
}
Responses
  • 204 Confirmed.
  • 401 Not signed in, or the CSRF token is missing or wrong.
  • 403 Wrong password or code; twoFactorRequired is true when a code is needed.
  • 429 Too many failed attempts.

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/desk

The desk this host is

On a supporto.city desk's own host, the desk's name, for its customer page (/support). On that host the support and help routes (tickets, queues, help articles) are the desk's, not Zappocity's. 404 on any other host.

Responses
  • 200 The desk.
    {
      "name": "Acme Support",
      "slug": "acme",
      "status": "active"
    }
  • 404 Not a desk's host.
GET /api/account/help

The help center

Its categories and articles: published public ones, and those for customers when signed in. q searches them (the best matches first, with a snippet); limit=5 gives a few suggestions while writing a request. Signing in is not needed.

ParameterInType
qquerystring
categoryquerystring
limitqueryinteger
Responses
  • 200 The categories and articles.
    {
      "categories": [
        {
          "articles": 6,
          "name": "Billing",
          "slug": "billing"
        }
      ],
      "items": [
        {
          "category": "billing",
          "kind": "faq",
          "slug": "refund",
          "snippet": "Open a request under Billing within 30 days",
          "title": "How do I get a refund?"
        }
      ],
      "signedIn": false
    }
  • 404 No such category.
GET /api/account/help/{slug}

A help article, rendered

ParameterInType
slug *pathstring
Responses
  • 200 The article.
    {
      "category": "billing",
      "html": "\u003cp\u003eOpen a request under \u003cstrong\u003eBilling\u003c/strong\u003e within 30 days.\u003c/p\u003e",
      "kind": "faq",
      "slug": "refund",
      "title": "How do I get a refund?",
      "updatedAt": "2026-10-09T12:00:00Z"
    }
  • 404 No such article
POST /api/account/help/{slug}/rating

Say whether an article helped

Counted at most 20 times an hour from one address for an article.

ParameterInType
slug *pathstring
Request
{
  "helpful": true
}
Responses
  • 204 Counted.
  • 404 No such article.
  • 422 helpful is missing.
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. tag is the request as it is shown (its queue's prefix and the number).

Responses
  • 200 The tickets.
    {
      "items": [
        {
          "number": 41,
          "prefix": "BIL",
          "priority": "normal",
          "queue": "billing",
          "requesterEmail": "amy@example.com",
          "status": "pending",
          "subject": "Invoice question",
          "tag": "BIL-41",
          "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 its prefix (BIL-41, or ZC-41 from before queues had their own).
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.