Zappocity platform API

version 2.28.0 OpenAPI spec (YAML)

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

GET /v1/platform/audit

Every change, with who made it

ParameterInType
subjectquerystringorg:<id>, person:<id>, product:<key> or subscription:<id>.
beforequeryintegerPage by entry id.
limitqueryinteger
Responses
  • 200 Newest 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"
        }
      ]
    }
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.

Billing

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

Responses
  • 200 The 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"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
PATCH /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.

Request
{
  "accent": "#0f766e",
  "companyName": "Zappocity Inc.",
  "dateFormat": "us",
  "footer": "Thank you for your business.",
  "paper": "letter"
}
Responses
  • 200 The settings.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
POST /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.

Request
{
  "kind": "quote",
  "settings": {
    "accent": "#0f766e",
    "quoteTitle": "Quote"
  }
}
Responses
  • 200 The PDF file, shown in the browser (Content-Disposition inline, named after its number).
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
GET /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, those exempt (dunningExempt) and those on hold (dunningHoldUntil, until it passes) are left out. Each step happens at most once per invoice. A VPS server is suspended in stages: at suspendDays it loses its network (its NIC's link is taken down, the VM left running), at vpsShutdownDays it is shut down (0: at suspension) and at vpsRemoveDays its VM is stopped, destroyed and unlinked, backups and addresses kept (0: never); the payers are mailed at each. Restoring brings the network back; a removed server comes back without a VM.

Responses
  • 200 The 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",
      "vpsRemoveDays": 30,
      "vpsShutdownDays": 17
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
PATCH /v1/platform/billing/dunning

Change what happens to unpaid invoices

Fields left out stay. In order: suspendDays, vpsShutdownDays (suspendDays or later), vpsRemoveDays (after both), cancelDays (after suspension and removal); the VPS steps need suspendDays. Recorded in the audit log.

Request
{
  "suspendDays": 1,
  "vpsRemoveDays": 5,
  "vpsShutdownDays": 3
}
Responses
  • 200 The settings.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
POST /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.

Responses
  • 200 How many invoices were issued, and what dunning did.
    {
      "dunning": {
        "canceled": 0,
        "lateFees": 0,
        "notices": 1,
        "reminders": 2,
        "restored": 0,
        "suspended": 1
      },
      "invoices": 3
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
GET /v1/platform/coupons

Coupons, newest first

ParameterInType
qquerystringCodes containing this.
Responses
  • 200 The 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"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /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.

Responses
  • 201 The coupon.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 A coupon with this code already exists.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/coupons/{couponId}

A coupon

ParameterInType
couponId *pathstring
Responses
  • 200 The coupon.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
PATCH /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.

ParameterInType
couponId *pathstring
Request
{
  "active": false
}
Responses
  • 200 The coupon.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/coupons/{couponId}

Delete a coupon nobody has redeemed

ParameterInType
couponId *pathstring
Responses
  • 204 Deleted.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 Someone has redeemed it; switch it off instead.
GET /v1/platform/coupons/{couponId}/redemptions

Who redeemed a coupon

ParameterInType
couponId *pathstring
Responses
  • 200 The 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"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /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.

ParameterInType
openquerystringOnly those not closed.
orgIdquerystring
Responses
  • 200 The 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
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
GET /v1/platform/invoices

Invoices of every organization

ParameterInType
statusquerystring
Responses
  • 200 Newest first
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
GET /v1/platform/invoices/{invoiceId}

An invoice with its lines

With its credit notes (creditNotes), and how much of its charge they took back (creditedCents).

ParameterInType
invoiceId *pathstring
Responses
  • 200 The 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
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /v1/platform/invoices/{invoiceId}/pay

Record a payment made outside the payment provider

ParameterInType
invoiceId *pathstring
Responses
  • 200 The invoice
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The invoice is not open.
  • 422 A value is out of range or names something that does not exist.
GET /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.

ParameterInType
invoiceId *pathstring
Responses
  • 200 The PDF file, shown in the browser (Content-Disposition inline, named after its number).
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /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.

ParameterInType
invoiceId *pathstring
Responses
  • 200 The invoice
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The invoice is not open.
GET /v1/platform/loyalty

The loyalty and referral program's rules

Responses
  • 200 The 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
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
PATCH /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.

Request
{
  "enabled": true,
  "inviteCostPoints": 100,
  "referralPoints": 1000
}
Responses
  • 200 The rules.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
POST /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.

ParameterInType
orgId *pathstring
Responses
  • 200 The invoices paid.
    {
      "items": [
        {
          "creditCents": 9500,
          "number": 7,
          "paymentMethod": "credit",
          "status": "paid",
          "totalCents": 0
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 An invoice listed is not one of the organization's open invoices.
GET /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.

ParameterInType
orgId *pathstring
Responses
  • 200 Newest 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"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /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.

ParameterInType
orgId *pathstring
Request
{
  "amountCents": 7500,
  "description": "Mailbox migration, 3 hours",
  "invoiceNow": true,
  "quantity": 3
}
Responses
  • 201 The 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"
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
  • 502 Added
PATCH /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.

ParameterInType
orgId *pathstring
itemId *pathstring
Request
{
  "status": "canceled"
}
Responses
  • 200 The item.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The item is done or canceled.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/orgs/{orgId}/billable-items/{itemId}

Delete a billable item never invoiced

ParameterInType
orgId *pathstring
itemId *pathstring
Responses
  • 204 Deleted.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It has been invoiced; cancel it instead.
GET /v1/platform/orgs/{orgId}/billing

An organization's billing and balance

A positive balance is credit in its favour; a negative one is owed.

ParameterInType
orgId *pathstring
Responses
  • 200 The 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"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
PATCH /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.

ParameterInType
orgId *pathstring
Responses
  • 200 The profile.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
GET /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.

ParameterInType
orgId *pathstring
Responses
  • 200 The redemptions.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /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.

ParameterInType
orgId *pathstring
Request
{
  "code": "WELCOME20"
}
Responses
  • 201 The redemption.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such organization or code.
  • 409 Refused, 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.
DELETE /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.

ParameterInType
orgId *pathstring
redemptionId *pathstring
Responses
  • 200 The ended redemption.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It has already ended.
GET /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).

ParameterInType
orgId *pathstring
Responses
  • 200 The 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"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /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.

ParameterInType
orgId *pathstring
creditNoteId *pathstring
Responses
  • 200 The PDF file, shown in the browser (Content-Disposition inline, named after its number).
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /v1/platform/orgs/{orgId}/invoices

An organization's invoices

ParameterInType
orgId *pathstring
statusquerystring
Responses
  • 200 Newest first.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /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.

ParameterInType
orgId *pathstring
invoiceId *pathstring
Request
{
  "amountCents": 2500,
  "kind": "credit",
  "reason": "Outage on 3 November"
}
Responses
  • 201 The 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"
    }
  • 204 The refund was recorded; the charge had been credited already.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The invoice is not paid, a refund of one paid by card or credit, or more than is left.
  • 422 A value is out of range or names something that does not exist.
GET /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.

ParameterInType
orgId *pathstring
beforequeryinteger
limitqueryinteger
Responses
  • 200 The 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"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /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.

ParameterInType
orgId *pathstring
Request
{
  "amountCents": 2500,
  "description": "Sorry for the outage",
  "kind": "credit",
  "reference": "ZC-41"
}
Responses
  • 201 The entry.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
GET /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.

ParameterInType
orgId *pathstring
Responses
  • 200 Newest first.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /v1/platform/orgs/{orgId}/quotes

Write a draft quote

Recorded in the audit log.

ParameterInType
orgId *pathstring
Request
{
  "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"
}
Responses
  • 201 The 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
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/orgs/{orgId}/quotes/{quoteId}

A quote with its lines

ParameterInType
orgId *pathstring
quoteId *pathstring
Responses
  • 200 The quote.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
PUT /v1/platform/orgs/{orgId}/quotes/{quoteId}

Rewrite a draft quote

Every field, as when writing it. Recorded in the audit log.

ParameterInType
orgId *pathstring
quoteId *pathstring
Request
{
  "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"
}
Responses
  • 200 The draft.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It is no longer a draft.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/orgs/{orgId}/quotes/{quoteId}

Delete a draft quote

ParameterInType
orgId *pathstring
quoteId *pathstring
Responses
  • 204 Deleted.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It has been sent; cancel it instead.
POST /v1/platform/orgs/{orgId}/quotes/{quoteId}/cancel

Withdraw a sent quote

It can no longer be accepted. Recorded in the audit log.

ParameterInType
orgId *pathstring
quoteId *pathstring
Responses
  • 200 The quote
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It is not a sent quote waiting for an answer.
GET /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.

ParameterInType
orgId *pathstring
quoteId *pathstring
Responses
  • 200 The PDF file, shown in the browser (Content-Disposition inline, named after its number).
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /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.

ParameterInType
orgId *pathstring
quoteId *pathstring
Responses
  • 200 The quote
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 Not a draft
GET /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. Test mode: a second key pair (testSecretKey, testWebhookSecret) from Stripe's test mode, and mode says which pair is used. testing is true when the key in use is a Stripe test key: no real money moves, payments are recorded as "Stripe (test mode)", and customers see a test mode notice. Each organization's Stripe customer and saved method are kept per mode, so a test card is never charged with the live key. Webhook events are checked against both signing secrets; the one that signed an event must match its livemode, and test events are ignored while the live keys are in use.

Responses
  • 200 The settings.
    {
      "fundsEnabled": true,
      "fundsMaxBalanceCents": 500000,
      "fundsMaxCents": 100000,
      "fundsMinCents": 1000,
      "hasSecretKey": true,
      "hasTestSecretKey": true,
      "hasTestWebhookSecret": true,
      "hasWebhookSecret": true,
      "mode": "live",
      "provider": "stripe",
      "taxCode": "txcd_10103001",
      "taxEnabled": true,
      "testing": false,
      "updatedAt": "2026-10-08T12:00:00Z"
    }
  • 401 No valid platform API key or admin panel session.
PATCH /v1/platform/payments

Set the payment provider and its keys

Responses
  • 200 The settings.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
POST /v1/platform/payments/test

Check the key works

Responses
  • 200 Whether Stripe took the key, and why not.
    {
      "error": "stripe: Invalid API Key provided (401 )",
      "ok": false
    }
  • 401 No valid platform API key or admin panel session.
GET /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.

ParameterInType
personId *pathstring
beforequeryinteger
limitqueryinteger
Responses
  • 200 The 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
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /v1/platform/people/{personId}/points

Give or take points by hand

ParameterInType
personId *pathstring
Request
{
  "description": "Thanks for the bug report",
  "points": 250
}
Responses
  • 201 The entry.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It would take more points than the person has.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/quotes

Quotes of every organization

ParameterInType
statusquerystring
Responses
  • 200 Newest 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
        }
      ]
    }
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.

DNS

GET /v1/platform/dns/accounts

List every DNS account

Every dnso.city DNS account, newest first, with its organization's name and its zones' names. q finds accounts whose name, organization's name or a zone's name contains it. A DNS account is made by a dnso subscription (POST /v1/platform/orgs/{orgId}/subscriptions with product dnso), which staff may make while dnso.city is still coming soon.

ParameterInType
qquerystring
limitqueryinteger
Responses
  • 200 The accounts.
    {
      "items": [
        {
          "createdAt": "2026-11-07T10:00:00Z",
          "featuresOff": [],
          "featuresOn": [],
          "id": "6f1c2e8a-3b5d-4c7e-9f10-2a3b4c5d6e7f",
          "name": "Zappocity",
          "orgId": "1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b",
          "orgName": "Zappocity",
          "plan": "free",
          "status": "active",
          "subscriptionId": "2c3d4e5f-6071-4829-9bac-1d2e3f4a5b6c",
          "updatedAt": "2026-11-07T10:00:00Z",
          "zones": [
            "mailo.city",
            "zappo.city"
          ]
        }
      ]
    }
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
GET /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.

ParameterInType
accountId *pathstring
Responses
  • 200 The zones.
    {
      "account": {
        "id": "1a2b3c4d-5e6f-4708-9a1b-2c3d4e5f6a7b",
        "name": "Acme",
        "plan": "personal",
        "status": "active"
      },
      "items": [
        {
          "id": "2b3c4d5e-6f70-4182-93a4-b5c6d7e8f901",
          "name": "example.com",
          "records": 2,
          "serial": 2026100901,
          "status": "active"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 404 No such DNS account
POST /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.

ParameterInType
accountId *pathstring
Responses
  • 201 The zone.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones).
  • 404 No such DNS account
  • 409 The zone is served already, or the plan's limit is reached.
  • 422 The record or zone is not valid
GET /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.

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

Stop serving a zone

Its records go with it.

ParameterInType
accountId *pathstring
zoneId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones).
  • 404 No such DNS account
  • 409 The zone is served already, or the plan's limit is reached.
  • 422 The record or zone is not valid
GET /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. When the zone is linked to a provider, linked names it: its name servers answer for the domain with its own DNSSEC key, so elsewhere and a mismatched DS record are expected then.

ParameterInType
accountId *pathstring
zoneId *pathstring
Responses
  • 200 The parent's answer.
    {
      "delegation": {
        "checkedAt": "2026-10-09T12:00:00Z",
        "ds": "off",
        "missing": [
          "ns2.dnso.city"
        ],
        "nameServers": [
          "ns1.dnso.city",
          "ns1.oldhost.net"
        ],
        "parent": "com",
        "status": "partly"
      },
      "expected": [
        "ns1.dnso.city",
        "ns2.dnso.city"
      ],
      "zone": "example.com"
    }
  • 401 No valid platform API key or admin panel session.
  • 404 No such DNS account or zone here.
  • 503 Delegation is not checked by this server.
PUT /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.

ParameterInType
accountId *pathstring
zoneId *pathstring
Request
{
  "addresses": [
    "192.0.2.53"
  ],
  "key": {
    "algorithm": "hmac-sha256",
    "name": "transfer.example.org",
    "secret": "c2VjcmV0LXNlY3JldC1zZWNyZXQtc2VjcmV0LTAxMjM="
  }
}
Responses
  • 200 The zone
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones).
  • 404 No such DNS account or zone here.
  • 409 The zone is not a secondary.
  • 422 An address or the key is not valid.
GET /v1/platform/dns/accounts/{accountId}/zones/{zoneId}/push

The provider the zone is pushed to

dnso.city keeps every zone; a zone whose name servers are another provider's (Route53, Cloudflare, DigitalOcean, Gandi, GoDaddy, Hetzner, Linode, Namecheap, OVHcloud, Porkbun, Spaceship, Vultr) can be linked to it, and dnso.city then pushes every change there through the provider's API, a moment after it is made, and again every hour. providers lists them with the fields each needs. Secrets are never shown; secretsSet names those kept. unmanaged are the provider's records dnso.city leaves alone, as last read.

ParameterInType
accountId *pathstring
zoneId *pathstring
Responses
  • 200 Whether and where the zone is pushed, and how the last push went.
    {
      "changes": 3,
      "error": "",
      "inStep": true,
      "linked": true,
      "mode": "authoritative",
      "offered": true,
      "provider": "cloudflare",
      "pushedAt": "2026-11-07T12:00:00Z",
      "pushedSerial": 12,
      "secretsSet": [
        "apiToken"
      ],
      "settings": {},
      "unmanaged": [
        {
          "data": "1 . alpn=h2",
          "name": "@",
          "ttl": 300,
          "type": "HTTPS"
        }
      ],
      "updatedAt": "2026-11-07T11:59:00Z"
    }
  • 401 No valid platform API key or admin panel session.
  • 404 No such DNS account or zone here.
