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/delete
Whether the account is to be deleted
confirmWith is password, or email for an account without one (passkeys only).
200The deletion state.{ "confirmWith": "password", "deleteAfter": null }401Not signed in, or the CSRF token is missing or wrong.
/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.
202Scheduled.{ "deleteAfter": "2026-10-23T12:00:00Z", "status": "active" }401Not signed in, or the CSRF token is missing or wrong.403Wrong password or email409Refused, with the reason in detail: the only owner of an organization, or a mailbox under a legal hold or its team's last owner.
/api/account/delete
Keep my Zappocity account (and the mailboxes scheduled with it)
204No longer to be deleted.401Not signed in, or the CSRF token is missing or wrong.
/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).
{
"name": "Acme Support"
}
201The desk.{ "name": "Acme Support", "slug": "acme-support", "url": "https://acme-support.supporto.city/support" }400Not JSON, or unknown fields.401Not signed in, or the CSRF token is missing or wrong.403The email is not confirmed.409Too many desks.422A value is not acceptable, such as a password under 12 characters.
/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. person.number is the customer number (shown as C-17), to give when contacting support.
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", "number": 17 } }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}/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.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
200The 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" } ] }401Not signed in, or the CSRF token is missing or wrong.404No such organization
/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.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
{
"code": "WELCOME20"
}
201The redemption.401Not signed in, or the CSRF token is missing or wrong.403Missing or wrong X-CSRF-Token header.404No such code (or organization).409Refused, with the reason in detail (expired, used up, first order only...).429Too many wrong codes.
/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/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.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
subscriptionId * | path | string |
200The subscription.400Not JSON, or unknown fields.401Not signed in, or the CSRF token is missing or wrong.403Not an owner or billing member.404No such subscription here.409Refused
/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/plans/{product}
A product's plans and prices
For products customers change themselves (supporto.city); 404 for others.
| Parameter | In | Type | |
|---|---|---|---|
product * | path | string |
200Its 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 } } ] }404Not a self-serve product.
/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.
200The 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 } }401Not signed in, or the CSRF token is missing or wrong.
/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.
{
"orgId": "3c1d9a7e-55b2-4b8e-9e1f-0a2b3c4d5e6f",
"points": 500
}
201The points entry and the credit entry.401Not signed in, or the CSRF token is missing or wrong.403Missing or wrong X-CSRF-Token header.404No such organization409Refused, with the reason in detail: the program is off, fewer than minRedeemPoints, or not enough points.
/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.
{
"count": 2
}
201The points entry and how many invitations the mailbox now has.{ "entry": { "balance": 320, "kind": "invitations", "points": -200 }, "invitationsLeft": 3 }401Not signed in, or the CSRF token is missing or wrong.403Missing or wrong X-CSRF-Token header.409Refused, with the reason in detail: not offered, not enough points, or no Mailocity mailbox (the points are given back).
/api/account/profile
The person's profile and badges
200The 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" } }401Not signed in, or the CSRF token is missing or wrong.
/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.
{
"bio": "Support lead",
"handle": "pat",
"name": "Pat Lee",
"pronouns": "they/them",
"visible": true,
"website": "https://pat.example"
}
200The profile and badges.401Not signed in, or the CSRF token is missing or wrong.403Missing or wrong X-CSRF-Token header.409That handle is taken.422A value is not valid.
/api/account/profiles/{handle}
Someone's visible profile
For people signed in; only profiles made visible (and the person's own).
| Parameter | In | Type | |
|---|---|---|---|
handle * | path | string |
200The 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 }401Not signed in, or the CSRF token is missing or wrong.404No such visible profile.
/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.
DNS
/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.
200The 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 } ] }401Not signed in, or the CSRF token is missing or wrong.
/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).
{
"name": "Acme"
}
201The DNS account.400Not JSON, or unknown fields.401Not signed in, or the CSRF token is missing or wrong.403The email is not confirmed.409Not open yet422A value is not acceptable, such as a password under 12 characters.
/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.
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string |
200The 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" } ] }401Not signed in, or the CSRF token is missing or wrong.404No such DNS account
/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.
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string |
201The zone.400Not JSON, or unknown fields.401Not signed in, or the CSRF token is missing or wrong.403Only the organization's owners change its zones.404No such DNS account409The zone is served already, or the plan's limit is reached.422The record or zone is not valid
/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.
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string | |
zoneId * | path | string |
200The 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" } }401Not signed in, or the CSRF token is missing or wrong.404No such DNS account
/api/account/dns/{accountId}/zones/{zoneId}
Stop serving a zone
Its records go with it.
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string | |
zoneId * | path | string |
204Removed.401Not signed in, or the CSRF token is missing or wrong.403Only the organization's owners change its zones.404No such DNS account409The zone is served already, or the plan's limit is reached.422The record or zone is not valid
/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.
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string | |
zoneId * | path | string |
200The 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" }401Not signed in, or the CSRF token is missing or wrong.404No such DNS account or zone here.503Delegation is not checked by this server.
/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.
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string | |
zoneId * | path | string |
{
"addresses": [
"192.0.2.53"
],
"key": {
"algorithm": "hmac-sha256",
"name": "transfer.example.org",
"secret": "c2VjcmV0LXNlY3JldC1zZWNyZXQtc2VjcmV0LTAxMjM="
}
}
200The zone400Not JSON, or unknown fields.401Not signed in, or the CSRF token is missing or wrong.403Only the organization's owners change its zones.404No such DNS account or zone here.409The zone is not a secondary.422An address or the key is not valid.
/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.
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string | |
zoneId * | path | string |
{
"data": "10 mail.example.com.",
"name": "@",
"ttl": 3600,
"type": "MX"
}
201The 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" } }400Not JSON, or unknown fields.401Not signed in, or the CSRF token is missing or wrong.403Only the organization's owners change its zones.404No such DNS account409The zone is served already, or the plan's limit is reached.422The record or zone is not valid
/api/account/dns/{accountId}/zones/{zoneId}/records/{recordId}
Replace a record
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string | |
zoneId * | path | string | |
recordId * | path | string |
{
"data": "10 mail.example.com.",
"name": "@",
"ttl": 3600,
"type": "MX"
}
200The zone and its records.400Not JSON, or unknown fields.401Not signed in, or the CSRF token is missing or wrong.403Only the organization's owners change its zones.404No such DNS account409The zone is served already, or the plan's limit is reached.422The record or zone is not valid
/api/account/dns/{accountId}/zones/{zoneId}/records/{recordId}
Remove a record
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string | |
zoneId * | path | string | |
recordId * | path | string |
200The zone and its records.401Not signed in, or the CSRF token is missing or wrong.403Only the organization's owners change its zones.404No such DNS account409The zone is served already, or the plan's limit is reached.422The record or zone is not valid
/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.
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string | |
zoneId * | path | string |
200The 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" } }401Not signed in, or the CSRF token is missing or wrong.403Only the organization's owners change its zones.404No such DNS account or zone here.409The zone is not a secondary.
/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.
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string | |
zoneId * | path | string |
{
"addresses": [
"192.0.2.53",
"2001:db8::53"
]
}
200The 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...=" } }400Not JSON, or unknown fields.401Not signed in, or the CSRF token is missing or wrong.403Only the organization's owners change its zones, or the plan does not include secondary DNS.404No such DNS account or zone here.422An address is not valid, not on the internet, or there are more than 10.
/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.
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string | |
zoneId * | path | string |
200The 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...=" } }401Not signed in, or the CSRF token is missing or wrong.403Only the organization's owners change its zones, or the plan does not include secondary DNS.404No such DNS account or zone here.
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/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.
{
"host": "acme.supporto.city",
"next": "/desk/acme",
"state": "q1w2e3..."
}
200Where to go.{ "url": "https://acme.supporto.city/api/account/handoff/redeem?code=...\u0026next=%2Fdesk%2Facme" }400Not JSON, or unknown fields.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/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.
| Parameter | In | Type | |
|---|---|---|---|
code * | query | string | |
next | query | string |
302Signed in; to the desk.400The code cannot be used here.403The account is suspended.
/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.
| Parameter | In | Type | |
|---|---|---|---|
next | query | string |
302To the account page.
/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
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.
204Signed out.
/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.
{
"code": "123456",
"password": "correct horse battery staple"
}
204Confirmed.401Not signed in, or the CSRF token is missing or wrong.403Wrong password or code; twoFactorRequired is true when a code is needed.429Too many failed attempts.
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/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.
200The desk.{ "name": "Acme Support", "slug": "acme", "status": "active" }404Not a desk's host.
/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.
| Parameter | In | Type | |
|---|---|---|---|
q | query | string | |
category | query | string | |
limit | query | integer |
200The 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 }404No such category.
/api/account/help/{slug}
A help article, rendered
| Parameter | In | Type | |
|---|---|---|---|
slug * | path | string |
200The 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" }404No such article
/api/account/help/{slug}/rating
Say whether an article helped
Counted at most 20 times an hour from one address for an article.
| Parameter | In | Type | |
|---|---|---|---|
slug * | path | string |
{
"helpful": true
}
204Counted.404No such article.422helpful is missing.
/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. tag is the request as it is shown (its queue's prefix and the number).
200The 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" } ] }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 its prefix (BIL-41, or ZC-41 from before queues had their own). |
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.