Zappocity is the central site for every product it sells (Mailocity today). This API keeps the customers and what they subscribe to; each product carries subscriptions out through the product contract: making a Mailocity subscription makes (or adopts) a Mailocity team.
Authentication is the admin API's: the platform API key as a bearer token, or an admin panel session with its X-CSRF-Token. Staff need the read permission to read, plans to change products and prices, and tenants for everything else. Teams cannot call it.
Every change is recorded in an append-only audit log with who made it (GET /v1/platform/audit). Amounts are whole US cents.
See docs/kb/admin/zappocity-accounts.md and docs/architecture/zappocity-platform.md.
Audit
/v1/platform/audit
Every change, with who made it
| Parameter | In | Type | |
|---|---|---|---|
subject | query | string | org:<id>, person:<id>, product:<key> or subscription:<id>. |
before | query | integer | Page by entry id. |
limit | query | integer |
200Newest first.{ "items": [ { "action": "provision", "at": "2026-10-08T12:00:00Z", "by": "the platform API key", "detail": "mailocity team year for Acme, resource 4f1c2d3e-1111-4a2b-9c3d-5e6f7a8b9c0d", "id": 12, "subject": "subscription:9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d" } ] }400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.
Billing
/v1/platform/billing/documents
How invoices, estimates and credit notes look as PDF files
Every invoice, quote (an estimate) and credit note can be downloaded as a PDF file, by staff here and by the organization's owners and billing members on the account page. These settings say who sends them, with a logo, an accent colour, the paper size, how dates are written and the words around the numbers. Only the standard PDF fonts are used, so letters outside Western European languages print as "?".
200The settings; logo is a data: URL, or empty.{ "accent": "#2563eb", "address": "100 Market Street\nSan Francisco, CA 94105", "companyName": "Zappocity Inc.", "creditNoteTitle": "Credit note", "dateFormat": "long", "email": "billing@zappocity.test", "footer": "Thank you for your business.", "invoiceTitle": "Invoice", "logo": "data:image/png;base64,iVBORw0KGgo...", "paper": "a4", "paymentTerms": "Pay by bank transfer to account 000123456 within 14 days.", "phone": "", "quoteTerms": "", "quoteTitle": "Estimate", "taxId": "US EIN 98-7654321", "timeZone": "UTC", "updatedAt": "2026-11-06T12:00:00Z", "updatedBy": "amy@zappocity.test", "website": "zappocity.test" }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.
/v1/platform/billing/documents
Change how billing documents look
Fields left out stay. Every document made after the change uses it, old ones included. Recorded in the audit log.
{
"accent": "#0f766e",
"companyName": "Zappocity Inc.",
"dateFormat": "us",
"footer": "Thank you for your business.",
"paper": "letter"
}
200The settings.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.422A value is out of range or names something that does not exist.
/v1/platform/billing/documents/sample
A made-up document, to see how the settings look
Renders a sample invoice, estimate or credit note for an example company with the saved settings, changed by settings when given (nothing is saved). long makes an invoice with enough lines to run onto several pages.
{
"kind": "quote",
"settings": {
"accent": "#0f766e",
"quoteTitle": "Quote"
}
}
200The PDF file, shown in the browser (Content-Disposition inline, named after its number).401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.422A value is out of range or names something that does not exist.
/v1/platform/billing/dunning
What happens to unpaid invoices
Dunning, run every hour: a reminder reminderDays before an open invoice is due (not when the invoice itself went out in the last day); an overdue notice on each of overdueDays after it was due; a late fee lateFeeDays after it (lateFeeCents plus lateFeePercent of what is unpaid, on an invoice of its own, without discounts or tax, never charged a late fee itself); its subscriptions suspended suspendDays after it, and canceled (their resources released, as staff canceling them) cancelDays after it. 0 turns a step off; only reminders and notices are on at first. Paying or voiding the invoice restores what was suspended for it at once (when nothing else past suspendDays is unpaid and the organization is active). Free organizations and those exempt (dunningExempt) are left out. Each step happens at most once per invoice.
200The settings.{ "cancelDays": 60, "lateFeeCents": 500, "lateFeeDays": 10, "lateFeePercent": 0, "overdueDays": [ 1, 3, 7 ], "reminderDays": 3, "suspendDays": 14, "updatedAt": "2026-11-05T12:00:00Z", "updatedBy": "amy@zappocity.test" }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.
/v1/platform/billing/dunning
Change what happens to unpaid invoices
Fields left out stay. cancelDays, when set with suspendDays, comes after it. Recorded in the audit log.
{
"lateFeeCents": 500,
"lateFeeDays": 10,
"suspendDays": 14
}
200The settings.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.422A value is out of range or names something that does not exist.
/v1/platform/billing/run
Bill what is due now
Otherwise billing runs every hour. Each organization whose subscriptions have reached their renewal gets one invoice: each plan at its price for the period, less discounts, less credit in its favour. An invoice with nothing left to pay is paid; one that is open is charged to the saved payment method when the organization pays automatically. Free organizations, and plans without a price, are renewed without an invoice. Then the dunning steps that are due are taken (see the dunning settings), as the hourly dunning job does.
200How many invoices were issued, and what dunning did.{ "dunning": { "canceled": 0, "lateFees": 0, "notices": 1, "reminders": 2, "restored": 0, "suspended": 1 }, "invoices": 3 }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.
/v1/platform/coupons
Coupons, newest first
| Parameter | In | Type | |
|---|---|---|---|
q | query | string | Codes containing this. |
200The coupons.{ "items": [ { "active": true, "amountOffCents": null, "boosts": {}, "buyCount": 1, "code": "WELCOME20", "createdAt": "2026-10-09T12:00:00Z", "createdBy": "owner@zappocity.test", "description": "20% off your first three months", "duration": "months", "durationMonths": 3, "expiresAt": "2027-01-01T00:00:00Z", "firstOrder": true, "freeCount": 1, "id": "6f1c2a9e-1b0e-4f3a-9a52-2f4d1e0c7b11", "kind": "percent", "maxRedemptions": null, "percentOff": 20, "periods": [ "month" ], "plans": [], "product": "mailocity", "redemptions": 12, "signedUpAfter": null, "signedUpBefore": null, "startsAt": null, "updatedAt": "2026-10-09T12:00:00Z" } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.
/v1/platform/coupons
Make a coupon
kind is percent (percentOff off each charge it covers), fixed (amountOffCents off an invoice's charges it covers), bogo (of every buyCount + freeCount subscriptions billed together, the cheapest freeCount are free, or percentOff off) or boost (raises the product's limits by name, by the amounts in boosts, such as {"users": 5, "storageBytes": 10737418240}; the names are the plan limits in GET /v1/platform/products). product, plans and periods limit which subscriptions it covers (none: all). duration is once (the next invoice it takes something off), months (durationMonths from redeeming) or forever; a boost lasts months or forever. startsAt and expiresAt are when it can be redeemed; maxRedemptions caps how many organizations can redeem it (1 for single use; leave it out for unlimited); firstOrder is for organizations never invoiced before, and applies only to their first invoice; signedUpAfter and signedUpBefore limit it to organizations made in that window. A code left out is made up (12 characters, for single-use codes). Codes are upper case.
201The coupon.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.409A coupon with this code already exists.422A value is out of range or names something that does not exist.
/v1/platform/coupons/{couponId}
A coupon
| Parameter | In | Type | |
|---|---|---|---|
couponId * | path | string |
200The coupon.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/coupons/{couponId}
Switch a coupon off or on, or change its end or cap
What a coupon takes off cannot change once it is out; make a new one for other terms. Switching it off stops new redemptions; organizations that have it keep it (remove it from one with DELETE /v1/platform/orgs/{orgId}/coupons/{redemptionId}). null removes the end date or the cap.
| Parameter | In | Type | |
|---|---|---|---|
couponId * | path | string |
{
"active": false
}
200The coupon.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/coupons/{couponId}
Delete a coupon nobody has redeemed
| Parameter | In | Type | |
|---|---|---|---|
couponId * | path | string |
204Deleted.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409Someone has redeemed it; switch it off instead.
/v1/platform/coupons/{couponId}/redemptions
Who redeemed a coupon
| Parameter | In | Type | |
|---|---|---|---|
couponId * | path | string |
200The redemptions, newest first.{ "items": [ { "active": true, "by": "pat@acme.test", "coupon": { "code": "WELCOME20" }, "discountCents": 200, "endReason": null, "endedAt": null, "endsAt": "2027-01-09T12:00:00Z", "id": "0b8e5f43-2d6c-4c55-8f1e-7c9a3e2d1b00", "invoices": 1, "invoicesLeft": null, "orgId": "3c1d9a7e-55b2-4b8e-9e1f-0a2b3c4d5e6f", "orgName": "Acme", "redeemedAt": "2026-10-09T12:00:00Z" } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/disputes
Card payments disputed with the bank (chargebacks)
From Stripe's dispute events, each handled once: the invoice and organization it is about, its reason, status and deadline to answer (at Stripe, with evidence), and what Stripe took back (withdrawnCents) and gave back (reinstatedCents), both on the organization's ledger as chargeback entries. A new dispute opens a high-priority ticket in the billing queue (ticketId). Newest first.
| Parameter | In | Type | |
|---|---|---|---|
open | query | string | Only those not closed. |
orgId | query | string |
200The disputes.{ "items": [ { "amountCents": 1428, "closedAt": null, "createdAt": "2026-10-20T14:00:00Z", "dueBy": "2026-11-03T23:59:59Z", "id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "invoiceId": "3a4b5c6d-7e8f-4a0b-9c1d-2e3f4a5b6c7d", "invoiceNumber": 42, "orgId": "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f", "orgName": "Acme", "paymentRef": "pi_123", "provider": "stripe", "providerId": "dp_1Abc", "reason": "fraudulent", "reinstatedCents": 0, "status": "needs_response", "ticketId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b", "updatedAt": "2026-10-20T14:00:00Z", "withdrawnCents": 1428 } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.
/v1/platform/invoices
Invoices of every organization
| Parameter | In | Type | |
|---|---|---|---|
status | query | string |
200Newest first400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.
/v1/platform/invoices/{invoiceId}
An invoice with its lines
With its credit notes (creditNotes), and how much of its charge they took back (creditedCents).
| Parameter | In | Type | |
|---|---|---|---|
invoiceId * | path | string |
200The invoice.{ "creditCents": 2500, "discountCents": 0, "dueAt": "2026-10-15T12:00:00Z", "lateFeeFor": null, "lines": [ { "amountCents": 12000, "description": "mailocity team, yearly", "kind": "charge" }, { "amountCents": -2500, "description": "Account credit", "kind": "credit" } ], "number": 7, "orgName": "Acme", "overdueNotices": 0, "remindedAt": "2026-10-12T12:00:00Z", "status": "open", "subtotalCents": 12000, "totalCents": 9500 }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/invoices/{invoiceId}/pay
Record a payment made outside the payment provider
| Parameter | In | Type | |
|---|---|---|---|
invoiceId * | path | string |
200The invoice401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409The invoice is not open.422A value is out of range or names something that does not exist.
/v1/platform/invoices/{invoiceId}/pdf
An invoice as a PDF file
Laid out with the document settings: its charges with their periods, then subtotal, discounts, credit used, tax, total and the amount due, stamped PAID, VOID or OVERDUE, and the payment terms while it is open.
| Parameter | In | Type | |
|---|---|---|---|
invoiceId * | path | string |
200The PDF file, shown in the browser (Content-Disposition inline, named after its number).401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/invoices/{invoiceId}/void
Cancel an open invoice
A credit note (kind void) takes its charge back, and puts it back on the balance as a credit_note ledger entry. Coupons used on it count as unused again.
| Parameter | In | Type | |
|---|---|---|---|
invoiceId * | path | string |
200The invoice401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409The invoice is not open.
/v1/platform/loyalty
The loyalty and referral program's rules
200The rules.{ "enabled": true, "inviteCostPoints": 100, "inviteUsedPoints": 20, "minRedeemPoints": 500, "pointValue": 100, "pointsPerDollar": 1, "referralPoints": 500, "signupPoints": 0, "updatedAt": "2026-10-09T12:00:00Z", "welcomePoints": 200 }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.
/v1/platform/loyalty
Change the rules
Points belong to people. They are earned for money paid on invoices (pointsPerDollar per whole dollar, to the organization's first owner); when someone a person invited (with their invitation link) has an organization that pays for the first time (referralPoints to the inviter, welcomePoints to the person invited, once each); on confirming a new account (signupPoints); and when someone signs up to Mailocity with an invitation the person's mailbox gave (inviteUsedPoints). They are spent on credit for an organization the person pays for (pointValue is a point's worth in hundredths of a cent: 100 is 1 cent; at least minRedeemPoints at a time) or on more Mailocity invitations (inviteCostPoints each; 0: not offered). inviteUsedPoints must be less than inviteCostPoints when both are set. Nothing is earned or spent while enabled is false.
{
"enabled": true,
"inviteCostPoints": 100,
"referralPoints": 1000
}
200The rules.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.422A value is out of range or names something that does not exist.
/v1/platform/orgs/{orgId}/apply-credit
Pay open invoices from credit
Pays the organization's open invoices from its credit, oldest first, as far as it goes: the ones listed, or all. An open invoice's charge is already on the balance, so the balance does not change; each invoice paid is marked paid by credit, with a credit line, and what dunning suspended for it starts again. Billing runs and added funds do this by themselves. Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
200The invoices paid.{ "items": [ { "creditCents": 9500, "number": 7, "paymentMethod": "credit", "status": "paid", "totalCents": 0 } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422An invoice listed is not one of the organization's open invoices.
/v1/platform/orgs/{orgId}/billable-items
An organization's billable items
One-off and recurring charges staff added: an hour of setup, a migration, a monthly support fee. When invoiceAt comes, the billing run (hourly, or Run billing now) puts each on the organization's invoice for subscriptions renewing in the same run, or on an invoice of its own; a recurring item then moves to its next month or year (a date long past bills one period, not every one missed) until its times run out or it is canceled. Coupons do not apply to them; tax does. Free organizations are billed for them too.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
200Newest first.{ "items": [ { "amountCents": 5000, "createdBy": "jane.doe@zappo.city", "description": "Priority support", "id": "0b7f2c1e-3a4d-4e5f-8a9b-1c2d3e4f5a6b", "invoiceAt": "2026-12-01T00:00:00Z", "invoiced": 1, "lastInvoiceId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "orgId": "9f8e7d6c-5b4a-4321-8fed-cba987654321", "period": "month", "quantity": 1, "remaining": null, "status": "active" } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/orgs/{orgId}/billable-items
Add a billable item
invoiceAt is when it is first invoiced (now unless given); invoiceNow invoices it, with any other items now due, on an invoice of its own at once. times limits a monthly or yearly item (leave it out to bill until canceled). Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
{
"amountCents": 7500,
"description": "Mailbox migration, 3 hours",
"invoiceNow": true,
"quantity": 3
}
201The item, and the invoice when invoiceNow issued one.{ "invoice": { "number": 1043, "status": "open", "totalCents": 22500 }, "item": { "amountCents": 7500, "description": "Mailbox migration, 3 hours", "id": "0b7f2c1e-3a4d-4e5f-8a9b-1c2d3e4f5a6b", "invoiced": 1, "period": "once", "quantity": 3, "remaining": 0, "status": "done" } }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.502Added
/v1/platform/orgs/{orgId}/billable-items/{itemId}
Change or cancel an active billable item
Changes apply from its next invoice; invoices already issued stay as they are. times is how many more invoices (0 ends it); untilCanceled removes the limit. status canceled stops it. Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
itemId * | path | string |
{
"status": "canceled"
}
200The item.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409The item is done or canceled.422A value is out of range or names something that does not exist.
/v1/platform/orgs/{orgId}/billable-items/{itemId}
Delete a billable item never invoiced
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
itemId * | path | string |
204Deleted.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It has been invoiced; cancel it instead.
/v1/platform/orgs/{orgId}/billing
An organization's billing and balance
A positive balance is credit in its favour; a negative one is owed.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
200The profile.{ "address": { "city": "Berlin", "country": "DE", "line1": "Hauptstr. 1", "line2": "", "postalCode": "10115", "state": "" }, "autoPay": true, "balanceCents": 2500, "free": true, "freeReason": "Launch partner", "freeUntil": "2027-01-01T00:00:00Z", "mode": "free", "orgId": "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f", "taxId": { "type": "eu_vat", "value": "DE123456789" }, "updatedAt": "2026-10-08T12:00:00Z" }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/orgs/{orgId}/billing
Make an organization free, or charged again; automatic payment
mode: free (with freeReason) charges nothing for its products, for good or until freeUntil (null: for good); standard charges again. address and taxId are what its invoices are taxed from (Stripe Tax). Recorded in the audit log. Needs the billing permission.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
{
"freeReason": "Launch partner",
"freeUntil": "2027-01-01",
"mode": "free"
}
200The profile.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/orgs/{orgId}/coupons
An organization's coupons
Every coupon it redeemed, newest first. active says it is still in force; endReason is used (a coupon for once, on an invoice), expired or removed. invoices and discountCents are what it has taken off so far.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
200The redemptions.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/orgs/{orgId}/coupons
Redeem a code for an organization
On the same terms as when its people redeem it on the account page. A boost applies at once.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
{
"code": "WELCOME20"
}
201The redemption.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such organization or code.409Refused, with the reason: switched off, not started, expired, used up, already used by the organization, first order only, not for organizations made then, or the organization has 10 coupons in force.
/v1/platform/orgs/{orgId}/coupons/{redemptionId}
Remove a coupon from an organization
It takes nothing more off, and its boosts go at once. What it took off already stays on those invoices.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
redemptionId * | path | string |
200The ended redemption.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It has already ended.
/v1/platform/orgs/{orgId}/credit-notes
An organization's credit notes
Newest first. Each takes back all or part of an invoice's charge and puts it back on the balance: kind void (the invoice was voided), refund (a payment of it was given back; by Stripe, or recorded by staff) or credit (staff credited it to the balance).
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
200The credit notes.{ "items": [ { "amountCents": 2500, "createdAt": "2026-11-05T13:20:49Z", "id": "3a4b5c6d-7e8f-4091-a2b3-c4d5e6f70812", "invoiceId": "0d1e2f3a-4b5c-4d6e-8f70-8192a3b4c5d6", "invoiceNumber": 7, "kind": "credit", "number": 12, "reason": "Outage on 3 November", "reference": "", "tag": "CN-12" } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/orgs/{orgId}/credit-notes/{creditNoteId}/pdf
A credit note as a PDF file
The amount taken back from an invoice, which invoice, the reference and the reason.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
creditNoteId * | path | string |
200The PDF file, shown in the browser (Content-Disposition inline, named after its number).401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/orgs/{orgId}/invoices
An organization's invoices
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
status | query | string |
200Newest first.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/orgs/{orgId}/invoices/{invoiceId}/credit-notes
Credit or refund part of a paid invoice
kind credit puts amountCents of the invoice's charge back on the balance (goodwill, a service not delivered). kind refund records money given back outside the payment provider (a bank transfer) for an invoice paid outside it: a refund entry and a credit note, so the balance is unchanged. Card payments are refunded in Stripe, which records them by itself (with a credit note). Never more than is left: credit notes together take back at most the charge, refunds at most what was paid. A refund of a charge already credited is recorded without a credit note (204). The invoice must be the organization's. Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
invoiceId * | path | string |
{
"amountCents": 2500,
"kind": "credit",
"reason": "Outage on 3 November"
}
201The credit note.{ "amountCents": 2500, "createdAt": "2026-11-05T13:20:49Z", "id": "3a4b5c6d-7e8f-4091-a2b3-c4d5e6f70812", "invoiceId": "0d1e2f3a-4b5c-4d6e-8f70-8192a3b4c5d6", "invoiceNumber": 7, "kind": "credit", "number": 12, "reason": "Outage on 3 November", "reference": "", "tag": "CN-12" }204The refund was recorded; the charge had been credited already.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409The invoice is not paid, a refund of one paid by card or credit, or more than is left.422A value is out of range or names something that does not exist.
/v1/platform/orgs/{orgId}/ledger
An organization's balance history
Every change, newest first, each with the balance after it. Entries are never changed or deleted.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
before | query | integer | |
limit | query | integer |
200The entries.{ "items": [ { "amountCents": 2500, "at": "2026-10-08T12:00:00Z", "balanceCents": 2500, "by": "owner@zappocity.test", "description": "Sorry for the outage", "id": 3, "invoiceId": null, "kind": "credit", "reference": "ZC-41" } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/orgs/{orgId}/ledger
Add credit, take it away, or record a payment or refund
credit and payment add to the balance; debit and refund take from it (the balance may go below zero: the organization owes it). Correct a mistake with an opposite entry.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
{
"amountCents": 2500,
"description": "Sorry for the outage",
"kind": "credit",
"reference": "ZC-41"
}
201The entry.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/orgs/{orgId}/quotes
An organization's quotes
Staff write a quote as a draft, then send it: the organization's owners and billing members are mailed and accept or decline it on the account page before validUntil. Accepting makes each line a billable item and invoices them at once (a monthly or yearly line is then billed each period until canceled). A sent quote never changes; staff can cancel it while it waits.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
200Newest first.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/orgs/{orgId}/quotes
Write a draft quote
Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
{
"lines": [
{
"description": "Mailbox migration, hours",
"quantity": 3,
"unitCents": 7500
},
{
"description": "Priority support",
"period": "month",
"unitCents": 5000
}
],
"subject": "Migration and priority support",
"validUntil": "2026-12-05"
}
201The draft.{ "answeredAt": null, "answeredBy": "", "declineReason": "", "expired": false, "firstCents": 27500, "id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a", "invoiceId": null, "lines": [ { "description": "Mailbox migration, hours", "period": "once", "quantity": 3, "unitCents": 7500 }, { "description": "Priority support", "period": "month", "quantity": 1, "unitCents": 5000 } ], "monthlyCents": 5000, "notes": "", "number": 1001, "orgId": "9f8e7d6c-5b4a-4321-8fed-cba987654321", "orgName": "Acme Co", "sentAt": null, "status": "draft", "subject": "Migration and priority support", "tag": "ZQ-1001", "validUntil": "2026-12-05T23:59:59Z", "yearlyCents": 0 }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/orgs/{orgId}/quotes/{quoteId}
A quote with its lines
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
quoteId * | path | string |
200The quote.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/orgs/{orgId}/quotes/{quoteId}
Rewrite a draft quote
Every field, as when writing it. Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
quoteId * | path | string |
{
"lines": [
{
"description": "Mailbox migration, hours",
"quantity": 3,
"unitCents": 7500
},
{
"description": "Priority support",
"period": "month",
"unitCents": 5000
}
],
"subject": "Migration and priority support",
"validUntil": "2026-12-05"
}
200The draft.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It is no longer a draft.422A value is out of range or names something that does not exist.
/v1/platform/orgs/{orgId}/quotes/{quoteId}
Delete a draft quote
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
quoteId * | path | string |
204Deleted.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It has been sent; cancel it instead.
/v1/platform/orgs/{orgId}/quotes/{quoteId}/cancel
Withdraw a sent quote
It can no longer be accepted. Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
quoteId * | path | string |
200The quote401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It is not a sent quote waiting for an answer.
/v1/platform/orgs/{orgId}/quotes/{quoteId}/pdf
A quote as a PDF file (an estimate)
Titled with quoteTitle (Estimate unless changed): each line with its quantity, unit price and how often, then the first invoice and what follows each month and year, its notes and the quote terms. Drafts are stamped DRAFT.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
quoteId * | path | string |
200The PDF file, shown in the browser (Content-Disposition inline, named after its number).401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/orgs/{orgId}/quotes/{quoteId}/send
Send a draft quote
Mails the organization's owners and billing members the quote, with a link to accept or decline it on the account page. It cannot be changed after. Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
quoteId * | path | string |
200The quote401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409Not a draft
/v1/platform/payments
The payment provider
Stripe today. Customers save a payment method and pay invoices on Stripe's hosted pages (any method enabled in the Stripe dashboard: cards, wallets, bank debits, crypto where Stripe offers it); card details never reach Zappocity. Point a Stripe webhook at https://zappo.city/api/billing/webhook/stripe for checkout.session.completed, charge.refunded and charge.dispute.* (created, updated, closed, funds_withdrawn, funds_reinstated). Keys are kept sealed and never returned. With taxEnabled, each invoice's tax is calculated by Stripe Tax (on the amount after discounts, exclusive, with taxCode) from the organization's billing address and tax number, as tax lines; once paid it is recorded as a Stripe Tax transaction (and reversed when refunded in full). Where to collect is registered in Stripe. An organization without a billing country is invoiced untaxed.
200The settings.{ "fundsEnabled": true, "fundsMaxBalanceCents": 500000, "fundsMaxCents": 100000, "fundsMinCents": 1000, "hasSecretKey": true, "hasWebhookSecret": true, "provider": "stripe", "taxCode": "txcd_10103001", "taxEnabled": true, "updatedAt": "2026-10-08T12:00:00Z" }401No valid platform API key or admin panel session.
/v1/platform/payments
Set the payment provider and its keys
{
"fundsEnabled": true,
"fundsMinCents": 2000,
"provider": "stripe",
"secretKey": "sk_live_xxx",
"taxEnabled": true,
"webhookSecret": "whsec_xxx"
}
200The settings.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.422A value is out of range or names something that does not exist.
/v1/platform/payments/test
Check the key works
200Whether Stripe took the key, and why not.{ "error": "stripe: Invalid API Key provided (401 )", "ok": false }401No valid platform API key or admin panel session.
/v1/platform/people/{personId}/points
A person's points and their history
Every change, newest first, with the balance after it. Entries are never changed or deleted.
| Parameter | In | Type | |
|---|---|---|---|
personId * | path | string | |
before | query | integer | |
limit | query | integer |
200The balance and entries.{ "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": "3c1d9a7e-55b2-4b8e-9e1f-0a2b3c4d5e6f" } ], "points": 520 }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/people/{personId}/points
Give or take points by hand
| Parameter | In | Type | |
|---|---|---|---|
personId * | path | string |
{
"description": "Thanks for the bug report",
"points": 250
}
201The entry.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It would take more points than the person has.422A value is out of range or names something that does not exist.
/v1/platform/quotes
Quotes of every organization
| Parameter | In | Type | |
|---|---|---|---|
status | query | string |
200Newest first, at most 500. A sent quote past validUntil has expired true.{ "items": [ { "answeredAt": null, "answeredBy": "", "declineReason": "", "expired": false, "firstCents": 27500, "id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a", "invoiceId": null, "lines": [ { "description": "Mailbox migration, hours", "period": "once", "quantity": 3, "unitCents": 7500 }, { "description": "Priority support", "period": "month", "quantity": 1, "unitCents": 5000 } ], "monthlyCents": 5000, "notes": "", "number": 1001, "orgId": "9f8e7d6c-5b4a-4321-8fed-cba987654321", "orgName": "Acme Co", "sentAt": "2026-11-05T12:00:00Z", "status": "sent", "subject": "Migration and priority support", "tag": "ZQ-1001", "validUntil": "2026-12-05T23:59:59Z", "yearlyCents": 0 } ] }400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.
DNS
/v1/platform/dns/accounts/{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" } ] }401No valid platform API key or admin panel session.404No such DNS account
/v1/platform/dns/accounts/{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.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403Not allowed (staff need the tenants permission to change zones).404No such DNS account409The zone is served already, or the plan's limit is reached.422The record or zone is not valid
/v1/platform/dns/accounts/{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" } }401No valid platform API key or admin panel session.404No such DNS account
/v1/platform/dns/accounts/{accountId}/zones/{zoneId}
Stop serving a zone
Its records go with it.
| Parameter | In | Type | |
|---|---|---|---|
accountId * | path | string | |
zoneId * | path | string |
204Removed.401No valid platform API key or admin panel session.403Not allowed (staff need the tenants permission to change zones).404No such DNS account409The zone is served already, or the plan's limit is reached.422The record or zone is not valid
/v1/platform/dns/accounts/{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" }401No valid platform API key or admin panel session.404No such DNS account or zone here.503Delegation is not checked by this server.
/v1/platform/dns/accounts/{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 zone400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403Not allowed (staff need the tenants permission to change zones).404No such DNS account or zone here.409The zone is not a secondary.422An address or the key is not valid.
/v1/platform/dns/accounts/{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" } }400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403Not allowed (staff need the tenants permission to change zones).404No such DNS account409The zone is served already, or the plan's limit is reached.422The record or zone is not valid
/v1/platform/dns/accounts/{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.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403Not allowed (staff need the tenants permission to change zones).404No such DNS account409The zone is served already, or the plan's limit is reached.422The record or zone is not valid
/v1/platform/dns/accounts/{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.401No valid platform API key or admin panel session.403Not allowed (staff need the tenants permission to change zones).404No such DNS account409The zone is served already, or the plan's limit is reached.422The record or zone is not valid
/v1/platform/dns/accounts/{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" } }401No valid platform API key or admin panel session.403Not allowed (staff need the tenants permission to change zones).404No such DNS account or zone here.409The zone is not a secondary.
/v1/platform/dns/accounts/{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...=" } }400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403Not allowed (staff need the tenants permission to change 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.
/v1/platform/dns/accounts/{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...=" } }401No valid platform API key or admin panel session.403Not allowed (staff need the tenants permission to change zones), or the plan does not include secondary DNS.404No such DNS account or zone here.
/v1/platform/dns/readdress
Change customers' records that name the platform's old addresses
When the platform's addresses move (docs/kb/admin/moving-ips.md), changes every primary zone's A and AAAA records and SPF ip4:/ip6: terms that name an old address exactly. step add puts the new address beside the old (an A or AAAA record more, a term after the old one), for while both are in use; replace puts it instead, or drops the old where the new is there already, for once the old one is gone. A block (ip4:203.0.113.16/28), a longer address and every other record are left alone; secondary zones never change. Each zone changes in one go (its serial up once) or not at all, within its plan's limits; a zone that cannot is listed with why, and the rest still change. Logged per zone. Needs the settings permission.
{
"dryRun": true,
"moves": [
{
"from": "203.0.113.17",
"to": "198.51.100.10"
}
],
"step": "add"
}
200The zones changed (or that would be), each with its changes; before "" is an added record, after "" a removed one.{ "dryRun": true, "failed": 0, "records": 2, "step": "add", "zones": [ { "accountId": "6f1c2e8a-3b5d-4c7e-9f10-2a3b4c5d6e7f", "changes": [ { "after": "198.51.100.10", "before": "", "name": "www", "type": "A" }, { "after": "v=spf1 ip4:203.0.113.17 ip4:198.51.100.10 -all", "before": "v=spf1 ip4:203.0.113.17 -all", "name": "@", "type": "TXT" } ], "zone": "example.com", "zoneId": "0c6d1f7e-2a3b-4c5d-8e9f-1a2b3c4d5e6f" } ] }400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.422A value is out of range or names something that does not exist.
/v1/platform/dns/settings
dnso.city's name servers
The name servers every served zone names in its NS records (the first also in its SOA), and the SOA's contact. The name servers read them with the zones. Approved name servers that answer the health checks are set here by themselves (docs/kb/admin/servers.md).
200The settings.{ "hostmaster": "hostmaster@dnso.city", "nameServers": [ "ns1.dnso.city", "ns2.dnso.city" ], "updatedAt": "2026-10-09T12:00:00Z" }401No valid platform API key or admin panel session.
/v1/platform/dns/settings
Set dnso.city's name servers
Needs the settings permission. Up to 13 host names; the hostmaster is an email address (empty for hostmaster@ the first name server's domain).
{
"hostmaster": "hostmaster@dnso.city",
"nameServers": [
"ns1.dnso.city",
"ns2.dnso.city"
]
}
200The settings.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.422A value is out of range or names something that does not exist.
Jobs
/v1/platform/jobs
The platform's scheduled jobs
When the platform runs apart from the mail server (zappocityd): its own recurring work (help desk mail, billing, Zappocity account deletions, the sign-in clean-up), in the same form as the admin API's /v1/jobs. In one process the list is empty, as /v1/jobs has every job. See docs/kb/admin/scheduled-jobs.md.
200The jobs.{ "items": [ { "defaultEverySeconds": 3600, "description": "Invoices subscriptions that are due, charges saved payment methods and sends the invoices.", "enabled": true, "everySeconds": 3600, "failures": 0, "lastDurationMs": 2012, "lastError": "", "lastFinishedAt": "2026-10-09T17:00:02Z", "lastOk": true, "lastResult": "3 invoices made", "lastStartedAt": "2026-10-09T17:00:00Z", "lastTrigger": "schedule", "name": "billing", "nextAt": "2026-10-09T18:00:00Z", "running": false, "runs": 40, "title": "Billing" } ] }401No valid platform API key or admin panel session.
/v1/platform/jobs/{name}
Pause, resume or reschedule a platform job
A paused job still runs when asked to run now. Needs the settings permission.
| Parameter | In | Type | |
|---|---|---|---|
name * | path | string |
{
"enabled": false
}
200The job.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/jobs/{name}/run
Run a platform job now
It starts within moments on zappocityd (202); its outcome shows in the list. Its next scheduled run counts from this one. Needs the settings permission.
| Parameter | In | Type | |
|---|---|---|---|
name * | path | string |
200Run here (no scheduler running); its outcome.{ "durationMs": 41, "error": "", "finished": true, "ok": true, "result": "0 invoices made", "started": true }202Started.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It is running now.
/v1/platform/mail
How Zappocity sends its own mail
jmap sends over JMAP (EmailSubmission) as a Mailocity mailbox: apiUrl is Mailocity's address and apiToken a mailbox API token with the jmap scope; it works the same in one process or once the services run apart. internal sends through Mailocity in the same process (from its notice address, under fromName) until JMAP is set up; smtp sends by SMTP submission as a mailbox with an app password. api (the old send-API mode) is taken as jmap. Secrets are never returned. Staff need the settings permission to change it.
200The settings. internalAvailable is false when Mailocity runs as its own service (CORPSUITE_MAILOCITY_API_URL), so mode internal is refused.{ "apiUrl": "https://mailo.city", "fromName": "Zappocity", "hasApiToken": true, "hasSmtpPassword": false, "internalAvailable": true, "mode": "jmap", "smtpHost": "", "smtpPort": 587, "smtpSecurity": "starttls", "smtpUsername": "", "updatedAt": "2026-10-08T12:00:00Z" }401No valid platform API key or admin panel session.
/v1/platform/mail
Change how Zappocity sends its own mail
{
"apiToken": "cs_mt_Yk3",
"apiUrl": "https://mailo.city",
"mode": "jmap"
}
200The settings.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.422A value is out of range or names something that does not exist.
/v1/platform/mail/test
Send a test message the configured way
200Whether it went, and the error if not.{ "ok": true }401No valid platform API key or admin panel session.422A value is out of range or names something that does not exist.
Organizations
/v1/platform/orgs
Find organizations
| Parameter | In | Type | |
|---|---|---|---|
q | query | string | Part of the name |
limit | query | integer |
200By name.{ "items": [ { "createdAt": "2026-10-08T12:00:00Z", "id": "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f", "kind": "company", "members": 3, "name": "Acme", "status": "active", "updatedAt": "2026-10-08T12:00:00Z" } ] }400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.
/v1/platform/orgs
Add an organization with its owner
The one who pays for subscriptions. owner is a person's id or email; they must exist.
{
"name": "Acme",
"owner": "amy@example.com"
}
201The organization.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.422A value is out of range or names something that does not exist.
/v1/platform/orgs/{orgId}
An organization with its members, invitations and subscriptions
invitations are the invitations its owners and admins sent from the account page that have not been answered or expired (14 days). A person can be a member of any number of organizations.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
200The organization.{ "invitations": [ { "createdAt": "2026-10-18T09:00:00Z", "email": "sam@example.com", "expiresAt": "2026-11-01T09:00:00Z", "id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", "invitedBy": "Alice", "orgId": "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f", "orgName": "Acme", "role": "member" } ], "members": [], "org": { "id": "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f", "kind": "company", "members": 1, "name": "Acme", "status": "active" }, "subscriptions": [] }401No valid platform API key or admin panel session.404No such object.
/v1/platform/orgs/{orgId}
Rename, or suspend and reactivate
Suspending suspends every active subscription's resource (a Mailocity team cannot sign in or receive mail); making it active again brings them back.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
{
"status": "suspended"
}
200The organization.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/orgs/{orgId}/invitations/{invitationId}
Withdraw an invitation to join the organization
The invitation (listed with the organization) can no longer be accepted. Staff add members directly with PUT on the member.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
invitationId * | path | string |
204Withdrawn.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/orgs/{orgId}/members/{personId}
Add a member or change their role
owner (everything), admin (runs the Mailocity team), billing (invoices and payment), support (tickets), member or guest: the same six roles as Mailocity mailboxes. The last owner cannot be demoted (409). A member with a mailbox in one of the organization's Mailocity teams has that mailbox's role: a role set here reaches all their mailboxes in its teams within a minute (the account sync), and one changed on a mailbox later comes back here.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
personId * | path | string |
{
"role": "billing"
}
200The members.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/orgs/{orgId}/members/{personId}
Remove a member
The last owner cannot be removed (409), nor a member with a mailbox in one of the organization's Mailocity teams (409, naming the mailbox): delete or unlink the mailbox in Mailocity first. A member the account sync added for their mailbox is removed by it once the mailbox is gone.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | |
personId * | path | string |
204Removed.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
/v1/platform/orgs/{orgId}/merge
Merge an organization recorded twice into this one
For one company with two organizations. The other's members (the stronger role kept where a person is in both), subscriptions, invoices and ledger (so its credit), coupons (one of each), help desks, DNS accounts, tickets and Mailocity teams move to this one. Its balance moves by an adjustment entry on each ledger (ledger entries are never changed); it is kept as status merged with mergedInto, out of lists. This one's billing profile and saved payment method are kept. Cannot be undone; recorded in the audit log with what moved. Merging again into the same organization only repeats the teams' move (to finish one that answered 502). Needs the billing permission.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string | The organization to keep. |
{
"from": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d"
}
200The organization kept, and how many of each thing moved.{ "moved": { "invoices": 4, "members": 2, "subscriptions": 1 }, "org": { "id": "1a2b3c4d-5e6f-4708-9a1b-2c3d4e5f6a7b", "name": "Acme" } }400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409One of them is merged into another organization already.422from is missing or the same organization.502Merged, but the teams could not be moved; merge again to finish.
People
/v1/platform/badges
Badges, with the icons and colours they can have
200The badges.{ "colors": [ "primary", "success" ], "icons": [ "star", "user", "rocket" ], "items": [ { "active": true, "color": "primary", "description": "Works at Zappocity", "holders": 0, "icon": "user", "id": "6f1c2a9e-1b0e-4f3a-9a52-2f4d1e0c7b11", "joinedBefore": null, "key": "staff", "name": "Staff", "plan": null, "product": null, "rule": "staff", "sortOrder": 20 } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.
/v1/platform/badges
Make a badge
rule says who has it: manual (given by hand), early_adopter (joined before joinedBefore, or ever subscribed to product's plan; either is enough), staff (active panel staff) or administrator (staff with every permission). Staff and administrator badges show on proven mail even when the person's profile is hidden.
{
"color": "warning",
"description": "Reported a security bug",
"icon": "bolt",
"key": "bug-hunter",
"name": "Bug hunter"
}
201The badge.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.409A badge with this key exists.422A value is out of range or names something that does not exist.
/v1/platform/badges/{badgeId}
Change a badge
Everything but key and rule. joinedBefore null removes the date; plan "" removes the plan.
| Parameter | In | Type | |
|---|---|---|---|
badgeId * | path | string |
{
"active": true,
"joinedBefore": "2027-06-01"
}
200The badge.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/badges/{badgeId}
Delete a badge (and take it from everyone)
| Parameter | In | Type | |
|---|---|---|---|
badgeId * | path | string |
204Deleted.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/people
Find people
| Parameter | In | Type | |
|---|---|---|---|
q | query | string | Part of an email or name. |
limit | query | integer |
200By email.{ "items": [ { "createdAt": "2026-10-08T12:00:00Z", "email": "amy@example.com", "emailVerifiedAt": "2026-10-08T12:00:00Z", "hasPassword": true, "id": "6c1f0b7e-2d4a-4b8e-9f3c-1a2b3c4d5e6f", "name": "Amy", "status": "active", "updatedAt": "2026-10-08T12:00:00Z" } ] }400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.
/v1/platform/people
Add a person
A Zappocity account. Without a password, they can set one later.
{
"email": "amy@example.com",
"name": "Amy",
"verified": true
}
201The person.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/people/{personId}
A person and the organizations they belong to
| Parameter | In | Type | |
|---|---|---|---|
personId * | path | string |
200The person.{ "memberships": [ { "createdAt": "2026-10-08T12:00:00Z", "email": "amy@example.com", "name": "Amy", "orgId": "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f", "orgName": "Acme", "personId": "6c1f0b7e-2d4a-4b8e-9f3c-1a2b3c4d5e6f", "role": "owner" } ], "person": { "email": "amy@example.com", "hasPassword": true, "id": "6c1f0b7e-2d4a-4b8e-9f3c-1a2b3c4d5e6f", "name": "Amy", "status": "active", "timeZone": "Europe/London" } }401No valid platform API key or admin panel session.404No such object.
/v1/platform/people/{personId}
Change a person's name, status, password, verification or time zone
Suspending signs them out everywhere. timeZone (since 1.39.0) is the IANA zone the account page shows their times in and Zappocity's messages to them use; empty: their device's (the platform's in messages). 422 for a zone the server does not know.
| Parameter | In | Type | |
|---|---|---|---|
personId * | path | string |
{
"status": "suspended"
}
200The person.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/people/{personId}
Delete a Zappocity account
Schedules the person's erasure after the platform's deletionGraceDays (now=true: at once). Erasing removes their email, name, password, profile, two-step sign-in, security keys, sessions, sign-in history, memberships, badges and invitations; the row stays as status deleted with a placeholder address, because billing, points and audit records name it. mailboxes=true also deletes the Mailocity mailboxes signed in to with it (otherwise they are unlinked and stay). Refused while the person is the only owner of an organization with other members or products.
| Parameter | In | Type | |
|---|---|---|---|
personId * | path | string | |
now | query | boolean | |
mailboxes | query | boolean |
202Scheduled; the person204Erased.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409Refused, with the reason: the only owner of an organization, or a mailbox under a legal hold or its team's last owner.
/v1/platform/people/{personId}/badges/{badgeId}
Give a badge by hand
| Parameter | In | Type | |
|---|---|---|---|
personId * | path | string | |
badgeId * | path | string |
204Given (or already had).401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409The badge is given by its rule
/v1/platform/people/{personId}/badges/{badgeId}
Take a badge given by hand
| Parameter | In | Type | |
|---|---|---|---|
personId * | path | string | |
badgeId * | path | string |
204Taken.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/people/{personId}/merge
Merge a person recorded twice into this one
For one human with two Zappocity accounts. The other's memberships (the stronger role kept where both belong), help desk seats, tickets, points (by an adjustment on each side), badges, invitations, the people it invited, its sign-in history and the mailboxes linked to it move to this one. The other is signed out, stripped as an erased account is (its email address is free again) and kept as status merged with mergedInto, so its points history stays; it is found by its customer number only. This one keeps its email, password and two-step sign-in. Neither may be merged, deleted or scheduled for deletion. Cannot be undone; recorded in the audit log with what moved. Merging again into the same person only repeats the mailboxes' move (to finish one that answered 502). Needs the tenants permission.
| Parameter | In | Type | |
|---|---|---|---|
personId * | path | string | The person to keep. |
{
"from": "3f1c2b4a-5d6e-4f70-8a91-b2c3d4e5f607"
}
200The person kept, and how many of each thing moved.{ "moved": { "memberships": 1, "points": 2, "tickets": 3 }, "person": { "email": "amy@example.com", "id": "6c1f0b7e-2d4a-4b8e-9f3c-1a2b3c4d5e6f" } }400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409One of them is merged, deleted or to be deleted; or the other is linked to a mailbox of a team this person is not on (no mailbox there, not in the team's organization): unlink it first.422from is missing or the same person.502Merged, but the mailboxes could not be moved; merge again to finish.
/v1/platform/people/{personId}/profile
A person's profile and badges
| Parameter | In | Type | |
|---|---|---|---|
personId * | path | string |
200The profile and the badges they have.401No valid platform API key or admin panel session.404No such object.
/v1/platform/people/{personId}/profile
Correct or hide a profile
The same fields as the person's own; visible false hides it.
| Parameter | In | Type | |
|---|---|---|---|
personId * | path | string |
{
"bio": "",
"visible": false
}
200The profile and badges.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409That handle is taken.422A value is out of range or names something that does not exist.
/v1/platform/people/{personId}/restore
Cancel a Zappocity account's deletion
Also cancels the deletion of the mailboxes scheduled with it.
| Parameter | In | Type | |
|---|---|---|---|
personId * | path | string |
200The person.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
Products
/v1/platform/products
The product catalog
Every product, with its plans (from the product's module) and their prices per period. A product run apart (Tether) whose plans cannot be read now is still listed, with plansError and no plans (since 1.38.0).
200The catalog.{ "currency": "USD", "items": [ { "description": "Business email on your own domains.", "key": "mailocity", "module": true, "name": "Mailocity", "plans": [ { "active": true, "code": "team", "limits": { "aliases": 100, "domains": 10, "messageBytes": 52428800, "sendPerDay": 5000, "sendPerHour": 500, "storageBytes": 32212254720, "users": 25 }, "name": "Team", "perSeat": false, "prices": { "month": 1200, "year": 12000 } } ], "serviceHost": "", "siteHost": "mailo.city", "sortOrder": 10, "status": "available", "updatedAt": "2026-10-08T12:00:00Z" } ] }401No valid platform API key or admin panel session.
/v1/platform/products/{product}
Change a product's name, description, status or site
available products are sold; coming ones are shown as on the way; retired ones take no new subscriptions. Only a product with a module here can be available.
| Parameter | In | Type | |
|---|---|---|---|
product * | path | string |
{
"serviceHost": "tether.example:443",
"siteHost": "tether.example"
}
200The product.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/products/{product}/features
The features a product's plans can include
For products whose plans Zappocity manages (Mailocity). enforced says the product acts on a feature today; the others are kept for what is planned.
| Parameter | In | Type | |
|---|---|---|---|
product * | path | string |
200The features.{ "items": [ { "description": "Read and send mail in the browser at /mail", "enforced": true, "key": "webmail", "name": "Webmail" } ] }401No valid platform API key or admin panel session.404No such object.409This product's plans are not managed here.
/v1/platform/products/{product}/plans/{plan}
Make or change a plan's limits and features
Limits by the names the catalog shows (users, domains, aliases, storageBytes, messageBytes, sendPerHour, sendPerDay, recipients, aliasesPerUser, sharedAliasesPerUser; -1 is unlimited); a limit left out stays. features replaces the plan's features (left out: they stay). A new plan needs a name and every limit. The product keeps and enforces it; prices are set with /prices. retired: true retires the plan: no new subscription can take it, and customers already on it keep it (and its price) for their renewals; retiredNote says why. Left out, a retired plan stays retired. The catalog shows retired, retiredNote and customers (how many are on each plan).
| Parameter | In | Type | |
|---|---|---|---|
product * | path | string | |
plan * | path | string |
200The plan401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409Refused (an unknown limit or feature
/v1/platform/products/{product}/plans/{plan}/prices
Set a plan's prices
In cents per period. A field left out stays; null stops selling the plan for that period. With perSeat true the prices are per seat: invoices charge them times a subscription's seats.
| Parameter | In | Type | |
|---|---|---|---|
product * | path | string | |
plan * | path | string |
{
"month": 1900,
"perSeat": true,
"year": 19000
}
200The whole catalog.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
Single sign-on
/v1/platform/sso-clients
Applications that sign in through Zappocity
Zappocity is an OpenID Connect provider: discovery at /.well-known/openid-configuration, the authorization code flow with PKCE (S256, required), RS256 ID tokens, userinfo. These are the registered clients; secrets are kept hashed. A client with orgsScope may ask for the orgs scope: its ID tokens and userinfo then carry orgs, each organization the person is in as {id, name, role}. A client with a backchannelLogoutUri is sent a logout token there (OpenID Connect Back-Channel Logout 1.0, with sid and sub) when a Zappocity session it signed in from ends (both since 1.38.0).
200The clients.{ "items": [ { "backchannelLogoutUri": "", "createdAt": "2026-10-08T12:00:00Z", "disabled": false, "id": "mailocity", "name": "Mailocity", "orgsScope": false, "redirectUris": [ "https://mailo.city/api/mail/sso/callback" ], "updatedAt": "2026-10-08T12:00:00Z" } ] }401No valid platform API key or admin panel session.
/v1/platform/sso-clients
Register an application
The secret is in the answer once.
{
"backchannelLogoutUri": "https://tether.example/auth/backchannel-logout",
"id": "tether",
"name": "Tether",
"orgsScope": true,
"redirectUris": [
"https://tether.example/auth/callback"
]
}
201The client and its secret.{ "client": { "id": "xiht", "name": "xi.ht" }, "secret": "cs_cs_Yk3" }400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/sso-clients/{clientId}
Rename, change return addresses, or disable
| Parameter | In | Type | |
|---|---|---|---|
clientId * | path | string |
{
"backchannelLogoutUri": "https://tether.example/auth/backchannel-logout"
}
200The client.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/sso-clients/{clientId}
Remove an application
| Parameter | In | Type | |
|---|---|---|---|
clientId * | path | string |
204Removed.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/sso-clients/{clientId}/secret
Give an application a new secret
The old secret stops working at once; the new one is in the answer once.
| Parameter | In | Type | |
|---|---|---|---|
clientId * | path | string |
200The client and its new secret.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
Status
/v1/platform/status
Incidents and maintenance
Every notice, newest first (the last 100); with page=true only what the account page shows: those not ended that have started or start within two weeks, and those ended in the last week.
| Parameter | In | Type | |
|---|---|---|---|
page | query | boolean |
200The notices, each with its updates, newest first.{ "items": [ { "createdAt": "2026-11-05T09:05:00Z", "createdBy": "ops@zappocity.test", "endsAt": null, "id": "7d1e2f3a-4b5c-4d6e-8f90-a1b2c3d4e5f6", "kind": "incident", "products": [ "mailocity" ], "resolvedAt": null, "severity": "major", "startsAt": "2026-11-05T09:00:00Z", "status": "identified", "title": "Slow mail delivery", "updatedAt": "2026-11-05T09:40:00Z", "updates": [ { "body": "A full disk on one relay; mail is queued, not lost.", "createdAt": "2026-11-05T09:40:00Z", "createdBy": "ops@zappocity.test", "id": 2, "status": "identified" }, { "body": "Mail is arriving late.", "createdAt": "2026-11-05T09:05:00Z", "createdBy": "ops@zappocity.test", "id": 1, "status": "investigating" } ] } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.
/v1/platform/status
Post an incident or maintenance
Customers see it on the account page at once (maintenance from two weeks before it starts), with body as its first update. Needs the Support permission; recorded in the audit log.
{
"body": "Webmail may be unavailable for up to ten minutes.",
"endsAt": "2026-11-10T03:00:00Z",
"kind": "maintenance",
"products": [
"mailocity"
],
"startsAt": "2026-11-10T02:00:00Z",
"title": "Database upgrade"
}
201The notice.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.422A value is out of range or names something that does not exist.
/v1/platform/status/{noticeId}
A notice with its updates
| Parameter | In | Type | |
|---|---|---|---|
noticeId * | path | string |
200The notice.{ "createdAt": "2026-11-05T09:05:00Z", "createdBy": "ops@zappocity.test", "endsAt": null, "id": "7d1e2f3a-4b5c-4d6e-8f90-a1b2c3d4e5f6", "kind": "incident", "products": [ "mailocity" ], "resolvedAt": null, "severity": "major", "startsAt": "2026-11-05T09:00:00Z", "status": "identified", "title": "Slow mail delivery", "updatedAt": "2026-11-05T09:40:00Z", "updates": [ { "body": "A full disk on one relay; mail is queued, not lost.", "createdAt": "2026-11-05T09:40:00Z", "createdBy": "ops@zappocity.test", "id": 2, "status": "identified" }, { "body": "Mail is arriving late.", "createdAt": "2026-11-05T09:05:00Z", "createdBy": "ops@zappocity.test", "id": 1, "status": "investigating" } ] }401No valid platform API key or admin panel session.404No such object.
/v1/platform/status/{noticeId}
Change a notice
Its title, severity, products and times; its status changes only with an update.
| Parameter | In | Type | |
|---|---|---|---|
noticeId * | path | string |
{
"products": [
"mailocity",
"zappocity"
],
"severity": "critical"
}
200The notice.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/status/{noticeId}
Remove a notice
For one posted by mistake; end real ones with an update instead. Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
noticeId * | path | string |
204Removed.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/status/{noticeId}/updates
Post an update
Moves the notice to status; the kind's last status ends it, another opens it again.
| Parameter | In | Type | |
|---|---|---|---|
noticeId * | path | string |
{
"body": "Delivery is back to normal; queued mail has been delivered.",
"status": "resolved"
}
201The notice.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
Subscriptions
/v1/platform/orgs/{orgId}/subscriptions
Subscribe an organization to a product
The product provisions it (a new Mailocity team named after the organization). With resourceRef, the product instead adopts what already exists (a Mailocity tenant id), keeping its plan unless plan names another; a resource can belong to one live subscription. If the product cannot do it, the subscription is recorded as canceled and the error returned.
| Parameter | In | Type | |
|---|---|---|---|
orgId * | path | string |
{
"period": "year",
"plan": "team",
"product": "mailocity"
}
201The subscription.{ "canceledAt": null, "createdAt": "2026-10-08T12:00:00Z", "id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d", "orgId": "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f", "orgName": "Acme", "period": "year", "plan": "team", "product": "mailocity", "resourceRef": "4f1c2d3e-1111-4a2b-9c3d-5e6f7a8b9c0d", "seats": 1, "status": "active", "updatedAt": "2026-10-08T12:00:00Z" }400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/subscriptions
Find subscriptions
| Parameter | In | Type | |
|---|---|---|---|
orgId | query | string | |
product | query | string | |
resourceRef | query | string | Which subscription a resource (a tenant id) belongs to. |
status | query | string | |
limit | query | integer |
200Newest first400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.
/v1/platform/subscriptions/{subscriptionId}
A subscription and what the product reports on it
| Parameter | In | Type | |
|---|---|---|---|
subscriptionId * | path | string |
200The subscription, and the product's summary of its resource.{ "resource": { "limits": { "users": 25 }, "name": "Acme", "plan": "team", "ref": "4f1c2d3e-1111-4a2b-9c3d-5e6f7a8b9c0d", "status": "active", "usage": { "aliases": 2, "domains": 1, "storageBytes": 1048576, "users": 3 } }, "subscription": { "id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d", "plan": "team", "product": "mailocity", "status": "active" } }401No valid platform API key or admin panel session.404No such object.
/v1/platform/subscriptions/{subscriptionId}
Change the plan, period or seats, suspend, resume or cancel
The product makes the change first, and the subscription changes only if it did (a plan the team does not fit is refused with 409). Canceling suspends the resource and unlinks it, keeping its data; a canceled subscription cannot change. A subscription of a suspended organization cannot be resumed. On a plan priced per seat, more seats are invoiced at once for the rest of the period already billed (in proportion to the time left); fewer seats take effect without a refund.
| Parameter | In | Type | |
|---|---|---|---|
subscriptionId * | path | string |
{
"seats": 5
}
200The subscription.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/subscriptions/{subscriptionId}/features
A subscription's features
Its plan's features, those switched on and off for this customer, and the result the product enforces.
| Parameter | In | Type | |
|---|---|---|---|
subscriptionId * | path | string |
200The features.{ "effective": [ "webmail", "smtp_submission", "archiving" ], "off": [ "imap" ], "on": [ "archiving" ], "plan": [ "webmail", "imap", "smtp_submission" ] }401No valid platform API key or admin panel session.404No such object.409Nothing provisioned
/v1/platform/subscriptions/{subscriptionId}/features
Switch features on or off for one subscription
Replaces the subscription's switches; each account's own switches (team admins and staff) still apply on top.
| Parameter | In | Type | |
|---|---|---|---|
subscriptionId * | path | string |
{
"off": [
"imap"
],
"on": [
"archiving"
]
}
200The features after the change.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409Refused (an unknown feature
Support
/v1/platform/support/escalations
The escalation rules
Every minute each active rule, in order, acts on each ticket that matches it and whose last reply (customer's or staff's, not a rule's) is older than afterMinutes: it moves, reprioritises, sets the status of, assigns or answers the ticket, emails staff, and leaves an internal note saying what it did. It acts on a ticket once per reply, so a ticket nobody answers is escalated once, and again only after someone replies and it waits again.
200The rules.{ "items": [ { "active": true, "afterMinutes": 60, "assigned": "any", "id": "9b2c1d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d", "name": "Urgent waiting an hour", "note": "", "notify": [ "lead@zappocity.test" ], "priorities": [ "urgent" ], "queues": [], "reply": "", "setAssignee": "lead@zappocity.test", "setPriority": null, "setQueue": null, "setStatus": null, "sortOrder": 0, "statuses": [ "open" ] } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.
/v1/platform/support/escalations
Add an escalation rule
It needs at least one action. Recorded in the audit log.
{
"afterMinutes": 60,
"name": "Urgent waiting an hour",
"note": "Urgent with no answer for an hour.",
"notify": [
"lead@zappocity.test"
],
"priorities": [
"urgent"
],
"setAssignee": "lead@zappocity.test"
}
201The rule.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/escalations/{ruleId}
Replace an escalation rule
Every field, as when adding it.
| Parameter | In | Type | |
|---|---|---|---|
ruleId * | path | string |
{
"afterMinutes": 60,
"name": "Urgent waiting an hour",
"note": "Urgent with no answer for an hour.",
"notify": [
"lead@zappocity.test"
],
"priorities": [
"urgent"
],
"setAssignee": "lead@zappocity.test"
}
200The rule.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/escalations/{ruleId}
Delete an escalation rule
| Parameter | In | Type | |
|---|---|---|---|
ruleId * | path | string |
204Deleted.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/support/follow-ups
Reminders that are due
200Oldest first.401No valid platform API key or admin panel session.
/v1/platform/support/help/articles
The help center's articles
Drafts and staff-only articles too. q searches titles and text (the best matches first, with a snippet).
| Parameter | In | Type | |
|---|---|---|---|
q | query | string | |
category | query | string | A category's slug. |
kind | query | string |
200The articles.{ "items": [ { "category": "billing", "helpful": 31, "id": "7e8f9a0b-1c2d-4e3f-8a9b-0c1d2e3f4a5b", "kind": "faq", "notHelpful": 2, "slug": "refund", "status": "published", "title": "How do I get a refund?", "views": 120, "visibility": "public" } ] }401No valid platform API key or admin panel session.404No such object.
/v1/platform/support/help/articles
Write an article or FAQ
Markdown; links may go to http(s), mail, this site's pages and anchors. visibility: public (the help center), customers (signed-in customers only) or staff. Needs the support permission.
{
"body": "Open a request under **Billing** within 30 days.",
"category": "billing",
"kind": "faq",
"slug": "refund",
"status": "published",
"title": "How do I get a refund?",
"visibility": "public"
}
201The article.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/help/articles/{articleId}
An article, with its body rendered
| Parameter | In | Type | |
|---|---|---|---|
articleId * | path | string |
200The article and its HTML: { article, html }.401No valid platform API key or admin panel session.404No such object.
/v1/platform/support/help/articles/{articleId}
Change an article
Replaces every field.
| Parameter | In | Type | |
|---|---|---|---|
articleId * | path | string |
{
"slug": "refund",
"status": "draft",
"title": "How do I get a refund?"
}
200The article.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/help/articles/{articleId}
Delete an article
| Parameter | In | Type | |
|---|---|---|---|
articleId * | path | string |
204Deleted.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/support/help/categories
The help center's categories
The knowledge base staff write for the help center (/help) and the account page: articles and FAQs, for everyone, for signed-in customers, or for staff. articles counts each category's published ones. See docs/kb/admin/help-center.md.
200The categories.{ "items": [ { "articles": 6, "description": "Invoices, cards and refunds", "id": "2d4f6a8c-1b3e-4c5d-9e7f-0a1b2c3d4e5f", "name": "Billing", "slug": "billing", "sortOrder": 0 } ] }401No valid platform API key or admin panel session.
/v1/platform/support/help/categories
Add a category
{
"description": "Invoices, cards and refunds",
"name": "Billing",
"slug": "billing"
}
201The category.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/help/categories/{categoryId}
Change a category
| Parameter | In | Type | |
|---|---|---|---|
categoryId * | path | string |
{
"name": "Billing and invoices",
"slug": "billing",
"sortOrder": 1
}
200The category.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/help/categories/{categoryId}
Delete a category
Its articles stay, without a category.
| Parameter | In | Type | |
|---|---|---|---|
categoryId * | path | string |
204Deleted.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/support/intake
Read the queues' mailboxes now
Otherwise they are read every minute. Replies go on their ticket (by In-Reply-To and References, or the [ZC-n] tag, from someone on the ticket); other mail opens a ticket when the queue takes mail from that sender. Each message is filed out of the inbox, into Tickets, Not accepted or Ignored (auto-replies, bounces, no-reply senders).
204Done.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.
/v1/platform/support/overview
The help desk's front page
Each queue the caller is an agent of (every queue for staff with every permission, the platform API key and the command line), with counts of its active tickets (open, pending, and by priority) and up to 50 of them, the most urgent first, then the longest waiting.
200The queues.{ "items": [ { "counts": { "normal": 3, "open": 3, "pending": 1, "urgent": 1 }, "queue": { "active": true, "id": "3f1c2d3e-1111-4a2b-9c3d-5e6f7a8b9c0d", "name": "Billing", "slug": "billing", "ticketPrefix": "BIL" }, "tickets": [ { "customerNumber": 17, "number": 1042, "priority": "urgent", "requesterEmail": "amy@example.com", "status": "open", "subject": "Charged twice", "tag": "BIL-1042" } ] } ] }401No valid platform API key or admin panel session.
/v1/platform/support/queues
The support queues and their settings
200The queues.{ "items": [ { "active": true, "address": "abuse@zappo.city", "addresses": [ "abuse@mailo.city" ], "allowContacts": true, "customerClose": false, "defaultPriority": "normal", "emailIntake": true, "hasMailbox": true, "id": "3f1c2d3e-1111-4a2b-9c3d-5e6f7a8b9c0d", "intakeFrom": "anyone", "name": "Abuse", "public": true, "readInEmail": true, "replyByEmail": true, "slug": "abuse", "ticketPrefix": "ABU", "webReply": true, "webReplyLogin": false } ] }401No valid platform API key or admin panel session.
/v1/platform/support/queues
Add a queue
When a support team is set up, its mailbox (slug@ the team's domain) is made with it. Settings: emailIntake (mail to the address opens tickets) from anyone or only known people (with a Zappocity account); readInEmail (notifications carry the message, or only say there is one); replyByEmail; webReply (a link to the ticket's page), webReplyLogin (that page needs signing in); allowContacts (customers and staff can add people to the replies); customerClose (customers may close their requests; off for abuse); public (offered when customers open a request); active. Needs the settings permission.
{
"customerClose": false,
"emailIntake": true,
"intakeFrom": "anyone",
"name": "Abuse",
"slug": "abuse"
}
201The queue.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/queues/{queueId}
Change a queue's settings
The slug (its address) cannot change. Turn a queue off with active false.
| Parameter | In | Type | |
|---|---|---|---|
queueId * | path | string |
{
"webReplyLogin": true
}
200The queue.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/support/queues/{queueId}
Remove a queue
A queue with tickets needs moveTo, the slug of the queue they move to; without it the answer is 409 with how many there are. The queue's other addresses stop working; its mailbox and the mail in it stay on the support team. The support queue cannot be removed: requests from webmail and imports land in it (turn it off with active false instead). Needs the settings permission.
| Parameter | In | Type | |
|---|---|---|---|
queueId * | path | string | |
moveTo | query | string | Slug of the queue the tickets move to. |
200Removed.{ "moved": 12 }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/queues/{queueId}/addresses/{address}
Let a queue take mail at another address
The address becomes an alias of the queue's mailbox, so mail to it opens and answers tickets like mail to the queue's own address, and notifications for tickets written to it go out from it. Its domain must be one of the support team's (add one with POST /v1/platform/support/settings/domains). Adding an address the queue has is a no-op. Needs the settings permission.
| Parameter | In | Type | |
|---|---|---|---|
queueId * | path | string | |
address * | path | string |
200The queue401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/queues/{queueId}/addresses/{address}
Stop a queue taking mail at one of its other addresses
The alias is removed; tickets written to it are answered from the queue's own address.
| Parameter | In | Type | |
|---|---|---|---|
queueId * | path | string | |
address * | path | string |
200The queue.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/support/queues/{queueId}/fields
A queue's custom ticket fields
The questions the queue asks when a ticket is opened, in order, inactive ones too. Customers see and answer the active ones that are not staff-only when they open a request on the account page; staff answer any of them on the ticket (fields on create and update).
| Parameter | In | Type | |
|---|---|---|---|
queueId * | path | string |
200The fields.{ "items": [ { "active": true, "help": "On your invoice, such as ZI-1042.", "id": "5d6e7f80-91a2-4b3c-8d4e-5f6a7b8c9d0e", "key": "order_number", "kind": "text", "label": "Order number", "options": [], "queueId": "2f3a4b5c-6d7e-4f80-9a1b-2c3d4e5f6a7b", "required": true, "sortOrder": 0, "staffOnly": false } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/support/queues/{queueId}/fields
Ask a question when tickets are opened
Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
queueId * | path | string |
{
"help": "On your invoice, such as ZI-1042.",
"key": "order_number",
"kind": "text",
"label": "Order number",
"required": true
}
201The field.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409The queue has a field with this key.422A value is out of range or names something that does not exist.
/v1/platform/support/queues/{queueId}/fields/{fieldId}
Replace a field's definition
Answers already given stay as they were, even when they no longer fit. Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
queueId * | path | string | |
fieldId * | path | string |
{
"help": "On your invoice, such as ZI-1042.",
"key": "order_number",
"kind": "text",
"label": "Order number",
"required": true
}
200The field.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409The queue has another field with this key.422A value is out of range or names something that does not exist.
/v1/platform/support/queues/{queueId}/fields/{fieldId}
Remove a field
Every ticket's answer to it goes too; to stop asking but keep the answers, make it inactive. Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
queueId * | path | string | |
fieldId * | path | string |
204Removed.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/support/queues/{queueId}/mailbox
Make the queue's mailbox, or give it a new token
| Parameter | In | Type | |
|---|---|---|---|
queueId * | path | string |
200The queue.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
/v1/platform/support/ratings
Customers' satisfaction ratings
The ratings requesters gave resolved and closed tickets in queues that ask (askRating) over the last days: by queue (count, average, how many of each score), in all, and the latest rated tickets (rating, ratingComment, ratedAt), only for the queues the caller sees.
| Parameter | In | Type | |
|---|---|---|---|
days | query | integer |
200The ratings.{ "average": 4.33, "count": 3, "days": 30, "queues": [ { "average": 4.33, "byScore": [ 0, 0, 1, 0, 2 ], "count": 3, "queue": "billing" } ], "recent": [ { "id": "fb9705e2-4477-4710-b10a-67f3bd68f854", "queue": "billing", "ratedAt": "2026-11-05T13:20:49Z", "rating": 5, "ratingComment": "Quick, thanks!", "status": "resolved", "subject": "Refund", "tag": "BIL-1042" } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.422days is not 1 to 365.
/v1/platform/support/replies
List canned replies and macros
Most used first. With queue, only that queue's own replies and those for every queue. placeholders lists the names a body may use.
| Parameter | In | Type | |
|---|---|---|---|
queue | query | string | A queue's slug. |
200The replies.401No valid platform API key or admin panel session.404No such object.
/v1/platform/support/replies
Save a canned reply or macro
A canned reply is text with placeholders, {{customer.name}} or {{customer.name|there}} with a fallback for an empty value: customer.name, customer.firstName, customer.email, ticket.number, ticket.tag (ZC-123), ticket.subject, queue.name, agent.name (from the staff address: jane.doe@ is Jane Doe) and agent.email. An unknown placeholder is refused. Actions make it a macro: using it also sets the ticket's status, priority, queue or assignee ("me" is whoever uses it, "" unassigns). A macro may have no body, and then only takes its actions. Needs the support permission.
{
"body": "Hi {{customer.firstName|there}},\n\nThat is fixed now. Reply to {{ticket.tag}} if it comes back.\n\n{{agent.name}}",
"name": "Fixed, closing",
"queue": "billing",
"setAssignee": "me",
"setStatus": "resolved"
}
201Saved.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/replies/{replyId}
Change a canned reply or macro
Replaces every field; actions left out are removed. Needs the support permission.
| Parameter | In | Type | |
|---|---|---|---|
replyId * | path | string |
{
"body": "Hi {{customer.firstName|there}}, could you send us a screenshot?",
"name": "Waiting on you",
"setStatus": "pending"
}
200Changed.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/replies/{replyId}
Delete a canned reply or macro
| Parameter | In | Type | |
|---|---|---|---|
replyId * | path | string |
204Deleted.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/support/settings
The support team that holds the queues' mailboxes
200The settings.{ "domain": "zappo.city", "jmapUrl": "https://mailo.city", "teamRef": "4f1c2d3e-1111-4a2b-9c3d-5e6f7a8b9c0d", "updatedAt": "2026-10-08T12:00:00Z" }401No valid platform API key or admin panel session.
/v1/platform/support/settings
Set up the support team
Makes a team on Mailocity with the domain (or adopts the team teamRef names, adding the domain), and gives every queue its mailbox (slug@domain) with a token; new queues get one when they are made. Zappocity reads the mailboxes and sends from them over JMAP at jmapUrl (Mailocity's address here when left out). Publish the domain's DNS records (MX, SPF, DKIM, DMARC) from its page under Tenants. Needs the settings permission.
{
"domain": "zappo.city"
}
200The settings.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/settings/domains
Add a mail domain to the support team
For queue addresses on it. A domain another team or a shared domain has is refused. Its DNS records are on its page under Tenants. Needs the settings permission.
{
"domain": "mailo.city"
}
204Added400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/spam-rules
The intake spam rules
Mail a queue's mailbox receives that matches a rule is filed in its Spam folder, with no ticket and no answer. A sender or domain rule also holds back replies on existing tickets; a subject or phrase rule only mail that would open a ticket. Matching uses the From address the mail claims, so a rule can only keep mail out, never let any in. hits counts the messages each rule caught.
200The rules.{ "items": [ { "createdAt": "2026-11-01T10:00:00Z", "createdBy": "lead@zappocity.test", "hits": 12, "id": "4c1e2d3f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "kind": "domain", "lastHitAt": "2026-11-05T09:30:00Z", "note": "Sends offers to every queue", "queueIds": [], "queues": [], "value": "bulk-mailer.example" } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.
/v1/platform/support/spam-rules
Add an intake spam rule
At most 1000 rules, one per kind and value. Recorded in the audit log.
{
"kind": "domain",
"note": "Sends offers to every queue",
"value": "bulk-mailer.example"
}
201The rule.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/spam-rules/{ruleId}
Replace an intake spam rule
Every field, as when adding it; its hits stay.
| Parameter | In | Type | |
|---|---|---|---|
ruleId * | path | string |
{
"kind": "domain",
"note": "Sends offers to every queue",
"value": "bulk-mailer.example"
}
200The rule.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/spam-rules/{ruleId}
Remove an intake spam rule
Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
ruleId * | path | string |
204Removed.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/support/tickets
Find tickets
Only tickets of queues the caller is an agent of.
| Parameter | In | Type | |
|---|---|---|---|
queue | query | string | |
status | query | string | |
assignee | query | string | |
q | query | string | A ticket number or tag (1042, #1042, BIL-1042: that ticket only), a customer number (C-17: that customer's tickets), or part of the subject, requester address or name. |
order | query | string | priority: the most urgent first, then the longest waiting. |
before | query | integer | Page by ticket number. |
limit | query | integer |
200Newest first.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.
/v1/platform/support/tickets
Open a ticket with a customer
The first message is from staff; the customer is told (unless notify is false) and the ticket waits on them.
201The ticket.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.422A value is out of range or names something that does not exist.
/v1/platform/support/tickets/{ticketId}
A ticket with every message, note, contact and follow-up
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string |
200The ticket.{ "contacts": [], "fields": [ { "fieldId": "5d6e7f80-91a2-4b3c-8d4e-5f6a7b8c9d0e", "key": "order_number", "kind": "text", "label": "Order number", "staffOnly": false, "updatedAt": "2026-11-05T13:20:49Z", "updatedBy": "amy@example.com", "value": "ZI-1042" } ], "followUps": [], "messages": [ { "authorKind": "customer", "body": "...", "internal": false } ], "ticket": { "number": 41, "prefix": "ABU", "status": "open", "subject": "Spam from your IP", "tag": "ABU-41" } }401No valid platform API key or admin panel session.404No such object.
/v1/platform/support/tickets/{ticketId}
Change status, priority, assignee or queue
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string |
200The ticket.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/support/tickets/{ticketId}/contacts
Add someone to the replies
Where the queue allows contacts; at most 20.
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string |
200The contacts.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.422A value is out of range or names something that does not exist.
/v1/platform/support/tickets/{ticketId}/contacts/{email}
Take someone off the replies
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string | |
email * | path | string |
204Removed.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/support/tickets/{ticketId}/follow-ups
Schedule a follow-up
A reminder shows in the due list at dueAt; a reply is sent at dueAt as its author, like any reply.
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string |
{
"body": "Check the customer's DNS is fixed",
"dueAt": "2026-10-15T09:00:00Z",
"kind": "reminder"
}
201The follow-up.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.422A value is out of range or names something that does not exist.
/v1/platform/support/tickets/{ticketId}/follow-ups/{followupId}
Mark a follow-up done, or not
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string | |
followupId * | path | integer |
204Changed.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/support/tickets/{ticketId}/merge
Merge tickets into this one
Each listed ticket (by id or number, such as SUP-12, in a queue the caller sees) is merged into this one, in order, each in one transaction: its messages, secure information, follow-ups and mail threading move here, it is closed and points here (mergedInto), and both get an internal note. Its number still leads here: a customer's reply to it by email, and opening it on the account page, land on this ticket. Its contacts are not moved. Only tickets of the same requester (the same address, and not two different accounts) are merged, so nobody sees another person's conversation. Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string |
{
"tickets": [
"SUP-12",
"1043"
]
}
200This ticket, after the merges.{ "id": "fb9705e2-4477-4710-b10a-67f3bd68f854", "mergedInto": null, "status": "open", "subject": "Can't sign in", "tag": "SUP-11" }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409Another requester's ticket422No such ticket
/v1/platform/support/tickets/{ticketId}/messages
Reply, or add an internal note
A reply tells the requester and contacts the way the queue says, and sets the ticket pending unless status says otherwise. An internal note is seen by staff only; staff named in mentions (their email addresses, as the panel's @ picker sends them) are emailed that they were mentioned. Addresses that are not staff are ignored.
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string |
201The message.400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409Someone else wrote since afterMessage (newer lists it); send again with force, or after reading it.422A value is out of range or names something that does not exist.
/v1/platform/support/tickets/{ticketId}/presence
Say you have the ticket open; see who else has
The ticket page sends this every 15 seconds while it is open (replying true while a reply or note is being written) and shows who else is on the ticket: other staff seen in the last 45 seconds, whether they are replying, and since when. A body is optional.
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string |
200The others on the ticket.{ "items": [ { "replying": true, "seenAt": "2026-11-05T13:21:04Z", "since": "2026-11-05T13:20:49Z", "who": "lead@zappocity.test" } ] }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/support/tickets/{ticketId}/presence
Say you closed the ticket
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string |
204Gone.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.
/v1/platform/support/tickets/{ticketId}/replies/{replyId}
Fill in a canned reply for a ticket
The body with its placeholders filled in, to edit before sending. Nothing changes.
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string | |
replyId * | path | string |
200The reply and its text for this ticket.{ "body": "Hi Ana,\n\nThat is fixed now. Reply to ZC-1042 if it comes back.\n\nJane Doe" }401No valid platform API key or admin panel session.404No such object.409The reply is for another queue.
/v1/platform/support/tickets/{ticketId}/replies/{replyId}
Use a canned reply or macro on a ticket
Takes the macro's actions (priority, queue, assignee), then sends the body filled in, or the edited text given, as a reply to the customer or an internal note, setting the status (a reply without a status sets the ticket pending, as any reply does). A macro without a body only takes its actions. Needs the support permission.
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string | |
replyId * | path | string |
{}
200The ticket as it is now, and the message sent (null when there was no body).400The body is not JSON with only the documented fields, or a query parameter is out of range.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409The reply is for another queue.422A value is out of range or names something that does not exist.
/v1/platform/support/tickets/{ticketId}/secrets/{secretId}
Delete a reply's secure data
The data is cleared for good; the reply still says it had secure data and who deleted it.
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string | |
secretId * | path | string |
204Deleted.401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.410Already deleted.
/v1/platform/support/tickets/{ticketId}/secrets/{secretId}/view
Show a reply's secure data
While it is held: before the customer has viewed it, and afterwards when the reply kept it. This is not the customer's view and does not use it up. Each view is recorded in the audit log (without the data). Answers carry Cache-Control no-store.
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string | |
secretId * | path | string |
200The data.{ "data": "c0rrect-h0rse", "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7" }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.410Deleted (by staff
/v1/platform/support/tickets/{ticketId}/split
Split messages into a new ticket
Moves the listed messages (and their secure information) to a new ticket with this ticket's requester, account, queue, priority and assignee and the subject given, in one transaction; at least one message stays. Both get an internal note. Nobody is emailed. Recorded in the audit log.
| Parameter | In | Type | |
|---|---|---|---|
ticketId * | path | string |
{
"messages": [
4211,
4215
],
"subject": "Invoice address"
}
201The new ticket.{ "id": "0c5b6f7e-2a41-4f3c-9d8e-1b2a3c4d5e6f", "status": "open", "subject": "Invoice address", "tag": "SUP-1044" }401No valid platform API key or admin panel session.403The staff account lacks the permission, or the caller is a team.404No such object.409Every message would move422A message that is not this ticket's