PUT /v1/platform/dns/accounts/{accountId}/zones/{zoneId}/push

Link the zone to the provider whose name servers answer for it

The credentials are checked by reading the zone at the provider; nothing is changed when that fails. mode authoritative (the default) makes the provider's copy exactly dnso.city's, removing and overwriting what dnso.city does not have, after first importing the provider's records dnso.city lacks (import, default true); managed touches only the record sets dnso.city has (and removes there only those it pushed before), listing the provider's others as unmanaged. Either way the provider's SOA, its name servers at the zone's top, and record types dnso.city cannot hold are left alone. Credentials not given again are kept when the provider is the same. Once linked, the zone's DNSSEC is the provider's: dnso.city's DS is no longer offered. The zone is pushed at once; a failure comes back as warning.

ParameterInType
accountId *pathstring
zoneId *pathstring
Request
{
  "credentials": {
    "accessKeyId": "AKIAIOSFODNN7EXAMPLE",
    "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
  },
  "mode": "authoritative",
  "provider": "route53"
}
Responses
  • 200 The link, with `imported` (records added to dnso.city), `leftAtProvider` and any `warning`.
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones).
  • 404 No such DNS account or zone here
  • 409 The provider's records would pass the plan's limit.
  • 422 Not a provider, missing or malformed credentials, a secondary zone, or the provider could not be read with them.
DELETE /v1/platform/dns/accounts/{accountId}/zones/{zoneId}/push

Stop pushing the zone

The records at the provider stay as they are. dnso.city's DNSSEC is offered again.

ParameterInType
accountId *pathstring
zoneId *pathstring
Responses
  • 204 Unlinked.
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones).
  • 404 No such DNS account or zone here
POST /v1/platform/dns/accounts/{accountId}/zones/{zoneId}/push/adopt

Bring the provider's unmanaged records into dnso.city

Reads the zone at the provider and adds its record sets dnso.city lacks (all, or those named in sets as "name TYPE"), so dnso.city manages them from then on. Types dnso.city cannot hold stay at the provider (leftAtProvider).

ParameterInType
accountId *pathstring
zoneId *pathstring
Request
{
  "sets": [
    "www A",
    "@ MX"
  ]
}
Responses
  • 200 The link, with `adopted` (records added).
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones).
  • 404 No such DNS account or zone here
  • 409 The records would pass the plan's limit.
  • 422 The provider could not be read.
POST /v1/platform/dns/accounts/{accountId}/zones/{zoneId}/push/run

Push the zone now

Pushes it now (unless a push of it is running), putting back anything changed at the provider. A failure comes back as warning.

ParameterInType
accountId *pathstring
zoneId *pathstring
Responses
  • 200 The link and how the push went.
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones).
  • 404 No such DNS account or zone here
POST /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.

ParameterInType
accountId *pathstring
zoneId *pathstring
Request
{
  "data": "10 mail.example.com.",
  "name": "@",
  "ttl": 3600,
  "type": "MX"
}
Responses
  • 201 The zone and its records.
    {
      "dnssec": {
        "algorithm": 13,
        "ds": "24680 13 2 9A1B...",
        "dsRecord": "example.com.\t3600\tIN\tDS\t24680 13 2 9A1B...",
        "keyTag": 24680,
        "ready": true
      },
      "records": [
        {
          "data": "10 mail.example.com.",
          "id": "3c4d5e6f-7081-4293-a4b5-c6d7e8f90123",
          "name": "@",
          "ttl": 3600,
          "type": "MX"
        }
      ],
      "zone": {
        "accountId": "1a2b3c4d-5e6f-4708-9a1b-2c3d4e5f6a7b",
        "id": "2b3c4d5e-6f70-4182-93a4-b5c6d7e8f901",
        "name": "example.com",
        "records": 2,
        "serial": 2026100901,
        "status": "active"
      }
    }
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones).
  • 404 No such DNS account
  • 409 The zone is served already, or the plan's limit is reached.
  • 422 The record or zone is not valid
PUT /v1/platform/dns/accounts/{accountId}/zones/{zoneId}/records/{recordId}

Replace a record

ParameterInType
accountId *pathstring
zoneId *pathstring
recordId *pathstring
Request
{
  "data": "10 mail.example.com.",
  "name": "@",
  "ttl": 3600,
  "type": "MX"
}
Responses
  • 200 The zone and its records.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones).
  • 404 No such DNS account
  • 409 The zone is served already, or the plan's limit is reached.
  • 422 The record or zone is not valid
DELETE /v1/platform/dns/accounts/{accountId}/zones/{zoneId}/records/{recordId}

Remove a record

ParameterInType
accountId *pathstring
zoneId *pathstring
recordId *pathstring
Responses
  • 200 The zone and its records.
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones).
  • 404 No such DNS account
  • 409 The zone is served already, or the plan's limit is reached.
  • 422 The record or zone is not valid
POST /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.

ParameterInType
accountId *pathstring
zoneId *pathstring
Responses
  • 200 The zone, its records and its secondary state.
    {
      "dnssec": {
        "ready": false,
        "secondary": true
      },
      "records": [
        {
          "data": "192.0.2.7",
          "name": "www",
          "ttl": 300,
          "type": "A"
        }
      ],
      "secondary": {
        "expire": 1209600,
        "keyName": "transfer.example.org.",
        "lastCheck": "2026-10-09T12:00:00Z",
        "lastError": "",
        "lastOk": "2026-10-09T12:00:00Z",
        "lastTransfer": "2026-10-09T12:00:00Z",
        "nextCheck": "2026-10-09T13:00:00Z",
        "primaries": [
          "192.0.2.53"
        ],
        "primarySerial": 7,
        "records": 3,
        "refresh": 3600,
        "retry": 600
      },
      "transfers": {
        "addresses": [],
        "key": null
      },
      "zone": {
        "id": "2b3c4d5e-6f70-4182-93a4-b5c6d7e8f901",
        "kind": "secondary",
        "name": "example.org",
        "records": 3,
        "serial": 7,
        "status": "active"
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones).
  • 404 No such DNS account or zone here.
  • 409 The zone is not a secondary.
PUT /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.

ParameterInType
accountId *pathstring
zoneId *pathstring
Request
{
  "addresses": [
    "192.0.2.53",
    "2001:db8::53"
  ]
}
Responses
  • 200 The zone's transfers; `key.secret` only when the key was just made.
    {
      "addresses": [
        "192.0.2.53",
        "2001:db8::53"
      ],
      "key": {
        "algorithm": "hmac-sha256",
        "createdAt": "2026-10-09T12:00:00Z",
        "name": "xfr.example.com.",
        "secret": "q2Lw...base64...="
      }
    }
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones), or the plan does not include secondary DNS.
  • 404 No such DNS account or zone here.
  • 422 An address is not valid, not on the internet, or there are more than 10.
POST /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.

ParameterInType
accountId *pathstring
zoneId *pathstring
Responses
  • 200 The zone's transfers with the new key's secret.
    {
      "addresses": [
        "192.0.2.53"
      ],
      "key": {
        "algorithm": "hmac-sha256",
        "createdAt": "2026-10-09T12:00:00Z",
        "name": "xfr.example.com.",
        "secret": "b7Rk...base64...="
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 403 Not allowed (staff need the tenants permission to change zones), or the plan does not include secondary DNS.
  • 404 No such DNS account or zone here.
POST /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.

Request
{
  "dryRun": true,
  "moves": [
    {
      "from": "203.0.113.17",
      "to": "198.51.100.10"
    }
  ],
  "step": "add"
}
Responses
  • 200 The 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"
        }
      ]
    }
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
GET /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). mailZones are the zones the mail server may write its outbound addresses' names (A, AAAA) and PTR records in, when its IP settings say namesOn dnso; it can read or change nothing else.

Responses
  • 200 The settings.
    {
      "hostmaster": "hostmaster@dnso.city",
      "mailZones": [
        "mailo.city",
        "113.0.203.in-addr.arpa"
      ],
      "nameServers": [
        "ns1.dnso.city",
        "ns2.dnso.city"
      ],
      "updatedAt": "2026-10-09T12:00:00Z"
    }
  • 401 No valid platform API key or admin panel session.
PATCH /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). Each mail zone must be a primary zone on dnso.city (up to 50). Recorded in the audit log.

Request
{
  "hostmaster": "hostmaster@dnso.city",
  "mailZones": [
    "mailo.city",
    "113.0.203.in-addr.arpa"
  ],
  "nameServers": [
    "ns1.dnso.city",
    "ns2.dnso.city"
  ]
}
Responses
  • 200 The settings.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.

Jobs

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

Responses
  • 200 The 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"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
PATCH /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.

ParameterInType
name *pathstring
Request
{
  "enabled": false
}
Responses
  • 200 The job.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
POST /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.

ParameterInType
name *pathstring
Responses
  • 200 Run here (no scheduler running); its outcome.
    {
      "durationMs": 41,
      "error": "",
      "finished": true,
      "ok": true,
      "result": "0 invoices made",
      "started": true
    }
  • 202 Started.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It is running now.

Mail

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

Responses
  • 200 The 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"
    }
  • 401 No valid platform API key or admin panel session.
PATCH /v1/platform/mail

Change how Zappocity sends its own mail

Request
{
  "apiToken": "cs_mt_Yk3",
  "apiUrl": "https://mailo.city",
  "mode": "jmap"
}
Responses
  • 200 The settings.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
POST /v1/platform/mail/test

Send a test message the configured way

Responses
  • 200 Whether it went, and the error if not.
    {
      "ok": true
    }
  • 401 No valid platform API key or admin panel session.
  • 422 A value is out of range or names something that does not exist.

Organizations

GET /v1/platform/orgs

Find organizations

ParameterInType
qquerystringPart of the name
limitqueryinteger
Responses
  • 200 By 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"
        }
      ]
    }
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
POST /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.

Request
{
  "name": "Acme",
  "owner": "amy@example.com"
}
Responses
  • 201 The organization.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
GET /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.

ParameterInType
orgId *pathstring
Responses
  • 200 The 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": []
    }
  • 401 No valid platform API key or admin panel session.
  • 404 No such object.
PATCH /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.

ParameterInType
orgId *pathstring
Request
{
  "status": "suspended"
}
Responses
  • 200 The organization.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
DELETE /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.

ParameterInType
orgId *pathstring
invitationId *pathstring
Responses
  • 204 Withdrawn.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
PUT /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.

ParameterInType
orgId *pathstring
personId *pathstring
Request
{
  "role": "billing"
}
Responses
  • 200 The members.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
DELETE /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.

ParameterInType
orgId *pathstring
personId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
POST /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.

ParameterInType
orgId *pathstringThe organization to keep.
Request
{
  "from": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d"
}
Responses
  • 200 The 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"
      }
    }
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 One of them is merged into another organization already.
  • 422 from is missing or the same organization.
  • 502 Merged, but the teams could not be moved; merge again to finish.

People

GET /v1/platform/badges

Badges, with the icons and colours they can have

Responses
  • 200 The 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
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /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.

Request
{
  "color": "warning",
  "description": "Reported a security bug",
  "icon": "bolt",
  "key": "bug-hunter",
  "name": "Bug hunter"
}
Responses
  • 201 The badge.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 A badge with this key exists.
  • 422 A value is out of range or names something that does not exist.
PATCH /v1/platform/badges/{badgeId}

Change a badge

Everything but key and rule. joinedBefore null removes the date; plan "" removes the plan.

ParameterInType
badgeId *pathstring
Request
{
  "active": true,
  "joinedBefore": "2027-06-01"
}
Responses
  • 200 The badge.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/badges/{badgeId}

Delete a badge (and take it from everyone)

ParameterInType
badgeId *pathstring
Responses
  • 204 Deleted.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /v1/platform/people

Find people

ParameterInType
qquerystringPart of an email or name.
limitqueryinteger
Responses
  • 200 By 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"
        }
      ]
    }
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
POST /v1/platform/people

Add a person

A Zappocity account. Without a password, they can set one later.

Request
{
  "email": "amy@example.com",
  "name": "Amy",
  "verified": true
}
Responses
  • 201 The person.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/people/{personId}

A person and the organizations they belong to

ParameterInType
personId *pathstring
Responses
  • 200 The 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"
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 404 No such object.
PATCH /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.

ParameterInType
personId *pathstring
Request
{
  "status": "suspended"
}
Responses
  • 200 The person.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
DELETE /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.

ParameterInType
personId *pathstring
nowqueryboolean
mailboxesqueryboolean
Responses
  • 202 Scheduled; the person
  • 204 Erased.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 Refused, with the reason: the only owner of an organization, or a mailbox under a legal hold or its team's last owner.
PUT /v1/platform/people/{personId}/badges/{badgeId}

Give a badge by hand

ParameterInType
personId *pathstring
badgeId *pathstring
Responses
  • 204 Given (or already had).
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The badge is given by its rule
DELETE /v1/platform/people/{personId}/badges/{badgeId}

Take a badge given by hand

ParameterInType
personId *pathstring
badgeId *pathstring
Responses
  • 204 Taken.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /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.

ParameterInType
personId *pathstringThe person to keep.
Request
{
  "from": "3f1c2b4a-5d6e-4f70-8a91-b2c3d4e5f607"
}
Responses
  • 200 The 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"
      }
    }
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 One 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.
  • 422 from is missing or the same person.
  • 502 Merged, but the mailboxes could not be moved; merge again to finish.
GET /v1/platform/people/{personId}/profile

A person's profile and badges

ParameterInType
personId *pathstring
Responses
  • 200 The profile and the badges they have.
  • 401 No valid platform API key or admin panel session.
  • 404 No such object.
PATCH /v1/platform/people/{personId}/profile

Correct or hide a profile

The same fields as the person's own; visible false hides it.

ParameterInType
personId *pathstring
Request
{
  "bio": "",
  "visible": false
}
Responses
  • 200 The profile and badges.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 That handle is taken.
  • 422 A value is out of range or names something that does not exist.
POST /v1/platform/people/{personId}/restore

Cancel a Zappocity account's deletion

Also cancels the deletion of the mailboxes scheduled with it.

ParameterInType
personId *pathstring
Responses
  • 200 The person.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /v1/platform/people/{personId}/ssh-keys

A person's SSH keys

The public keys that sign them in to Hatch, with when each was last used.

ParameterInType
personId *pathstring
Responses
  • 200 The keys.
    {
      "items": [
        {
          "createdAt": "2026-11-08T12:00:00Z",
          "fingerprint": "SHA256:Hq3Y0exampleexampleexampleexampleexample0",
          "id": "4d5e6f70-8192-4a3b-9c4d-5e6f708192a3",
          "lastUsedAt": null,
          "name": "amy@laptop",
          "publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyOnly"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
DELETE /v1/platform/people/{personId}/ssh-keys/{keyId}

Remove a person's SSH key

For a key that leaked. Recorded in the audit log.

ParameterInType
personId *pathstring
keyId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /v1/platform/people/{personId}/verify/resend

Send a person a new confirmation link

For someone who has not confirmed their email address and lost or never got the link. Mails the same link they can ask for themselves on their account page, valid for two days, to the address the account already has; the request takes no body and nothing in it can send the link elsewhere. Counts toward the same limit as their own requests (five links an hour). Recorded in the audit log. Needs the tenants permission.

ParameterInType
personId *pathstring
Responses
  • 202 The link is on its way.
    {
      "detail": "A new link is on its way to amy@example.com."
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The address is already confirmed
  • 429 Five links went to this address in the last hour.
  • 503 This process does not send mail (the command line without the server).

Products

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

Responses
  • 200 The 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"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
PATCH /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.

ParameterInType
product *pathstring
Request
{
  "serviceHost": "tether.example:443",
  "siteHost": "tether.example"
}
Responses
  • 200 The product.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/products/{product}/features

The features a product's plans can include

For products whose plans Zappocity manages (Mailocity, VPS, dnso.city, supporto.city). enforced says the product acts on a feature today; the others are kept for what is planned. limits, for a product that names them (VPS, dnso.city, supporto.city), are the limits its plans set, as PUT .../plans/{plan} takes them: required ones a new plan needs, and -1 for unlimited where unlimited is true.

ParameterInType
product *pathstring
Responses
  • 200 The features, and the product's plan limits.
    {
      "items": [
        {
          "description": "The server's text console in the browser.",
          "enforced": true,
          "key": "console",
          "name": "Console"
        }
      ],
      "limits": [
        {
          "help": "Cores given to the VM.",
          "key": "vcpus",
          "max": 512,
          "min": 1,
          "name": "vCPUs",
          "required": true,
          "unlimited": false
        },
        {
          "key": "ipv4",
          "max": 256,
          "min": 0,
          "name": "IPv4 addresses",
          "required": false,
          "unlimited": false
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 404 No such object.
  • 409 This product's plans are not managed here.
PUT /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).

ParameterInType
product *pathstring
plan *pathstring
Responses
  • 200 The plan
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 Refused (an unknown limit or feature
PUT /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.

ParameterInType
product *pathstring
plan *pathstring
Request
{
  "month": 1900,
  "perSeat": true,
  "year": 19000
}
Responses
  • 200 The whole catalog.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.

Settings

GET /v1/platform/sealing

How the platform's secrets are sealed

Each kind of stored secret (name) with how many values are sealed with the server's key file (local) and how many by OpenBao's transit engine (remote). openbao says whether this server seals new secrets with OpenBao (CORPSUITE_OPENBAO_ADDR is set).

Responses
  • 200 The counts.
    {
      "items": [
        {
          "failed": 0,
          "local": 2,
          "moved": 0,
          "name": "Proxmox API tokens",
          "remote": 5
        }
      ],
      "openbao": true
    }
  • 401 No valid platform API key or admin panel session.
POST /v1/platform/sealing/rewrap

Move the platform's secrets to OpenBao

Moves every secret still sealed with the key file to OpenBao: each is opened and sealed again by OpenBao for the same place, in place. A value changed meanwhile is left for the next run; one that fails is counted (failed, with the last problem) and left as it was. Safe to run again. 409 when OpenBao is not set up here. Recorded in the audit log.

Responses
  • 200 What it did, per kind.
    {
      "failed": 0,
      "items": [
        {
          "failed": 0,
          "local": 0,
          "moved": 3,
          "name": "Proxmox API tokens",
          "remote": 3
        }
      ],
      "moved": 3,
      "openbao": true
    }
  • 401 No valid platform API key or admin panel session.
  • 409 OpenBao is not set up on this server.

Single sign-on

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

Responses
  • 200 The 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"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
POST /v1/platform/sso-clients

Register an application

The secret is in the answer once.

Request
{
  "backchannelLogoutUri": "https://tether.example/auth/backchannel-logout",
  "id": "tether",
  "name": "Tether",
  "orgsScope": true,
  "redirectUris": [
    "https://tether.example/auth/callback"
  ]
}
Responses
  • 201 The client and its secret.
    {
      "client": {
        "id": "xiht",
        "name": "xi.ht"
      },
      "secret": "cs_cs_Yk3"
    }
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
PATCH /v1/platform/sso-clients/{clientId}

Rename, change return addresses, or disable

ParameterInType
clientId *pathstring
Request
{
  "backchannelLogoutUri": "https://tether.example/auth/backchannel-logout"
}
Responses
  • 200 The client.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/sso-clients/{clientId}

Remove an application

ParameterInType
clientId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /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.

ParameterInType
clientId *pathstring
Responses
  • 200 The client and its new secret.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.

Status

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

ParameterInType
pagequeryboolean
Responses
  • 200 The 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"
            }
          ]
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /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.

Request
{
  "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"
}
Responses
  • 201 The notice.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/status/{noticeId}

A notice with its updates

ParameterInType
noticeId *pathstring
Responses
  • 200 The 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"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 404 No such object.
PATCH /v1/platform/status/{noticeId}

Change a notice

Its title, severity, products and times; its status changes only with an update.

ParameterInType
noticeId *pathstring
Request
{
  "products": [
    "mailocity",
    "zappocity"
  ],
  "severity": "critical"
}
Responses
  • 200 The notice.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/status/{noticeId}

Remove a notice

For one posted by mistake; end real ones with an update instead. Recorded in the audit log.

ParameterInType
noticeId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /v1/platform/status/{noticeId}/updates

Post an update

Moves the notice to status; the kind's last status ends it, another opens it again.

ParameterInType
noticeId *pathstring
Request
{
  "body": "Delivery is back to normal; queued mail has been delivered.",
  "status": "resolved"
}
Responses
  • 201 The notice.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.

Storage

GET /v1/platform/storage

Object storage's settings

The host name S3 clients connect to (path-style, https://HOST/BUCKET/KEY; empty: not served) and the region they sign for. The host gets its certificate like the portal hosts. localLimitBytes is how much is kept on this server before the objects least recently written go to the remote S3 service (such as Backblaze B2), encrypted here first; null keeps everything here. The remote service's secret is never shown (secretSet says whether one is kept). localBytes is what objects and unfinished uploads take here; remoteBytes what's offloaded.

Responses
  • 200 The settings.
    {
      "host": "s3.example.com",
      "localBytes": 1999000000000,
      "localLimitBytes": 2000000000000,
      "region": "us-east-1",
      "remote": {
        "bucket": "zappocity-objects",
        "endpoint": "https://s3.us-west-004.backblazeb2.com",
        "keyId": "0042a1b2c3d4e5f0000000001",
        "pathStyle": true,
        "region": "us-west-004",
        "secretSet": true
      },
      "remoteBytes": 350000000000,
      "updatedAt": "2026-11-08T12:00:00Z"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 Object storage is not served here.
PATCH /v1/platform/storage

Change object storage's settings

Fields left out stay; taken on within 30 seconds on other servers, at once on this one. Recorded in the audit log (never the secret). When the remote service's settings change, a small test object is kept, read and removed there first, and they're kept only when that works. While objects are offloaded, the remote endpoint and bucket can't change (the key id and secret can).

Request
{
  "localLimitBytes": 2000000000000,
  "remoteBucket": "zappocity-objects",
  "remoteEndpoint": "https://s3.us-west-004.backblazeb2.com",
  "remoteKeyId": "0042a1b2c3d4e5f0000000001",
  "remoteRegion": "us-west-004",
  "remoteSecret": "K004abcdefghijklmnopqrstuvwxyz0"
}
Responses
  • 200 The settings now.
    {
      "host": "s3.example.com",
      "localBytes": 1999000000000,
      "localLimitBytes": 2000000000000,
      "region": "us-east-1",
      "remote": {
        "bucket": "zappocity-objects",
        "endpoint": "https://s3.us-west-004.backblazeb2.com",
        "keyId": "0042a1b2c3d4e5f0000000001",
        "pathStyle": true,
        "region": "us-west-004",
        "secretSet": true
      },
      "remoteBytes": 350000000000,
      "updatedAt": "2026-11-08T12:00:00Z"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 Object storage is not served here.
  • 409 Objects are offloaded to the remote service
  • 422 A value we don't take
GET /v1/platform/storage/accounts

Storage accounts

Newest first; q finds a name, organization or bucket name.

ParameterInType
qquerystring
limitqueryinteger
Responses
  • 200 The accounts.
    {
      "items": [
        {
          "boostBuckets": 0,
          "boostBytes": 0,
          "buckets": 2,
          "bytesUsed": 1048576,
          "createdAt": "2026-11-08T12:00:00Z",
          "featuresOff": [],
          "featuresOn": [],
          "id": "6f1d2c3b-4a5e-4f60-8172-839a4b5c6d7e",
          "name": "Acme Files",
          "orgId": "0a1b2c3d-4e5f-4061-8273-94a5b6c7d8e9",
          "orgName": "Acme Files",
          "plan": "starter",
          "status": "active",
          "subscriptionId": "9a8b7c6d-5e4f-4302-9182-736455463728",
          "updatedAt": "2026-11-08T12:00:00Z"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 Object storage is not served here.
  • 422 A limit out of range.
GET /v1/platform/storage/accounts/{accountId}

A storage account

Its limits, buckets and access keys (never their secrets).

ParameterInType
accountId *pathstring
Responses
  • 200 The account.
    {
      "account": {
        "bytesUsed": 1048576,
        "id": "6f1d2c3b-4a5e-4f60-8172-839a4b5c6d7e",
        "name": "Acme Files",
        "plan": "starter",
        "status": "active"
      },
      "buckets": [
        {
          "bytes": 1048576,
          "createdAt": "2026-11-08T12:00:00Z",
          "id": "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9",
          "name": "acme-backups",
          "objects": 12
        }
      ],
      "keys": [
        {
          "accessKeyId": "ZKEXAMPLEEXAMPLEEXA",
          "bucketId": null,
          "createdAt": "2026-11-08T12:00:00Z",
          "id": "2b3c4d5e-6f70-4182-93a4-b5c6d7e8f9a0",
          "lastUsedAt": "2026-11-08T13:00:00Z",
          "name": "backup server",
          "readOnly": false
        }
      ],
      "limits": {
        "buckets": 10,
        "storageBytes": 250000000000
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
DELETE /v1/platform/storage/accounts/{accountId}/keys/{keyId}

Remove an access key

For a key that leaked; requests signed with it are refused at once. Recorded in the audit log.

ParameterInType
accountId *pathstring
keyId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such key in that account.

Subscriptions

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

ParameterInType
orgId *pathstring
Request
{
  "period": "year",
  "plan": "team",
  "product": "mailocity"
}
Responses
  • 201 The 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"
    }
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/subscriptions

Find subscriptions

ParameterInType
orgIdquerystring
productquerystring
resourceRefquerystringWhich subscription a resource (a tenant id) belongs to.
statusquerystring
limitqueryinteger
Responses
  • 200 Newest first
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
GET /v1/platform/subscriptions/{subscriptionId}

A subscription and what the product reports on it

ParameterInType
subscriptionId *pathstring
Responses
  • 200 The 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"
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 404 No such object.
PATCH /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.

ParameterInType
subscriptionId *pathstring
Request
{
  "seats": 5
}
Responses
  • 200 The subscription.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
GET /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.

ParameterInType
subscriptionId *pathstring
Responses
  • 200 The features.
    {
      "effective": [
        "webmail",
        "smtp_submission",
        "archiving"
      ],
      "off": [
        "imap"
      ],
      "on": [
        "archiving"
      ],
      "plan": [
        "webmail",
        "imap",
        "smtp_submission"
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 404 No such object.
  • 409 Nothing provisioned
PUT /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.

ParameterInType
subscriptionId *pathstring
Request
{
  "off": [
    "imap"
  ],
  "on": [
    "archiving"
  ]
}
Responses
  • 200 The features after the change.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 Refused (an unknown feature

Support

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

Responses
  • 200 The 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"
          ]
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /v1/platform/support/escalations

Add an escalation rule

It needs at least one action. Recorded in the audit log.

Request
{
  "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"
}
Responses
  • 201 The rule.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
PUT /v1/platform/support/escalations/{ruleId}

Replace an escalation rule

Every field, as when adding it.

ParameterInType
ruleId *pathstring
Request
{
  "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"
}
Responses
  • 200 The rule.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/support/escalations/{ruleId}

Delete an escalation rule

ParameterInType
ruleId *pathstring
Responses
  • 204 Deleted.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /v1/platform/support/follow-ups

Reminders that are due

Responses
  • 200 Oldest first.
  • 401 No valid platform API key or admin panel session.
GET /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).

ParameterInType
qquerystring
categoryquerystringA category's slug.
kindquerystring
Responses
  • 200 The 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"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 404 No such object.
POST /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.

Request
{
  "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"
}
Responses
  • 201 The article.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/support/help/articles/{articleId}

An article, with its body rendered

ParameterInType
articleId *pathstring
Responses
  • 200 The article and its HTML: { article, html }.
  • 401 No valid platform API key or admin panel session.
  • 404 No such object.
PUT /v1/platform/support/help/articles/{articleId}

Change an article

Replaces every field.

ParameterInType
articleId *pathstring
Request
{
  "slug": "refund",
  "status": "draft",
  "title": "How do I get a refund?"
}
Responses
  • 200 The article.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/support/help/articles/{articleId}

Delete an article

ParameterInType
articleId *pathstring
Responses
  • 204 Deleted.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /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.

Responses
  • 200 The categories.
    {
      "items": [
        {
          "articles": 6,
          "description": "Invoices, cards and refunds",
          "id": "2d4f6a8c-1b3e-4c5d-9e7f-0a1b2c3d4e5f",
          "name": "Billing",
          "slug": "billing",
          "sortOrder": 0
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
POST /v1/platform/support/help/categories

Add a category

Request
{
  "description": "Invoices, cards and refunds",
  "name": "Billing",
  "slug": "billing"
}
Responses
  • 201 The category.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
PUT /v1/platform/support/help/categories/{categoryId}

Change a category

ParameterInType
categoryId *pathstring
Request
{
  "name": "Billing and invoices",
  "slug": "billing",
  "sortOrder": 1
}
Responses
  • 200 The category.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/support/help/categories/{categoryId}

Delete a category

Its articles stay, without a category.

ParameterInType
categoryId *pathstring
Responses
  • 204 Deleted.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /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).

Responses
  • 204 Done.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
GET /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.

Responses
  • 200 The 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"
            }
          ]
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
GET /v1/platform/support/queues

The support queues and their settings

Responses
  • 200 The 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
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
POST /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.

Request
{
  "customerClose": false,
  "emailIntake": true,
  "intakeFrom": "anyone",
  "name": "Abuse",
  "slug": "abuse"
}
Responses
  • 201 The queue.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
PATCH /v1/platform/support/queues/{queueId}

Change a queue's settings

The slug (its address) cannot change. Turn a queue off with active false.

ParameterInType
queueId *pathstring
Request
{
  "webReplyLogin": true
}
Responses
  • 200 The queue.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
DELETE /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.

ParameterInType
queueId *pathstring
moveToquerystringSlug of the queue the tickets move to.
Responses
  • 200 Removed.
    {
      "moved": 12
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
PUT /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.

ParameterInType
queueId *pathstring
address *pathstring
Responses
  • 200 The queue
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
DELETE /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.

ParameterInType
queueId *pathstring
address *pathstring
Responses
  • 200 The queue.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /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).

ParameterInType
queueId *pathstring
Responses
  • 200 The 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
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /v1/platform/support/queues/{queueId}/fields

Ask a question when tickets are opened

Recorded in the audit log.

ParameterInType
queueId *pathstring
Request
{
  "help": "On your invoice, such as ZI-1042.",
  "key": "order_number",
  "kind": "text",
  "label": "Order number",
  "required": true
}
Responses
  • 201 The field.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The queue has a field with this key.
  • 422 A value is out of range or names something that does not exist.
PUT /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.

ParameterInType
queueId *pathstring
fieldId *pathstring
Request
{
  "help": "On your invoice, such as ZI-1042.",
  "key": "order_number",
  "kind": "text",
  "label": "Order number",
  "required": true
}
Responses
  • 200 The field.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The queue has another field with this key.
  • 422 A value is out of range or names something that does not exist.
DELETE /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.

ParameterInType
queueId *pathstring
fieldId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /v1/platform/support/queues/{queueId}/mailbox

Make the queue's mailbox, or give it a new token

ParameterInType
queueId *pathstring
Responses
  • 200 The queue.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
GET /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.

ParameterInType
daysqueryinteger
Responses
  • 200 The 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"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 days is not 1 to 365.
GET /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.

ParameterInType
queuequerystringA queue's slug.
Responses
  • 200 The replies.
  • 401 No valid platform API key or admin panel session.
  • 404 No such object.
POST /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.

Request
{
  "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"
}
Responses
  • 201 Saved.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
PUT /v1/platform/support/replies/{replyId}

Change a canned reply or macro

Replaces every field; actions left out are removed. Needs the support permission.

ParameterInType
replyId *pathstring
Request
{
  "body": "Hi {{customer.firstName|there}}, could you send us a screenshot?",
  "name": "Waiting on you",
  "setStatus": "pending"
}
Responses
  • 200 Changed.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/support/replies/{replyId}

Delete a canned reply or macro

ParameterInType
replyId *pathstring
Responses
  • 204 Deleted.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /v1/platform/support/settings

The support team that holds the queues' mailboxes

Responses
  • 200 The settings.
    {
      "domain": "zappo.city",
      "jmapUrl": "https://mailo.city",
      "teamRef": "4f1c2d3e-1111-4a2b-9c3d-5e6f7a8b9c0d",
      "updatedAt": "2026-10-08T12:00:00Z"
    }
  • 401 No valid platform API key or admin panel session.
PUT /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.

Request
{
  "domain": "zappo.city"
}
Responses
  • 200 The settings.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
POST /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.

Request
{
  "domain": "mailo.city"
}
Responses
  • 204 Added
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
GET /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.

Responses
  • 200 The 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"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /v1/platform/support/spam-rules

Add an intake spam rule

At most 1000 rules, one per kind and value. Recorded in the audit log.

Request
{
  "kind": "domain",
  "note": "Sends offers to every queue",
  "value": "bulk-mailer.example"
}
Responses
  • 201 The rule.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
PUT /v1/platform/support/spam-rules/{ruleId}

Replace an intake spam rule

Every field, as when adding it; its hits stay.

ParameterInType
ruleId *pathstring
Request
{
  "kind": "domain",
  "note": "Sends offers to every queue",
  "value": "bulk-mailer.example"
}
Responses
  • 200 The rule.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/support/spam-rules/{ruleId}

Remove an intake spam rule

Recorded in the audit log.

ParameterInType
ruleId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /v1/platform/support/tickets

Find tickets

Only tickets of queues the caller is an agent of.

ParameterInType
queuequerystring
statusquerystring
assigneequerystring
qquerystringA 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.
orderquerystringpriority: the most urgent first, then the longest waiting.
beforequeryintegerPage by ticket number.
limitqueryinteger
Responses
  • 200 Newest first.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
POST /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.

Responses
  • 201 The ticket.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/support/tickets/{ticketId}

A ticket with every message, note, contact and follow-up

ParameterInType
ticketId *pathstring
Responses
  • 200 The 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"
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 404 No such object.
PATCH /v1/platform/support/tickets/{ticketId}

Change status, priority, assignee or queue

ParameterInType
ticketId *pathstring
Responses
  • 200 The ticket.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
POST /v1/platform/support/tickets/{ticketId}/contacts

Add someone to the replies

Where the queue allows contacts; at most 20.

ParameterInType
ticketId *pathstring
Responses
  • 200 The contacts.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/support/tickets/{ticketId}/contacts/{email}

Take someone off the replies

ParameterInType
ticketId *pathstring
email *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /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.

ParameterInType
ticketId *pathstring
Request
{
  "body": "Check the customer's DNS is fixed",
  "dueAt": "2026-10-15T09:00:00Z",
  "kind": "reminder"
}
Responses
  • 201 The follow-up.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
PATCH /v1/platform/support/tickets/{ticketId}/follow-ups/{followupId}

Mark a follow-up done, or not

ParameterInType
ticketId *pathstring
followupId *pathinteger
Responses
  • 204 Changed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /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.

ParameterInType
ticketId *pathstring
Request
{
  "tickets": [
    "SUP-12",
    "1043"
  ]
}
Responses
  • 200 This ticket, after the merges.
    {
      "id": "fb9705e2-4477-4710-b10a-67f3bd68f854",
      "mergedInto": null,
      "status": "open",
      "subject": "Can't sign in",
      "tag": "SUP-11"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 Another requester's ticket
  • 422 No such ticket
POST /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.

ParameterInType
ticketId *pathstring
Responses
  • 201 The message.
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 Someone else wrote since afterMessage (newer lists it); send again with force, or after reading it.
  • 422 A value is out of range or names something that does not exist.
POST /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.

ParameterInType
ticketId *pathstring
Responses
  • 200 The others on the ticket.
    {
      "items": [
        {
          "replying": true,
          "seenAt": "2026-11-05T13:21:04Z",
          "since": "2026-11-05T13:20:49Z",
          "who": "lead@zappocity.test"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
DELETE /v1/platform/support/tickets/{ticketId}/presence

Say you closed the ticket

ParameterInType
ticketId *pathstring
Responses
  • 204 Gone.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /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.

ParameterInType
ticketId *pathstring
replyId *pathstring
Responses
  • 200 The 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"
    }
  • 401 No valid platform API key or admin panel session.
  • 404 No such object.
  • 409 The reply is for another queue.
POST /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.

ParameterInType
ticketId *pathstring
replyId *pathstring
Request
{}
Responses
  • 200 The ticket as it is now, and the message sent (null when there was no body).
  • 400 The body is not JSON with only the documented fields, or a query parameter is out of range.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The reply is for another queue.
  • 422 A value is out of range or names something that does not exist.
DELETE /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.

ParameterInType
ticketId *pathstring
secretId *pathstring
Responses
  • 204 Deleted.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 410 Already deleted.
POST /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.

ParameterInType
ticketId *pathstring
secretId *pathstring
Responses
  • 200 The data.
    {
      "data": "c0rrect-h0rse",
      "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 410 Deleted (by staff
POST /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.

ParameterInType
ticketId *pathstring
Request
{
  "messages": [
    4211,
    4215
  ],
  "subject": "Invoice address"
}
Responses
  • 201 The new ticket.
    {
      "id": "0c5b6f7e-2a41-4f3c-9d8e-1b2a3c4d5e6f",
      "status": "open",
      "subject": "Invoice address",
      "tag": "SUP-1044"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 Every message would move
  • 422 A message that is not this ticket's

VPS

GET /v1/platform/orgs/{orgId}/servers

An organization's servers

A server is made by a VPS subscription and waits for staff to link it to a VM. With state=1 each linked server's VM is asked for its state.

ParameterInType
orgId *pathstring
statequerystring
Responses
  • 200 The servers, as GET /v1/platform/vps/servers shows them (with state or stateError when asked).
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /v1/platform/orgs/{orgId}/servers/import

Import a VM as it is, with a custom plan of its size

For an existing VM that fits no plan. Zappocity reads its size from Proxmox (vCPUs as sockets times cores, cores alone for a container; memory; the boot disk, rounded up to whole GB) and makes a custom plan of exactly that size for this organization alone (orgId on the plan): no other organization can be subscribed or moved to it, and checkout never offers it. The organization is subscribed to it, not billed (renewsAt null) until the plan has a price, the server is given ipv4 when named (an address from a pool, kept with its Kea reservation), and it is linked to the VM. Nothing in Proxmox changes, since the plan is the VM's size. Set the price with PUT /v1/platform/products/vps/plans/{plan}/prices, then start billing with PATCH /v1/platform/subscriptions/{subscriptionId} renewsAt: now. A template, a VM another server is linked to, or one whose boot disk size cannot be read is refused, and nothing is made. When a later step fails (409 "Imported in part"), the plan, subscription and server are kept; finish on the organization's page. A server moved off its custom plan retires the plan.

ParameterInType
orgId *pathstring
Request
{
  "clusterId": "7d0f3a52-0c1e-4b4a-9d8e-2f1a6c3b5e70",
  "ipv4": "203.0.113.10",
  "name": "web1",
  "vmid": 123
}
Responses
  • 201 The plan, the VM's size, the subscription and the server.
    {
      "plan": {
        "code": "custom-123-9f2c4a1b",
        "diskGb": 55,
        "ipv4": 1,
        "memoryMb": 6144,
        "name": "Custom: VM 123 (web1)",
        "orgId": "4b0c6a2e-1d3f-4e5a-8b7c-9d0e1f2a3b4c",
        "vcpus": 6
      },
      "server": {
        "server": {
          "name": "web1",
          "vmid": 123
        }
      },
      "size": {
        "disk": "scsi0",
        "diskGb": 55,
        "kind": "qemu",
        "memoryMb": 6144,
        "name": "web1",
        "node": "pve1",
        "vcpus": 6,
        "vmid": 123
      },
      "subscription": {
        "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
        "plan": "custom-123-9f2c4a1b",
        "renewsAt": null
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 Imported in part (the plan
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/orgs/{orgId}/servers/{serverId}

A server, its network and its VM's state

ParameterInType
orgId *pathstring
serverId *pathstring
Responses
  • 200 The server; state is Proxmox's now, or stateError says why it could not be read.
    {
      "network": {
        "ipv4": [
          {
            "address": "192.0.2.10",
            "bridge": "vmbr0",
            "gateway": "192.0.2.1",
            "prefix": 24,
            "routed": false
          },
          {
            "address": "203.0.113.10",
            "bridge": "",
            "gateway": "",
            "prefix": 32,
            "routed": true
          }
        ],
        "ipv4Management": {
          "address": "10.255.0.10",
          "bridge": "vmbr0",
          "dhcp": true,
          "gateway": "10.255.0.1",
          "network": "10.255.0.0/24"
        },
        "ipv6": {
          "bridge": "vmbr1",
          "gateway": "2001:db8:ffff::1",
          "management": "2001:db8:ffff::101",
          "network": "2001:db8:ffff::/64",
          "nic": 1,
          "prefix": "2001:db8:1000:5::/64"
        },
        "mac": "bc:24:11:aa:bb:cc"
      },
      "server": {
        "id": "1f0c2b9e-3a5d-4a8e-9c1b-7d2e4f6a8b0c",
        "kind": "qemu",
        "name": "Server 1",
        "plan": "vps-small",
        "status": "active",
        "vmid": 101
      },
      "state": {
        "cpu": 0.04,
        "cpus": 1,
        "disk": 0,
        "maxdisk": 42949672960,
        "maxmem": 2147483648,
        "mem": 734003200,
        "netin": 1048576,
        "netout": 524288,
        "status": "running",
        "uptime": 86400
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
PATCH /v1/platform/orgs/{orgId}/servers/{serverId}

Rename a server, link it to a VM or unlink it

Linking reads the VM's node, kind (qemu or lxc) and the MAC of its first network card (net0) from Proxmox, unless mac is given, and reserves the server's IPv4 addresses for the MAC in Kea. When IPv6's bridge is not the server's IPv4 bridge, the MAC of the VM's other card on the IPv6 bridge is kept as mac6, and IPv6 is reserved for it (a warning says when there is no such card). A VM is linked to one server at most. unlink takes the server off its VM and its addresses' reservations out of Kea, keeping the addresses. A warning says when Kea could not be told. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "clusterId": "6b1d5f0e-58a4-4f0e-9d3c-2f1f8f3f0a11",
  "vmid": 101
}
Responses
  • 200 The server, as GET shows it without state, and a warning when there is one.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
GET /v1/platform/orgs/{orgId}/servers/{serverId}/access

A server's access settings

What the VM's cloud-init sets at its next start: its hostname, the user it makes, the SSH keys that user accepts, whether a password is set (never the password), and whether the VM has a cloud-init drive (without one, ask support). VMs only: a container's access is set inside it. Staff may use it on any linked VM, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Responses
  • 200 The access settings.
    {
      "cloudInit": true,
      "hostname": "web1.example.com",
      "passwordSet": true,
      "sshKeys": [
        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample amy@laptop"
      ],
      "user": "debian"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
PATCH /v1/platform/orgs/{orgId}/servers/{serverId}/access

Set a server's hostname, user, SSH keys or password

Changes what the VM's cloud-init sets; fields left out stay as they are. The VM takes the change on at its next start through us (a reboot from inside the VM does not): send reboot true to reboot a running VM now. An empty password makes a random one of 20 characters, answered once as password and never kept by us; a password given must be 12 to 128 characters. An empty sshKeys removes them all. The password is never recorded; the change is, by what it set. Staff may use it on any linked VM, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "password": "",
  "reboot": true
}
Responses
  • 200 The access settings now, the password made (only when one was), and whether it was rebooted.
    {
      "cloudInit": true,
      "hostname": "web1",
      "password": "x7Kp2mQr9TzVw4NcHb3E",
      "passwordSet": true,
      "rebooted": true,
      "sshKeys": [],
      "user": "root"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 429 Another action was taken on the server a moment ago (reboot only).
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/apply-plan

Set a server's VM to its plan

Sets the VM's vCPUs and memory to the plan's and grows its boot disk (rootfs for a container) to the plan's size. Proxmox cannot shrink a disk, so a larger one is left as it is. A plan change does this by itself; this is for when Proxmox could not be reached then. New vCPUs and memory take effect when a VM next starts, unless it hot-plugs them; a grown disk needs its partition and file system grown inside the server. Needs VM.Config.CPU, VM.Config.Memory and VM.Config.Disk on the VM and Datastore.AllocateSpace on its storage. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Responses
  • 200 What was changed, and what was left.
    {
      "notes": [
        "vCPUs 1 to 2",
        "memory 2048 to 4096 MB",
        "the new vCPUs and memory take effect when the VM next starts (unless it hot-plugs them)",
        "disk scsi0 40 to 80 GB (grow the partition and file system inside the server to use it)"
      ],
      "plan": "vps-medium"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The server is not linked to a VM yet.
  • 502 Proxmox refused or could not be reached.
GET /v1/platform/orgs/{orgId}/servers/{serverId}/backups

A server's backups

The server's backups on its cluster's backup storage, newest first, with how many are kept and whether a backup or restore is running now. Only the backups Proxmox lists for the server's own VM are given. Staff may use backups on any server whose cluster has a backup storage, whatever its plan or status, and are not spaced.

ParameterInType
orgId *pathstring
serverId *pathstring
Responses
  • 200 The backups.
    {
      "items": [
        {
          "ctime": 1793500000,
          "notes": "Zappocity: web1",
          "protected": false,
          "size": 1073741824,
          "vmid": 101,
          "volid": "backups:backup/vzdump-qemu-101-2026_11_01-02_00_00.vma.zst"
        }
      ],
      "keep": 7,
      "running": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/backups

Back a server up now

A snapshot backup, so the server keeps running. Proxmox keeps the newest few of the server (the cluster's backupKeep) and removes the oldest beyond that. Refused while Proxmox is busy with the server. Staff may use backups on any server whose cluster has a backup storage, whatever its plan or status, and are not spaced. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Responses
  • 202 Started; the backup appears in the list once Proxmox has finished.
    {
      "task": "UPID:pve1:00012345:0ABCDEF0:672B3C4D:vzdump:101:zappocity@pve!vps:"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/backups/delete

Delete one of a server's backups

Removes one of the server's own backups from the storage; a protected one cannot be removed. Staff may use backups on any server whose cluster has a backup storage, whatever its plan or status, and are not spaced. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "volid": "backups:backup/vzdump-qemu-101-2026_11_01-02_00_00.vma.zst"
}
Responses
  • 200 Deleted.
    {
      "deleted": "backups:backup/vzdump-qemu-101-2026_11_01-02_00_00.vma.zst"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such backup of it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/backups/restore

Restore a backup over a server

Puts one of the server's own backups back over its disks: everything on the server now is replaced. The server must be stopped, and Proxmox not busy with it. Start it again once the restore has finished. Staff may use backups on any server whose cluster has a backup storage, whatever its plan or status, and are not spaced. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "volid": "backups:backup/vzdump-qemu-101-2026_11_01-02_00_00.vma.zst"
}
Responses
  • 202 Started.
    {
      "task": "UPID:pve1:00012345:0ABCDEF0:672B3C4D:qmrestore:101:zappocity@pve!vps:"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such backup of it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/build

Build a server's VM from a template

Starts building a VM for a server that has none: a full clone of the template (on the template's node, to its storage), with cloud-init set from the server's addresses (its first IPv4 address and gateway, or DHCP; its management IPv6 address and gateway), the template's user and name servers and the sshKeys given (public keys, one a line, at most 20). Once the clone has finished, the server is linked to the VM (reserving its addresses in Kea and emailing its owners and admins that it is ready), its plan is applied and it is started. The build runs in the background: the server's build is building, then empty when done, or failed with buildError. Calling this on a failed build carries it on from where it stopped (the body is then ignored). A server is never linked before its build has finished, so its people cannot act on it meanwhile. Needs VM.Clone on the template, VM.Allocate, VM.Config.Cloudinit, VM.Config.Network and Datastore.AllocateSpace. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "sshKeys": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG3x amy@laptop",
  "templateId": "0b6f3a52-8f0e-4d1c-9a7b-2c3d4e5f6a7b"
}
Responses
  • 202 The build has started; the server, with `build` set.
    {
      "features": [
        "power",
        "usage"
      ],
      "server": {
        "build": "building",
        "buildError": "",
        "buildVmid": null,
        "id": "6d1f0e2a-3b4c-4d5e-8f9a-0b1c2d3e4f5a",
        "name": "Server 1"
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/destroy

Delete a server's VM in Proxmox

Deletes the server's VM (or container) and its disks in Proxmox, and unlinks the server. Its addresses stay given, and backups on the backup storage are kept. vmid must be the server's VM id, as a confirmation, and the VM must be stopped and not busy. Waits for Proxmox to finish (up to five minutes). Staff only: customers cannot delete a VM. Needs VM.Allocate. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "vmid": 105
}
Responses
  • 200 Deleted; a warning when Kea could not be told.
    {
      "warning": ""
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The server is not linked to a VM.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused
GET /v1/platform/orgs/{orgId}/servers/{serverId}/firewall

A server's firewall

The server's own Proxmox firewall: its options, every rule in order (managed ones are ours: the port 25 block until support opens it), aliases, IP sets, and the addresses its IP filter lets it send from (its IPv4 addresses, management IPv6 address and routed /64; ours, kept in step with its addresses). Reading it first puts our part back if it was changed in Proxmox. Staff may use it on any linked server, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Responses
  • 200 The firewall as it is now.
    {
      "addresses": [
        "192.0.2.10",
        "2001:db8:ffff::100",
        "2001:db8:1000:1::/64"
      ],
      "aliases": [
        {
          "cidr": "198.51.100.0/24",
          "comment": "Our office",
          "name": "office"
        }
      ],
      "ipsets": [
        {
          "comment": "",
          "entries": [
            {
              "cidr": "203.0.113.5"
            }
          ],
          "name": "monitoring"
        }
      ],
      "options": {
        "dhcp": true,
        "logLevelIn": "nolog",
        "logLevelOut": "nolog",
        "ndp": true,
        "policyIn": "DROP",
        "policyOut": "ACCEPT"
      },
      "rules": [
        {
          "action": "REJECT",
          "comment": "zappocity: outgoing mail (port 25) is blocked; open a support ticket to have it opened",
          "dport": "25",
          "enable": true,
          "managed": true,
          "pos": 0,
          "proto": "tcp",
          "type": "out"
        },
        {
          "action": "ACCEPT",
          "comment": "SSH from anywhere",
          "enable": true,
          "macro": "SSH",
          "managed": false,
          "pos": 1,
          "type": "in"
        }
      ],
      "smtpOpen": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such rule, alias or IP set on it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/firewall/aliases

Add a firewall alias

A name for an address or network, for rules' source and dest. Staff may use it on any linked server, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "cidr": "198.51.100.0/24",
  "comment": "Our office",
  "name": "office"
}
Responses
  • 200 The firewall as it is now.
    {
      "addresses": [
        "192.0.2.10",
        "2001:db8:ffff::100",
        "2001:db8:1000:1::/64"
      ],
      "aliases": [
        {
          "cidr": "198.51.100.0/24",
          "comment": "Our office",
          "name": "office"
        }
      ],
      "ipsets": [
        {
          "comment": "",
          "entries": [
            {
              "cidr": "203.0.113.5"
            }
          ],
          "name": "monitoring"
        }
      ],
      "options": {
        "dhcp": true,
        "logLevelIn": "nolog",
        "logLevelOut": "nolog",
        "ndp": true,
        "policyIn": "DROP",
        "policyOut": "ACCEPT"
      },
      "rules": [
        {
          "action": "REJECT",
          "comment": "zappocity: outgoing mail (port 25) is blocked; open a support ticket to have it opened",
          "dport": "25",
          "enable": true,
          "managed": true,
          "pos": 0,
          "proto": "tcp",
          "type": "out"
        },
        {
          "action": "ACCEPT",
          "comment": "SSH from anywhere",
          "enable": true,
          "macro": "SSH",
          "managed": false,
          "pos": 1,
          "type": "in"
        }
      ],
      "smtpOpen": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such rule, alias or IP set on it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
PUT /v1/platform/orgs/{orgId}/servers/{serverId}/firewall/aliases/{name}

Change a firewall alias

Changes an alias's address or network and comment. Staff may use it on any linked server, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
name *pathstring
Request
{
  "cidr": "198.51.100.0/25"
}
Responses
  • 200 The firewall as it is now.
    {
      "addresses": [
        "192.0.2.10",
        "2001:db8:ffff::100",
        "2001:db8:1000:1::/64"
      ],
      "aliases": [
        {
          "cidr": "198.51.100.0/24",
          "comment": "Our office",
          "name": "office"
        }
      ],
      "ipsets": [
        {
          "comment": "",
          "entries": [
            {
              "cidr": "203.0.113.5"
            }
          ],
          "name": "monitoring"
        }
      ],
      "options": {
        "dhcp": true,
        "logLevelIn": "nolog",
        "logLevelOut": "nolog",
        "ndp": true,
        "policyIn": "DROP",
        "policyOut": "ACCEPT"
      },
      "rules": [
        {
          "action": "REJECT",
          "comment": "zappocity: outgoing mail (port 25) is blocked; open a support ticket to have it opened",
          "dport": "25",
          "enable": true,
          "managed": true,
          "pos": 0,
          "proto": "tcp",
          "type": "out"
        },
        {
          "action": "ACCEPT",
          "comment": "SSH from anywhere",
          "enable": true,
          "macro": "SSH",
          "managed": false,
          "pos": 1,
          "type": "in"
        }
      ],
      "smtpOpen": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such rule, alias or IP set on it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
DELETE /v1/platform/orgs/{orgId}/servers/{serverId}/firewall/aliases/{name}

Remove a firewall alias

Removes an alias (Proxmox refuses while a rule uses it). Staff may use it on any linked server, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
name *pathstring
Responses
  • 200 The firewall as it is now.
    {
      "addresses": [
        "192.0.2.10",
        "2001:db8:ffff::100",
        "2001:db8:1000:1::/64"
      ],
      "aliases": [
        {
          "cidr": "198.51.100.0/24",
          "comment": "Our office",
          "name": "office"
        }
      ],
      "ipsets": [
        {
          "comment": "",
          "entries": [
            {
              "cidr": "203.0.113.5"
            }
          ],
          "name": "monitoring"
        }
      ],
      "options": {
        "dhcp": true,
        "logLevelIn": "nolog",
        "logLevelOut": "nolog",
        "ndp": true,
        "policyIn": "DROP",
        "policyOut": "ACCEPT"
      },
      "rules": [
        {
          "action": "REJECT",
          "comment": "zappocity: outgoing mail (port 25) is blocked; open a support ticket to have it opened",
          "dport": "25",
          "enable": true,
          "managed": true,
          "pos": 0,
          "proto": "tcp",
          "type": "out"
        },
        {
          "action": "ACCEPT",
          "comment": "SSH from anywhere",
          "enable": true,
          "macro": "SSH",
          "managed": false,
          "pos": 1,
          "type": "in"
        }
      ],
      "smtpOpen": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such rule, alias or IP set on it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/firewall/ipsets

Make a firewall IP set

An empty set of addresses and networks, for rules as +name. Names starting ipfilter are the IP filter's. Staff may use it on any linked server, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "name": "monitoring"
}
Responses
  • 200 The firewall as it is now.
    {
      "addresses": [
        "192.0.2.10",
        "2001:db8:ffff::100",
        "2001:db8:1000:1::/64"
      ],
      "aliases": [
        {
          "cidr": "198.51.100.0/24",
          "comment": "Our office",
          "name": "office"
        }
      ],
      "ipsets": [
        {
          "comment": "",
          "entries": [
            {
              "cidr": "203.0.113.5"
            }
          ],
          "name": "monitoring"
        }
      ],
      "options": {
        "dhcp": true,
        "logLevelIn": "nolog",
        "logLevelOut": "nolog",
        "ndp": true,
        "policyIn": "DROP",
        "policyOut": "ACCEPT"
      },
      "rules": [
        {
          "action": "REJECT",
          "comment": "zappocity: outgoing mail (port 25) is blocked; open a support ticket to have it opened",
          "dport": "25",
          "enable": true,
          "managed": true,
          "pos": 0,
          "proto": "tcp",
          "type": "out"
        },
        {
          "action": "ACCEPT",
          "comment": "SSH from anywhere",
          "enable": true,
          "macro": "SSH",
          "managed": false,
          "pos": 1,
          "type": "in"
        }
      ],
      "smtpOpen": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such rule, alias or IP set on it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
DELETE /v1/platform/orgs/{orgId}/servers/{serverId}/firewall/ipsets/{name}

Remove a firewall IP set

Removes one of the server's IP sets and its entries; never the IP filter's. Staff may use it on any linked server, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
name *pathstring
Responses
  • 200 The firewall as it is now.
    {
      "addresses": [
        "192.0.2.10",
        "2001:db8:ffff::100",
        "2001:db8:1000:1::/64"
      ],
      "aliases": [
        {
          "cidr": "198.51.100.0/24",
          "comment": "Our office",
          "name": "office"
        }
      ],
      "ipsets": [
        {
          "comment": "",
          "entries": [
            {
              "cidr": "203.0.113.5"
            }
          ],
          "name": "monitoring"
        }
      ],
      "options": {
        "dhcp": true,
        "logLevelIn": "nolog",
        "logLevelOut": "nolog",
        "ndp": true,
        "policyIn": "DROP",
        "policyOut": "ACCEPT"
      },
      "rules": [
        {
          "action": "REJECT",
          "comment": "zappocity: outgoing mail (port 25) is blocked; open a support ticket to have it opened",
          "dport": "25",
          "enable": true,
          "managed": true,
          "pos": 0,
          "proto": "tcp",
          "type": "out"
        },
        {
          "action": "ACCEPT",
          "comment": "SSH from anywhere",
          "enable": true,
          "macro": "SSH",
          "managed": false,
          "pos": 1,
          "type": "in"
        }
      ],
      "smtpOpen": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such rule, alias or IP set on it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/firewall/ipsets/{name}/entries

Add to a firewall IP set

Adds an address or network (nomatch leaves it out of the set). Never to the IP filter's. Staff may use it on any linked server, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
name *pathstring
Request
{
  "cidr": "203.0.113.5"
}
Responses
  • 200 The firewall as it is now.
    {
      "addresses": [
        "192.0.2.10",
        "2001:db8:ffff::100",
        "2001:db8:1000:1::/64"
      ],
      "aliases": [
        {
          "cidr": "198.51.100.0/24",
          "comment": "Our office",
          "name": "office"
        }
      ],
      "ipsets": [
        {
          "comment": "",
          "entries": [
            {
              "cidr": "203.0.113.5"
            }
          ],
          "name": "monitoring"
        }
      ],
      "options": {
        "dhcp": true,
        "logLevelIn": "nolog",
        "logLevelOut": "nolog",
        "ndp": true,
        "policyIn": "DROP",
        "policyOut": "ACCEPT"
      },
      "rules": [
        {
          "action": "REJECT",
          "comment": "zappocity: outgoing mail (port 25) is blocked; open a support ticket to have it opened",
          "dport": "25",
          "enable": true,
          "managed": true,
          "pos": 0,
          "proto": "tcp",
          "type": "out"
        },
        {
          "action": "ACCEPT",
          "comment": "SSH from anywhere",
          "enable": true,
          "macro": "SSH",
          "managed": false,
          "pos": 1,
          "type": "in"
        }
      ],
      "smtpOpen": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such rule, alias or IP set on it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/firewall/ipsets/{name}/entries/delete

Take an address out of a firewall IP set

Removes an address or network from one of the server's IP sets; never the IP filter's. Staff may use it on any linked server, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
name *pathstring
Request
{
  "cidr": "203.0.113.5"
}
Responses
  • 200 The firewall as it is now.
    {
      "addresses": [
        "192.0.2.10",
        "2001:db8:ffff::100",
        "2001:db8:1000:1::/64"
      ],
      "aliases": [
        {
          "cidr": "198.51.100.0/24",
          "comment": "Our office",
          "name": "office"
        }
      ],
      "ipsets": [
        {
          "comment": "",
          "entries": [
            {
              "cidr": "203.0.113.5"
            }
          ],
          "name": "monitoring"
        }
      ],
      "options": {
        "dhcp": true,
        "logLevelIn": "nolog",
        "logLevelOut": "nolog",
        "ndp": true,
        "policyIn": "DROP",
        "policyOut": "ACCEPT"
      },
      "rules": [
        {
          "action": "REJECT",
          "comment": "zappocity: outgoing mail (port 25) is blocked; open a support ticket to have it opened",
          "dport": "25",
          "enable": true,
          "managed": true,
          "pos": 0,
          "proto": "tcp",
          "type": "out"
        },
        {
          "action": "ACCEPT",
          "comment": "SSH from anywhere",
          "enable": true,
          "macro": "SSH",
          "managed": false,
          "pos": 1,
          "type": "in"
        }
      ],
      "smtpOpen": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such rule, alias or IP set on it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
PATCH /v1/platform/orgs/{orgId}/servers/{serverId}/firewall/options

Set a server's firewall policies and options

The policies for traffic no rule matches, log levels, and DHCP and NDP; fields left out stay as they are. The firewall itself, its IP and MAC filters and router advertisements are ours: asking to change them is refused (set both policies to ACCEPT to let everything through). Staff may use it on any linked server, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "policyIn": "DROP",
  "policyOut": "ACCEPT"
}
Responses
  • 200 The firewall as it is now.
    {
      "addresses": [
        "192.0.2.10",
        "2001:db8:ffff::100",
        "2001:db8:1000:1::/64"
      ],
      "aliases": [
        {
          "cidr": "198.51.100.0/24",
          "comment": "Our office",
          "name": "office"
        }
      ],
      "ipsets": [
        {
          "comment": "",
          "entries": [
            {
              "cidr": "203.0.113.5"
            }
          ],
          "name": "monitoring"
        }
      ],
      "options": {
        "dhcp": true,
        "logLevelIn": "nolog",
        "logLevelOut": "nolog",
        "ndp": true,
        "policyIn": "DROP",
        "policyOut": "ACCEPT"
      },
      "rules": [
        {
          "action": "REJECT",
          "comment": "zappocity: outgoing mail (port 25) is blocked; open a support ticket to have it opened",
          "dport": "25",
          "enable": true,
          "managed": true,
          "pos": 0,
          "proto": "tcp",
          "type": "out"
        },
        {
          "action": "ACCEPT",
          "comment": "SSH from anywhere",
          "enable": true,
          "macro": "SSH",
          "managed": false,
          "pos": 1,
          "type": "in"
        }
      ],
      "smtpOpen": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such rule, alias or IP set on it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/firewall/rules

Add a firewall rule

Adds a rule at pos in the whole list (default the end), never above ours, so the port 25 block always comes first. At most 200 rules. Staff may use it on any linked server, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "action": "ACCEPT",
  "comment": "Web",
  "dport": "443",
  "enable": true,
  "proto": "tcp",
  "type": "in"
}
Responses
  • 200 The firewall as it is now.
    {
      "addresses": [
        "192.0.2.10",
        "2001:db8:ffff::100",
        "2001:db8:1000:1::/64"
      ],
      "aliases": [
        {
          "cidr": "198.51.100.0/24",
          "comment": "Our office",
          "name": "office"
        }
      ],
      "ipsets": [
        {
          "comment": "",
          "entries": [
            {
              "cidr": "203.0.113.5"
            }
          ],
          "name": "monitoring"
        }
      ],
      "options": {
        "dhcp": true,
        "logLevelIn": "nolog",
        "logLevelOut": "nolog",
        "ndp": true,
        "policyIn": "DROP",
        "policyOut": "ACCEPT"
      },
      "rules": [
        {
          "action": "REJECT",
          "comment": "zappocity: outgoing mail (port 25) is blocked; open a support ticket to have it opened",
          "dport": "25",
          "enable": true,
          "managed": true,
          "pos": 0,
          "proto": "tcp",
          "type": "out"
        },
        {
          "action": "ACCEPT",
          "comment": "SSH from anywhere",
          "enable": true,
          "macro": "SSH",
          "managed": false,
          "pos": 1,
          "type": "in"
        }
      ],
      "smtpOpen": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such rule, alias or IP set on it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
PUT /v1/platform/orgs/{orgId}/servers/{serverId}/firewall/rules/{pos}

Change a firewall rule

Replaces the rule at pos (fields left out are removed), and moves it to moveTo when given, never above ours. Ours cannot be changed: the port 25 rule says to open a support ticket. Staff may use it on any linked server, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
pos *pathinteger
Request
{
  "action": "ACCEPT",
  "enable": true,
  "macro": "SSH",
  "moveTo": 1,
  "source": "office",
  "type": "in"
}
Responses
  • 200 The firewall as it is now.
    {
      "addresses": [
        "192.0.2.10",
        "2001:db8:ffff::100",
        "2001:db8:1000:1::/64"
      ],
      "aliases": [
        {
          "cidr": "198.51.100.0/24",
          "comment": "Our office",
          "name": "office"
        }
      ],
      "ipsets": [
        {
          "comment": "",
          "entries": [
            {
              "cidr": "203.0.113.5"
            }
          ],
          "name": "monitoring"
        }
      ],
      "options": {
        "dhcp": true,
        "logLevelIn": "nolog",
        "logLevelOut": "nolog",
        "ndp": true,
        "policyIn": "DROP",
        "policyOut": "ACCEPT"
      },
      "rules": [
        {
          "action": "REJECT",
          "comment": "zappocity: outgoing mail (port 25) is blocked; open a support ticket to have it opened",
          "dport": "25",
          "enable": true,
          "managed": true,
          "pos": 0,
          "proto": "tcp",
          "type": "out"
        },
        {
          "action": "ACCEPT",
          "comment": "SSH from anywhere",
          "enable": true,
          "macro": "SSH",
          "managed": false,
          "pos": 1,
          "type": "in"
        }
      ],
      "smtpOpen": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such rule, alias or IP set on it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
DELETE /v1/platform/orgs/{orgId}/servers/{serverId}/firewall/rules/{pos}

Remove a firewall rule

Removes the rule at pos. Ours cannot be removed: the port 25 rule says to open a support ticket. Staff may use it on any linked server, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
pos *pathinteger
Responses
  • 200 The firewall as it is now.
    {
      "addresses": [
        "192.0.2.10",
        "2001:db8:ffff::100",
        "2001:db8:1000:1::/64"
      ],
      "aliases": [
        {
          "cidr": "198.51.100.0/24",
          "comment": "Our office",
          "name": "office"
        }
      ],
      "ipsets": [
        {
          "comment": "",
          "entries": [
            {
              "cidr": "203.0.113.5"
            }
          ],
          "name": "monitoring"
        }
      ],
      "options": {
        "dhcp": true,
        "logLevelIn": "nolog",
        "logLevelOut": "nolog",
        "ndp": true,
        "policyIn": "DROP",
        "policyOut": "ACCEPT"
      },
      "rules": [
        {
          "action": "REJECT",
          "comment": "zappocity: outgoing mail (port 25) is blocked; open a support ticket to have it opened",
          "dport": "25",
          "enable": true,
          "managed": true,
          "pos": 0,
          "proto": "tcp",
          "type": "out"
        },
        {
          "action": "ACCEPT",
          "comment": "SSH from anywhere",
          "enable": true,
          "macro": "SSH",
          "managed": false,
          "pos": 1,
          "type": "in"
        }
      ],
      "smtpOpen": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such rule, alias or IP set on it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
GET /v1/platform/orgs/{orgId}/servers/{serverId}/history

A server's usage history

The server's average use over each step of a timeframe, oldest first, as Proxmox keeps it (about 70 steps: a minute each for an hour, half an hour for a day, and so on). cpu is a fraction of the server's vCPUs (vcpus, as the plan gives now; 1 is all of them), mem and maxmem are bytes, and netin, netout, diskread and diskwrite are bytes a second. Steps with no data (the server was stopped) are left out. Staff may see it for any linked server, whatever its plan or status.

ParameterInType
orgId *pathstring
serverId *pathstring
timeframequerystring
Responses
  • 200 The history.
    {
      "points": [
        {
          "cpu": 0.12,
          "diskread": 4096,
          "diskwrite": 20480,
          "maxmem": 2147483648,
          "mem": 1073741824,
          "netin": 1520.5,
          "netout": 830.2,
          "time": 1793500200
        }
      ],
      "timeframe": "day",
      "vcpus": 2
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/ipv4

Give a server an IPv4 address

The address named (in any pool), or the lowest free one of poolId. Never the pool's network, broadcast or gateway address, nor one given to another server. When the pool has a Kea subnet id and the server a MAC, Kea reserves the address for the MAC; a warning says when it could not be told. The server binds the address itself, or gets it by DHCP. An address of a routed pool is routed as a /32 by prefixd on the routers via the server's IPv4 management address, given with its first routed address (the lowest free one of the management network from its tenth address) and reserved in Kea for its MAC when the network has a Kea subnet; it goes with the server's last routed address. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "poolId": "3c9d2e1f-4b5a-4c6d-8e7f-9a0b1c2d3e4f"
}
Responses
  • 201 The address.
    {
      "address": {
        "address": "192.0.2.10",
        "gateway": "192.0.2.1",
        "poolId": "3c9d...",
        "prefix": 24,
        "serverId": "1f0c2b9e-3a5d-4a8e-9c1b-7d2e4f6a8b0c"
      },
      "warning": ""
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/orgs/{orgId}/servers/{serverId}/ipv4/{address}

Take an IPv4 address back from a server

Its Kea reservation is removed; a warning says when Kea could not be told. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
address *pathstring
Responses
  • 200 Taken back.
    {
      "warning": ""
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/ipv6

Give a server a management address and a routed /64

The management address comes from the management /64 (from ::100 up; the ones below are the router's and staff's), the /64 from the pool (never its first /64). Both are the lowest free ones unless named; ones released within the grace days are not given out. The routers (prefixd) add the route of the /64 via the management address on their next sync, while the server is not suspended. With keaSubnet6Id set in the IPv6 settings and the server's MAC known, both are reserved for the MAC in Kea's DHCPv6 server (the /64 as a delegated prefix); warning says when Kea could not be told. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{}
Responses
  • 201 The assignment and the server's network.
    {
      "assignment": {
        "id": "7e8f...",
        "management": "2001:db8:ffff::100",
        "prefix": "2001:db8:1000:1::/64",
        "serverId": "1f0c..."
      },
      "network": {
        "ipv6": {
          "dhcp": true,
          "gateway": "2001:db8:ffff::1",
          "management": "2001:db8:ffff::100",
          "network": "2001:db8:ffff::/64",
          "prefix": "2001:db8:1000:1::/64"
        }
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/orgs/{orgId}/servers/{serverId}/ipv6

Take a server's IPv6 back

The routers drop its route on their next sync, and its DHCPv6 reservation is removed. The addresses stay held for the grace days, so they are not given to someone else at once, or are freed now with now=1. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
nowquerystring
Responses
  • 200 Taken back, but Kea could not be told.
    {
      "warning": "Saved, but the DHCP server was not updated (kea: 502 Bad Gateway); use Push to DHCP once it is fixed"
    }
  • 204 Taken back.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/plan-from-vm

Replace a server's plan with a custom plan of its VM's size now

For a linked server whose VM was resized in Proxmox, or never matched its plan. Zappocity reads the VM's size as Proxmox has it now (vCPUs as sockets times cores, memory, the boot disk rounded up to whole GB), as an import does. It makes a custom plan of that size for the server's organization alone, and moves the server and its subscription onto it. Nothing in Proxmox changes. The new plan keeps the old plan's features, snapshot limit and IPv4 addresses (addresses to change them). It also keeps the old plan's prices, so billing goes on as before, unless prices gives others (null: not sold for that period). A custom plan the server leaves is retired. A server not linked to a VM is refused.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "prices": {
    "month": 2500,
    "year": 25000
  }
}
Responses
  • 200 The new plan, the VM's size, and the plan replaced.
    {
      "plan": {
        "code": "custom-123-9f2c4a1b",
        "diskGb": 55,
        "ipv4": 1,
        "memoryMb": 6144,
        "name": "Custom: VM 123 (web1)",
        "vcpus": 6
      },
      "previous": "vps-small",
      "size": {
        "diskGb": 55,
        "memoryMb": 6144,
        "vcpus": 6,
        "vmid": 123
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/power

Start, shut down, stop, reboot or reset a server

shutdown asks the guest to shut down and turns it off after three minutes if it has not; stop turns it off at once; reset (VMs only) restarts it at once. Staff may act on a suspended server. Actions are at least five seconds apart. Proxmox carries the action out after answering; its task id is returned. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "action": "start"
}
Responses
  • 202 Proxmox took the action.
    {
      "action": "start",
      "task": "UPID:pve1:00012345:0ABCDEF0:672B3C4D:qmstart:101:zappocity@pve!vps:"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 429 Another action was taken moments ago.
  • 502 Proxmox refused or could not be reached.
PUT /v1/platform/orgs/{orgId}/servers/{serverId}/rdns

Name one of a server's addresses (reverse DNS)

Sets the PTR record of one of the server's addresses: an IPv4 address of a pool with a reverse zone, its management address, or any address of its routed /64 (at most 32 of those), in the reverse zones staff chose on dnso.city. Not in a delegated /64 (see rdns-delegation). The name must resolve (A or AAAA) to the address, so nobody can name an address after a domain they do not control. An empty hostname removes the name. Names go when their address is taken back. Staff may skip the forward check with confirm false. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "address": "192.0.2.10",
  "hostname": "mail.example.com"
}
Responses
  • 200 The server, with its network's names under reverseDns.
    {
      "network": {
        "reverseDns": {
          "delegation": null,
          "ipv4": [
            "192.0.2.10"
          ],
          "management": true,
          "names": {
            "192.0.2.10": "mail.example.com"
          },
          "prefix": true
        }
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
PUT /v1/platform/orgs/{orgId}/servers/{serverId}/rdns-delegation

Delegate a server's routed /64 to name servers (reverse DNS)

Puts NS records for the server's routed /64 (its 16-nibble name under ip6.arpa) in the pool's reverse zone, so its people keep every name in the /64 on name servers of their own; the names Zappocity kept in the /64 go, and new ones are refused while it is delegated. Each name server must resolve. dnso.city's own name servers can be used only once the /64's zone is on dnso.city in a DNS account the caller owns (only staff and the people who manage the server can add that zone); its DS record is then put beside the NS records, unless the zone is linked to another provider. An empty list removes the delegation; it also goes when the /64 is taken back. Staff may skip the resolve check with confirm false. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "nameservers": [
    "ns1.example.com",
    "ns2.example.com"
  ]
}
Responses
  • 200 The server, with the delegation under reverseDns.
    {
      "network": {
        "reverseDns": {
          "delegation": {
            "ds": [],
            "nameservers": [
              "ns1.example.com",
              "ns2.example.com"
            ],
            "prefix": "2001:db8:1000:1::/64",
            "updatedAt": "2026-11-07T12:00:00Z"
          },
          "ipv4": [
            "192.0.2.10"
          ],
          "management": true,
          "names": {},
          "prefix": true
        }
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 No routed /64, reverse DNS not offered for it, name servers that do not resolve, dnso.city's without the zone in a DNS account you own, or the server is suspended.
GET /v1/platform/orgs/{orgId}/servers/{serverId}/reinstall

The images a server may be reinstalled from

Its cluster's templates. VMs only. Staff may use it on any linked VM, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Responses
  • 200 The templates.
    {
      "templates": [
        {
          "id": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e",
          "name": "Debian 12"
        },
        {
          "id": "6c7d8e9f-0a1b-4c2d-9e3f-4a5b6c7d8e9f",
          "name": "Ubuntu 24.04"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/reinstall

Reinstall a server

Erases the VM (it is stopped and destroyed with its disks and snapshots; backups stay) and starts building a new one from the template, with the same addresses. The server's name must be typed as confirm. SSH keys left out are the ones the VM has now; the template's user is used, and a password can be set on the Access tab afterwards. The build runs in the background: the server shows as being set up until it is ready. Spaced like power actions. Staff may use it on any linked VM, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "confirm": "web1",
  "templateId": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e"
}
Responses
  • 202 Erased; the build started.
    {
      "started": true
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 429 Another action was taken on the server a moment ago.
  • 502 Proxmox refused or could not be reached.
GET /v1/platform/orgs/{orgId}/servers/{serverId}/rescue

A server's rescue state and images

Whether the VM is booting from an image (and which), and the ISO images it may boot from (its cluster's: rescue systems and installers). VMs only. Staff may use it on any linked VM, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Responses
  • 200 The rescue state.
    {
      "active": true,
      "image": "SystemRescue 11",
      "images": [
        {
          "id": "3f1c2a9e-7b4d-4e1a-9c2f-5d6e7f8a9b0c",
          "name": "SystemRescue 11"
        },
        {
          "id": "8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d",
          "name": "Debian 12 installer"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/rescue

Boot a server from an image

Puts the image in a free CD slot (or the VM's CD drive) first in the boot order, with its disks still attached, and starts the VM, or reboots it if running. It stays so, across reboots, until rescue is ended. Refused while in rescue, with no free CD slot, or when the image isn't on the VM's node. Spaced like power actions. Staff may use it on any linked VM, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "imageId": "3f1c2a9e-7b4d-4e1a-9c2f-5d6e7f8a9b0c"
}
Responses
  • 200 In rescue.
    {
      "active": true,
      "image": "SystemRescue 11",
      "images": [
        {
          "id": "3f1c2a9e-7b4d-4e1a-9c2f-5d6e7f8a9b0c",
          "name": "SystemRescue 11"
        },
        {
          "id": "8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d",
          "name": "Debian 12 installer"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 429 Another action was taken on the server a moment ago.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/rescue/end

End rescue

Takes the image out, puts the CD slot and the boot order back as they were, and reboots the VM if it is running. Staff may use it on any linked VM, whatever its plan or status. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Responses
  • 200 Out of rescue.
    {
      "active": false,
      "image": "",
      "images": [
        {
          "id": "3f1c2a9e-7b4d-4e1a-9c2f-5d6e7f8a9b0c",
          "name": "SystemRescue 11"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 429 Another action was taken on the server a moment ago.
  • 502 Proxmox refused or could not be reached.
PUT /v1/platform/orgs/{orgId}/servers/{serverId}/smtp

Open or close port 25 out for a server

Port 25 (outgoing mail) is blocked on every server by a rule of ours at the top of its firewall. open true takes the rule away; false puts it back. Saved for a server not linked yet. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "open": true
}
Responses
  • 200 Done, with what changed.
    {
      "notes": [
        "port 25 out opened"
      ],
      "smtpOpen": true
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
GET /v1/platform/orgs/{orgId}/servers/{serverId}/snapshots

A server's snapshots

The server's snapshots in Proxmox, newest first (limit is -1 for staff (no limit)), and whether Proxmox is taking, rolling back or deleting one now. Only the snapshots Proxmox lists for the server's own VM are given. ram says the running memory was saved too. Staff may use snapshots on any linked server, whatever its plan or status, with no limit or spacing.

ParameterInType
orgId *pathstring
serverId *pathstring
Responses
  • 200 The snapshots.
    {
      "items": [
        {
          "description": "Zappocity: web1",
          "name": "before-upgrade",
          "parent": "",
          "ram": false,
          "time": 1793500000
        }
      ],
      "limit": -1,
      "running": false
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such snapshot of it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/snapshots

Snapshot a server now

Takes a snapshot of the server's disks (and, with ram, a running VM's memory, so a rollback resumes it running; never a container's). Every field is optional (send {}): a name is made from the time. Refused while Proxmox is busy with the server. Staff may use snapshots on any linked server, whatever its plan or status, with no limit or spacing. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "description": "Before the PHP upgrade",
  "name": "before-upgrade",
  "ram": false
}
Responses
  • 202 Started; the snapshot appears in the list once Proxmox has finished.
    {
      "task": "UPID:pve1:0001A2B3:00C4D5E6:6720A1B2:qmsnapshot:101:zappocity@pve!vps:"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such snapshot of it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/snapshots/delete

Delete one of a server's snapshots

Removes one of the server's own snapshots; the server keeps what is on it now. Refused while Proxmox is busy with the server. Staff may use snapshots on any linked server, whatever its plan or status, with no limit or spacing. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "name": "before-upgrade"
}
Responses
  • 202 Started.
    {
      "task": "UPID:pve1:0001A2B3:00C4D5E6:6720A1B2:qmsnapshot:101:zappocity@pve!vps:"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such snapshot of it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/snapshots/rollback

Roll a server back to one of its snapshots

Puts the server's disks (and its configuration, and memory where it was saved) back as they were at the snapshot: whatever changed since is lost. Once the rollback has finished the plan's vCPUs and memory are put on the VM again. Staff may use snapshots on any linked server, whatever its plan or status, with no limit or spacing. Recorded in the audit log.

ParameterInType
orgId *pathstring
serverId *pathstring
Request
{
  "name": "before-upgrade"
}
Responses
  • 202 Started.
    {
      "task": "UPID:pve1:0001A2B3:00C4D5E6:6720A1B2:qmsnapshot:101:zappocity@pve!vps:"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such server, or no such snapshot of it.
  • 409 The server is not linked to a VM yet.
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
POST /v1/platform/orgs/{orgId}/servers/{serverId}/undo-import

Take an import back, so the VM can be imported again

For a server imported as it is (on its organization's custom plan). It does the following: unlinks the server from its VM, which removes its DHCP reservations and its routes on the routers' next sync; frees its IPv6 management address and /64 at once (no grace days, so the new import can have them again; ipv6Freed); removes the server, releasing its addresses; cancels its subscription; and retires its custom plan with its prices. The VM itself is left as it is, and appears as not linked in the cluster's VMs, ready to import again. A server on a standard plan is refused (unlink it instead). So is one whose subscription has been invoiced (cancel the subscription instead, so the invoices keep their record).

ParameterInType
orgId *pathstring
serverId *pathstring
Responses
  • 200 Undone.
    {
      "ipv6Freed": "2001:db8:1000:5::/64",
      "plan": "custom-123-9f2c4a1b",
      "vmid": 123
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 Its subscription has been invoiced.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/orgs/{orgId}/whois

How an organization appears in ARIN's whois

private (the default) is ARIN's Private Customer, showing only the city, state, postal code and country; organization shows its name and billing address. With its registrations.

ParameterInType
orgId *pathstring
Responses
  • 200 The choice and the registrations.
    {
      "reassignments": [],
      "whois": "private"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
PUT /v1/platform/orgs/{orgId}/whois

Change how an organization appears in ARIN's whois

Its registered blocks are updated at ARIN by the job. Recorded in the audit log.

ParameterInType
orgId *pathstring
Request
{
  "whois": "organization"
}
Responses
  • 200 The choice and the registrations.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/vps/clusters

The Proxmox clusters servers are on

Each cluster (or single Proxmox VE node) is reached with an API token. The token's secret is never shown again. A self-signed certificate, as Proxmox makes, is trusted by its pinned SHA-256 fingerprint; with no fingerprint, the certificate must be trusted by the system's roots.

Responses
  • 200 The clusters, with how many servers are linked to each.
    {
      "items": [
        {
          "apiUrl": "https://pve1.example.net:8006",
          "createdAt": "2026-11-06T12:00:00Z",
          "fingerprint": "ab:cd:...:ef",
          "id": "6b1d5f0e-58a4-4f0e-9d3c-2f1f8f3f0a11",
          "metadataUrl": "http://169.254.169.254/nocloud/",
          "name": "dc1",
          "servers": 12,
          "tokenId": "zappocity@pve!vps",
          "updatedAt": "2026-11-06T12:00:00Z"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /v1/platform/vps/clusters

Add a Proxmox cluster

Proxmox is asked for its version with the settings before they are saved, unless check is false. Make the token in Proxmox under Datacenter → Permissions → API Tokens, with privilege separation and a role with VM.Audit and VM.PowerMgmt on the VMs it should see (PVEVMUser has both). The secret is sealed in the database. Recorded in the audit log.

Request
{
  "apiUrl": "https://pve1.example.net:8006",
  "fingerprint": "AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89",
  "name": "dc1",
  "tokenId": "zappocity@pve!vps",
  "tokenSecret": "5c7c8a3e-1f2b-4c7d-9e0f-123456789abc"
}
Responses
  • 201 The cluster, and the version Proxmox reported.
    {
      "cluster": {
        "apiUrl": "https://pve1.example.net:8006",
        "id": "6b1d5f0e-58a4-4f0e-9d3c-2f1f8f3f0a11",
        "name": "dc1",
        "servers": 0,
        "tokenId": "zappocity@pve!vps"
      },
      "version": "8.2.4"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
PATCH /v1/platform/vps/clusters/{clusterId}

Change a Proxmox cluster

Fields left out stay; an empty or missing tokenSecret keeps the saved one. Proxmox is asked for its version first unless check is false.

ParameterInType
clusterId *pathstring
Request
{
  "fingerprint": "AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:90"
}
Responses
  • 200 The cluster and the version Proxmox reported.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/vps/clusters/{clusterId}

Remove a cluster no server is linked to

ParameterInType
clusterId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
GET /v1/platform/vps/clusters/{clusterId}/vms

The cluster's VMs and containers

Asked of Proxmox now, with the server each is linked to, to choose one to link a server to. Templates are marked.

ParameterInType
clusterId *pathstring
Responses
  • 200 The VMs and containers the token may see.
    {
      "items": [
        {
          "kind": "qemu",
          "name": "web1",
          "node": "pve1",
          "serverId": null,
          "status": "running",
          "template": false,
          "vmid": 101
        },
        {
          "kind": "lxc",
          "name": "ct1",
          "node": "pve2",
          "serverId": "1f0c2b9e-3a5d-4a8e-9c1b-7d2e4f6a8b0c",
          "status": "stopped",
          "template": false,
          "vmid": 200
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 502 Proxmox refused or could not be reached.
GET /v1/platform/vps/dhcp

The Kea control agent IPv4 reservations go to

The password is never shown; keaPasswordSet says whether there is one.

Responses
  • 200 The settings.
    {
      "keaPasswordSet": true,
      "keaUrl": "http://10.0.0.53:8000/",
      "keaUser": "zappocity",
      "updatedAt": "2026-11-06T12:00:00Z",
      "updatedBy": "amy@zappocity.test"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
PATCH /v1/platform/vps/dhcp

Change the Kea control agent

Kea needs the host_cmds hook and a hosts database (MySQL or PostgreSQL) for reservations added through the agent to be kept. An empty keaUrl keeps reservations in Zappocity only; an empty keaPassword removes the password. After saving, the agent is asked for its version and a warning says when it did not answer. Recorded in the audit log.

Request
{
  "keaPassword": "correct-horse",
  "keaUrl": "http://10.0.0.53:8000/",
  "keaUser": "zappocity"
}
Responses
  • 200 The settings
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
POST /v1/platform/vps/dhcp/sync

Push every reservation to Kea again

After Kea was unreachable or its hosts database was replaced. Each address in a pool with a Kea subnet id is reserved again for its server's MAC. Recorded in the audit log.

Responses
  • 200 How many were pushed, and what failed (up to 20).
    {
      "failed": [
        "192.0.2.20: the server has no MAC"
      ],
      "pushed": 12
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
POST /v1/platform/vps/firewall/enforce

Put our part of every server's firewall back

For every linked server: the firewall on (on net0 and its own), the IP and MAC filters on, router advertisements off, the IP filter holding exactly its addresses, and port 25 blocked unless support opened it. Done by itself when a server is linked or built and when its addresses change; use this after a change made in Proxmox, or once for servers linked before the firewall. Recorded in the audit log.

Responses
  • 200 How many were done, and what failed.
    {
      "done": 12,
      "failed": [
        "Server 3 (VM 104): proxmox: 403 Permission check failed (/vms/104, VM.Config.Network)"
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
GET /v1/platform/vps/hatch

Hatch's settings

Hatch, the emergency SSH shell for servers' people: whether it is on, the address and port it listens on (empty address: all), the name people connect to, its host key's fingerprint (made the first time it runs, kept sealed), and where it is listening now ("" when it is not).

Responses
  • 200 The settings.
    {
      "address": "",
      "enabled": true,
      "fingerprint": "SHA256:k3yF1ngerprintExampleExampleExampleExample0",
      "host": "hatch.example.com",
      "listening": "[::]:2222",
      "port": 2222,
      "updatedAt": "2026-11-08T12:00:00Z"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
PATCH /v1/platform/vps/hatch

Change Hatch's settings

Fields left out stay. Taken on at once; problem says why it could not listen (a port in use). Recorded in the audit log.

Request
{
  "enabled": true,
  "host": "hatch.example.com",
  "port": 2222
}
Responses
  • 200 The settings now.
    {
      "address": "",
      "enabled": true,
      "fingerprint": "SHA256:k3yF1ngerprintExampleExampleExampleExample0",
      "host": "hatch.example.com",
      "listening": "[::]:2222",
      "port": 2222,
      "updatedAt": "2026-11-08T12:00:00Z"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/vps/ipv4

The IPv4 management network

management is the IPv4 network on the servers' bridge where each server with a routed IPv4 address gets a management address (from the tenth address up); gateway is the router's address in it, the servers' IPv4 gateway. Each routed address is routed as a /32 by prefixd via its server's management address. keaSubnetId is the network's subnet id in Kea's dhcp4 configuration, where each management address is reserved for its server's MAC (0 for none). bridge is the Proxmox bridge the network is on: builds put the first network card of servers with routed addresses on it ("" for the template's). Empty strings until set.

Responses
  • 200 The settings.
    {
      "bridge": "vmbr0",
      "gateway": "10.255.0.1",
      "keaSubnetId": 4,
      "management": "10.255.0.0/24",
      "updatedAt": "2026-11-08T12:00:00Z",
      "updatedBy": "amy@zappocity.test"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
PATCH /v1/platform/vps/ipv4

Change the IPv4 management network

Fields left out stay. management is an IPv4 network from /16 to /27 overlapping no IPv4 pool (a private one, such as 10.255.0.0/24, is usual); gateway is a host address in it. Both empty: none. The network and gateway cannot change while any server has a management address. Recorded in the audit log.

Request
{
  "bridge": "vmbr0",
  "gateway": "10.255.0.1",
  "keaSubnetId": 4,
  "management": "10.255.0.0/24"
}
Responses
  • 200 The settings.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/vps/ipv4-pools

The IPv4 pools

Responses
  • 200 The pools, with how many of their addresses are given out.
    {
      "items": [
        {
          "bridge": "vmbr0",
          "cidr": "192.0.2.0/24",
          "createdAt": "2026-11-06T12:00:00Z",
          "gateway": "192.0.2.1",
          "id": "3c9d2e1f-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
          "keaSubnetId": 1,
          "note": "rack 2",
          "reverseZone": "2.0.192.in-addr.arpa",
          "reverseZoneId": "0b6f8a2e-1c3d-4e5f-8a9b-0c1d2e3f4a5c",
          "routed": false,
          "used": 12
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /v1/platform/vps/ipv4-pools

Add an IPv4 pool

A network from /16 to /30, overlapping no other pool nor the IPv4 management network, with its gateway inside it. keaSubnetId is the id of the network's subnet in Kea's configuration; with one, the pool's addresses are reserved in Kea for their servers' MACs (0 keeps them in Zappocity only). A routed pool (routed true) has no gateway and no Kea subnet: each of its addresses is routed as a /32 via its server's IPv4 management address (set the management network with PATCH /v1/platform/vps/ipv4 first). Whether a pool is routed is set when it is made. An on-link pool's bridge is the Proxmox bridge its network is on: builds put the server's first network card (net0) on it. A server's IPv4 addresses share one bridge, so an address of a pool on another is refused. Recorded in the audit log.

Responses
  • 201 The pool.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
PATCH /v1/platform/vps/ipv4-pools/{poolId}

Change an IPv4 pool's gateway, Kea subnet, bridge or note

A pool's network cannot change, nor whether it is routed. A new bridge is used by builds from then on; servers already built keep their network cards. Recorded in the audit log.

ParameterInType
poolId *pathstring
Request
{
  "keaSubnetId": 2
}
Responses
  • 200 The pool.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/vps/ipv4-pools/{poolId}

Remove an IPv4 pool none of whose addresses are given out

ParameterInType
poolId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
  • 409 It already exists, the product refused the change, the resource belongs to another subscription, or an organization would lose its last owner.
GET /v1/platform/vps/ipv6

The IPv6 settings

management is the /64 on the servers' bridge where each server gets a management address; gateway is the router's address in it, the servers' IPv6 gateway; pool is the prefix (a /40, say) routed /64s are given from. Released addresses are held for graceDays before they are given out again. keaSubnet6Id is the management /64's subnet id in Kea's dhcp6 configuration, where each server's management address and /64 are reserved for its MAC (0 for none). reverseZone and managementReverseZone are the dnso.city zones the PTR records of the routed /64s and of the management addresses go in ("" for none); link a zone to Route53, or another provider, under its DNS account to have it pushed there. metadataVia is the metadata service's host, as GET /v1/platform/vps/metadata shows it (kept here for older clients; deprecated). bridge is the Proxmox bridge the management /64 is on ("" for the template's): when it is not the servers' IPv4 bridge, builds give IPv6 a network card of its own (net1, with ipconfig1), its DHCPv6 reservation is for that card's MAC (the server's mac6), and every card gets the firewall and an IP filter.

Responses
  • 200 The settings (empty strings until set).
    {
      "bridge": "vmbr1",
      "gateway": "2001:db8:ffff::1",
      "graceDays": 30,
      "keaSubnet6Id": 6,
      "management": "2001:db8:ffff::/64",
      "managementReverseZone": "f.f.f.f.8.b.d.0.1.0.0.2.ip6.arpa",
      "managementReverseZoneId": "0b6f8a2e-1c3d-4e5f-8a9b-0c1d2e3f4a5b",
      "metadataVia": "192.0.2.42",
      "pool": "2001:db8:1000::/40",
      "reverseZone": "0.0.0.1.8.b.d.0.1.0.0.2.ip6.arpa",
      "reverseZoneId": "0b6f8a2e-1c3d-4e5f-8a9b-0c1d2e3f4a5c",
      "updatedAt": "2026-11-06T12:00:00Z",
      "updatedBy": "amy@zappocity.test"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
PATCH /v1/platform/vps/ipv6

Change the IPv6 settings

Fields left out stay. The gateway must be in the management /64 below ::100, and the pool must not overlap it, unless the management /64 is the pool's first /64 (never given to a server), so that one block such as a /40 holds both. Assignments already made are kept. A reverse zone must cover all of its network: a primary zone on dnso.city (only staff can add a zone covering a VPS network). A new reverse zone gets the names and delegations already set, in the background. Recorded in the audit log.

Request
{
  "gateway": "2001:db8:ffff::1",
  "keaSubnet6Id": 6,
  "management": "2001:db8:ffff::/64",
  "pool": "2001:db8:1000::/40",
  "reverseZone": "0.0.0.1.8.b.d.0.1.0.0.2.ip6.arpa"
}
Responses
  • 200 The settings.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/vps/ipv6/held

Released /64s still in their grace days

Each was taken back from a server (or its server was removed) and is held, with its management address, until graceDays after releasedAt, so it isn't given to another server meanwhile.

Responses
  • 200 The held /64s, newest first.
    {
      "graceDays": 30,
      "items": [
        {
          "createdAt": "2026-10-01T12:00:00Z",
          "id": "5a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
          "management": "2001:db8:ffff::104",
          "prefix": "2001:db8:1000:5::/64",
          "releasedAt": "2026-11-08T17:20:00Z",
          "serverId": null
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /v1/platform/vps/ipv6/held/free

Free a held /64 now

Ends one released /64's grace days at once, so it and its management address can be given again (by name, with prefix and management on POST .../servers/{serverId}/ipv6). Only a held /64 is freed; one a server has is refused (take it back from the server first). Recorded in the log.

Request
{
  "prefix": "2001:db8:1000:5::/64"
}
Responses
  • 200 Freed.
    {
      "freed": {
        "management": "2001:db8:ffff::104",
        "prefix": "2001:db8:1000:5::/64"
      }
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 Not held (free already
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/vps/isos

The ISO images servers may boot from

Rescue systems and installers on the clusters' storage, that servers' people may boot their VMs from (rescue).

Responses
  • 200 The images, by name.
    {
      "items": [
        {
          "clusterId": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
          "clusterName": "dc1",
          "createdAt": "2026-11-08T12:00:00Z",
          "description": "",
          "id": "3f1c2a9e-7b4d-4e1a-9c2f-5d6e7f8a9b0c",
          "installer": false,
          "name": "SystemRescue 11",
          "volid": "local:iso/systemrescue-11.03-amd64.iso"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /v1/platform/vps/isos

List an ISO image

An ISO image already on a cluster's storage (upload it in Proxmox: the storage's ISO Images). Each node a server is on must see it: shared storage, or the same file on each node's. Recorded in the audit log.

Request
{
  "clusterId": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
  "description": "Installs Debian 12 over the console, with an encrypted disk if wanted",
  "installer": true,
  "name": "Debian 12 installer",
  "volid": "local:iso/debian-12-netinst-serial.iso"
}
Responses
  • 201 Listed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 An image has that name
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/vps/isos/{isoId}

Take an ISO image off the list

The file in Proxmox is not touched; servers booted from it stay so until rescue is ended. Recorded in the audit log.

ParameterInType
isoId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /v1/platform/vps/metadata

The metadata service

via is the IPv4 address of the Proxmox host running proxmox-metadata, which prefixd on each router routes address (169.254.169.254/32) to ("" for no route). clusters lists each Proxmox cluster with its metadataUrl: set, its VMs are pointed at the service through their SMBIOS serial; empty, they keep cloud-init drives (changed on the cluster, PATCH /v1/platform/vps/clusters/{clusterId}).

Responses
  • 200 The settings.
    {
      "address": "169.254.169.254/32",
      "clusters": [
        {
          "id": "6a1f0e2b-9c3d-4e5f-8a7b-1c2d3e4f5a6b",
          "metadataUrl": "http://169.254.169.254/nocloud/",
          "name": "dc1"
        }
      ],
      "updatedAt": "2026-11-08T12:00:00Z",
      "updatedBy": "amy@zappocity.test",
      "via": "192.0.2.42"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
PATCH /v1/platform/vps/metadata

Change the metadata service's host

Routes 169.254.169.254/32 on every router to via, a host's own IPv4 address (not loopback, link-local, multicast or broadcast); an empty via takes the route away. prefixd picks it up at its next sync. Recorded in the audit log.

Request
{
  "via": "192.0.2.42"
}
Responses
  • 200 The settings
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/vps/routers

The routers that run prefixd, and the Kea hosts that run kea-sync

With each one's role (router: prefixd, which reads the routes; dhcp: kea-sync on a Kea host, which reads the DHCP subnets and reservations), when it last read its feed, from where, and how many routes or reservations it was given.

Responses
  • 200 The routers.
    {
      "items": [
        {
          "createdAt": "2026-11-06T12:00:00Z",
          "id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
          "lastFrom": "198.51.100.7",
          "lastRoutes": 12,
          "lastSeen": "2026-11-06T12:00:30Z",
          "name": "edge1",
          "role": "router"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /v1/platform/vps/routers

Add a router or a Kea host

Its token is shown once, here. A router's (zr_...) reads the routes (GET /api/vps/routes) and nothing else; a Kea host's (role dhcp, zk_...) reads the DHCP subnets and reservations (GET /api/vps/dhcp) and nothing else. Only its SHA-256 is kept. Recorded in the audit log.

Responses
  • 201 The router and its token.
    {
      "router": {
        "id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
        "name": "edge1",
        "role": "router"
      },
      "token": "zr_Q2hhbmdlIG1lIHRvIGEgcmVhbCB0b2tlbiBwbGVhc2U"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/vps/routers/{routerId}

Remove a router

Its token stops working. The routes it added stay until prefixd is stopped and they are removed by hand, or the router restarts.

ParameterInType
routerId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /v1/platform/vps/routers/{routerId}/token

Replace a router's token

The old token stops working at once; the new one is shown once. Recorded in the audit log.

ParameterInType
routerId *pathstring
Responses
  • 200 The new token.
    {
      "token": "zr_Q2hhbmdlIG1lIHRvIGEgcmVhbCB0b2tlbiBwbGVhc2U"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
GET /v1/platform/vps/routes

The routes routers are given now

What prefixd on each router reads from GET /api/vps/routes (account API) with its own token; servers that are suspended have no route. metadata is the host 169.254.169.254 is routed to ("" for none). management4, gateway4, pools4 and routes4 are the routed IPv4: the management network, the router's address on it, the routed pools, and each routed address as a /32 via its server's management address.

Responses
  • 200 The routes.
    {
      "gateway": "2001:db8:ffff::1",
      "gateway4": "10.255.0.1",
      "management": "2001:db8:ffff::/64",
      "management4": "10.255.0.0/24",
      "metadata": "192.0.2.42",
      "pool": "2001:db8:1000::/40",
      "pools4": [
        "203.0.113.0/24"
      ],
      "routes": [
        {
          "prefix": "2001:db8:1000:1::/64",
          "serverId": "1f0c2b9e-3a5d-4a8e-9c1b-7d2e4f6a8b0c",
          "via": "2001:db8:ffff::100"
        }
      ],
      "routes4": [
        {
          "prefix": "203.0.113.10/32",
          "serverId": "1f0c2b9e-3a5d-4a8e-9c1b-7d2e4f6a8b0c",
          "via": "10.255.0.10"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
GET /v1/platform/vps/servers

Every server

Every VPS server, newest first, with its organization, plan, cluster and network (not the VM's state, which would ask Proxmox for each).

Responses
  • 200 The servers.
    {
      "items": [
        {
          "clusterName": "dc1",
          "features": [
            "power",
            "usage",
            "ipv6"
          ],
          "network": {
            "ipv4": [
              {
                "address": "192.0.2.10",
                "gateway": "192.0.2.1",
                "prefix": 24
              }
            ],
            "ipv6": null,
            "mac": "bc:24:11:aa:bb:cc"
          },
          "orgName": "Acme Inc",
          "server": {
            "clusterId": "6b1d...",
            "id": "1f0c2b9e-3a5d-4a8e-9c1b-7d2e4f6a8b0c",
            "kind": "qemu",
            "mac": "bc:24:11:aa:bb:cc",
            "name": "Server 1",
            "node": "pve1",
            "orgId": "0b6c...",
            "plan": "vps-small",
            "status": "active",
            "vmid": 101
          }
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
GET /v1/platform/vps/swip

How VPS blocks are registered at ARIN (SWIP)

mode is off; manual (staff register a block with POST /v1/platform/vps/swip/reassignments); size (each block given to a server whose prefix length is minV4Bits / minV6Bits or shorter, by itself, as ARIN requires: /29 and /47 by default); or all (every block given: each IPv4 address and each routed /64). environment is ARIN's production Reg-RWS or its test one (ote), whose API keys are its own. parents are our nets at ARIN that blocks are reassigned from. The API key is never shown; apiKeySet says whether there is one.

Responses
  • 200 The settings.
    {
      "apiKeySet": true,
      "environment": "production",
      "minV4Bits": 29,
      "minV6Bits": 47,
      "mode": "all",
      "netName": "ZAPPOCITY",
      "parents": [
        {
          "cidr": "192.0.2.0/24",
          "handle": "NET-192-0-2-0-1"
        },
        {
          "cidr": "2001:db8:1000::/40",
          "handle": "NET6-2001-DB8-1000-1"
        }
      ],
      "updatedAt": "2026-11-07T12:00:00Z",
      "updatedBy": "amy@zappocity.test"
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
PATCH /v1/platform/vps/swip

Change how VPS blocks are registered at ARIN

Fields left out stay. A mode other than off needs an API key; an empty apiKey removes it. The key is sealed and never shown or logged. Blocks already registered stay registered when the mode changes. Recorded in the audit log.

Request
{
  "apiKey": "API-1234-5678-9ABC-DEF0",
  "environment": "ote",
  "mode": "all",
  "parents": [
    {
      "cidr": "192.0.2.0/24",
      "handle": "NET-192-0-2-0-1"
    }
  ]
}
Responses
  • 200 The settings.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
GET /v1/platform/vps/swip/reassignments

The blocks registered at ARIN

Every block registered, or being registered (pending), updated (update: the organization's whois choice or billing address changed) or removed (removing: the block went back), with ARIN's handles and the last error. Filter with ?orgId=.

ParameterInType
orgIdquerystring
Responses
  • 200 The registrations.
    {
      "items": [
        {
          "createdAt": "2026-11-07T12:00:00Z",
          "createdBy": "amy@zappocity.test",
          "customerHandle": "C01234567",
          "error": "",
          "failures": 0,
          "id": "5e1c2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
          "net": "192.0.2.10/32",
          "netHandle": "NET-192-0-2-10-1",
          "nextTryAt": "2026-11-07T12:00:00Z",
          "orgId": "3c9d2e1f-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
          "orgName": "Acme Inc",
          "parentHandle": "NET-192-0-2-0-1",
          "serverId": "7a8b9c0d-1e2f-4a5b-8c6d-7e8f9a0b1c2d",
          "status": "registered",
          "updatedAt": "2026-11-07T12:00:00Z"
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /v1/platform/vps/swip/reassignments

Register one of a server's blocks at ARIN

The block must be exactly one the server was given (an IPv4 address as /32, or its routed /64), and SWIP not off. The job registers it within minutes (or POST /v1/platform/vps/swip/run): a customer under the parent net covering it, as the organization chose for whois, then a simple reassignment. Recorded in the audit log.

Request
{
  "net": "2001:db8:1000:1::/64",
  "orgId": "3c9d2e1f-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
  "serverId": "7a8b9c0d-1e2f-4a5b-8c6d-7e8f9a0b1c2d"
}
Responses
  • 201 The registration
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 422 A value is out of range or names something that does not exist.
DELETE /v1/platform/vps/swip/reassignments/{reassignmentId}

Remove a block's registration at ARIN

The job removes the reassignment and its customer at ARIN, then forgets it (one never registered there is forgotten at once). Releasing a block does the same by itself. Recorded in the audit log.

ParameterInType
reassignmentId *pathstring
Responses
  • 204 Being removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.
POST /v1/platform/vps/swip/run

Do the work due at ARIN now

Registers, updates and removes every block waiting, without waiting for the job or the failures' back-off.

Responses
  • 200 How many went through and how many failed (their errors are on the registrations).
    {
      "done": 3,
      "failed": 1
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
GET /v1/platform/vps/templates

The templates servers are built from

Responses
  • 200 The templates, by name.
    {
      "items": [
        {
          "ciUser": "debian",
          "clusterId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
          "clusterName": "dc1",
          "createdAt": "2026-11-07T12:00:00Z",
          "id": "0b6f3a52-8f0e-4d1c-9a7b-2c3d4e5f6a7b",
          "name": "Debian 12",
          "nameservers": "192.0.2.53 2001:db8::53",
          "node": "pve1",
          "storage": "local-lvm",
          "vmid": 900
        }
      ]
    }
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
POST /v1/platform/vps/templates

Add a template

A VM template of a cluster (not a container), which must have a cloud-init drive. Its node is read from Proxmox. storage is where clones' disks go (empty: the template's own); ciUser the cloud-init user (empty: the image's own); nameservers at most three addresses. Recorded in the audit log.

Request
{
  "ciUser": "debian",
  "clusterId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "name": "Debian 12",
  "nameservers": "192.0.2.53 2001:db8::53",
  "storage": "local-lvm",
  "vmid": 900
}
Responses
  • 201 The template.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 409 A template has that name
  • 422 A value is out of range or names something that does not exist.
  • 502 Proxmox refused or could not be reached.
DELETE /v1/platform/vps/templates/{templateId}

Remove a template

Servers built from it keep their VMs; the VM in Proxmox is not touched. Recorded in the audit log.

ParameterInType
templateId *pathstring
Responses
  • 204 Removed.
  • 401 No valid platform API key or admin panel session.
  • 403 The staff account lacks the permission, or the caller is a team.
  • 404 No such object.