SpipCP
Reference

Domains & Routes API

The /api/v1 domains + routes surface — list, get, attach, renew, detach, the orange-cloud toggle, driver list, test-credentials, provider DNS accounts, the self-hosted nameserver/zone endpoints, and the http/raw route CRUD.

Everything the panel does is available on the API. The web wizard and any REST or mobile client all call the same procedures. This page documents the domains and routes surfaces. Each procedure requires a permission, and the server enforces it — the access rules below are real, not just documentation.

Transport

Procedures are reached over oRPC at /api/rpc/* (typed client) and exposed on /api/v1 (OpenAPI). The OpenAPI document is served at GET /api/v1/openapi.json — generated from the same router that handles the requests, so it always matches the live API. Un-routed procedures fall back to POST /api/v1/<dotted.path>.

Authentication — session cookie or a bearer token

A browser client authenticates with the better-auth session cookie. A machine client (a script, CI job, or mobile app) instead sends an API token as a bearer header:

Authorization: Bearer spat_xxxxxxxx

A token authenticates as a synthetic principal whose effective permissions are the intersection of the token's chosen subset and the role of the admin who created it. The per-procedure permission checks below then apply unchanged: a call outside the token's scope is 403; a revoked or expired token is 401.

Permissions

PermissionGrants
domains:viewList + get domains, the driver list, list routes, read DNS servers/zones. Held by operators.
domains:manageAttach, renew, detach, the orange-cloud toggle, test-credentials (admin-only).
routes:manageOpen a raw port, reconcile, remove a route (admin-only).
settings:editSave the (encrypted) DNS provider credentials, manage nameserver nodes + zones (admin-only).
custom-hostnames:viewRead a registered hostname's plan/status, list a site's registered hostnames. Held by operators.
custom-hostnames:manageRegister or remove a hostname, mint a site-bound app token (admin-only).

Hostnames your app's customers bring

The permissions above are for domains you attach. If your app's own customers bring their domains, that's a separate machine seam — see the Custom hostnames API.

Operators hold only domains:view (browse domains/routes + their state). Attaching domains, editing routes, toggling orange-cloud, saving provider credentials, and managing self-hosted nameservers/zones are admin-only — those procedures return 403 for an operator.

Domains — read (domains:view)

domains.list

domains:view{ siteId } → { domains: DomainRow[], target: string | null }. Every domain attached to a site (newest-first), plus the node's resolved DNS target (the IP/CNAME the records must point at) so a client can render "create an A record → <ip>" without a second call. 404 if the site is unknown.

DomainRow fields: id, siteId, routeId, hostname, driver, dnsState, issuer, certState, certExpiresAt, proxied, failureRemedy, verification, createdAt, updatedAt. dnsState is pending | verified | failed; certState is none | pending | issued | renewing | failed. failureRemedy is set only when a step failed.

domains.get

domains:view{ id } → DomainRow & { nextStep: { title, detail } | null }. One domain's live state. nextStep is the "Set up backups" prompt — shown only when the domain is fully green (dnsState: verified and certState: issued). 404 if unknown. Poll this to watch an attach progress to green.

domains.drivers

domains:view{} → { drivers: { id, label, supportsProxy }[], issuers: ["pebble", "letsencrypt"] }. The DNS drivers + ACME issuers for the attach wizard's selects. Drivers are manual plus every configured provider (cloudflare, bunny, hetzner, desec, gcore, and selfhosted when a nameserver fleet exists). supportsProxy is true only for cloudflare — the wizard hides the orange-cloud toggle for the rest.

Domains — manage (domains:manage / settings:edit)

domains.attach

domains:manage

{ siteId, hostname: FQDN, driver?: "manual" | "cloudflare",
  issuer?: "pebble" | "letsencrypt", proxied?: boolean }
  → { domainId, hostname }

Creates the domain and starts its attach atomically — if anything fails, both roll back. SpipCP runs the flow step by step: driver check → DNS verify → route setup → certificate → smoke test → green, or failed + remedy if any step fails (it never reports a false green). The node's DNS target is resolved here (returns 409 if the site's instance or node is gone). Audited as domain.attach.

domains.renew

domains:manage{ id } → { renewing: true, id }. Re-triggers issuance (certState issued → renewing → issued, a new serial, no DNS-verify gate). 404 if unknown, 409 if the chain is gone. Audited as domain.renew.

domains.setProxied

domains:manage{ id, proxied: boolean } → { proxied, reattachToApply }. Flips the Cloudflare orange-cloud toggle for one domain. The flag is recorded now; for a Cloudflare-managed domain reattachToApply is true, meaning the change applies on the next attach. Audited as domain.set-proxied.

domains.detach

domains:manage{ id } → { detached: boolean }. Removes the domain. The cert-expiry probe auto-archives on its own; the route is torn down on the next node reconcile. 404 if unknown. Audited as domain.detach.

domains.testCredentials

domains:manage{ accountId } | { driver } → { ok: boolean, detail: string }. The per-account "test" button. manual is always ok (no credentials); a provider account verifies its token against the provider, reading the encrypted stored credential.

DNS provider accounts

DNS provider accounts use the same shared accounts surface as git and SSL-issuer accounts, and resolve by the node → instance → domain cascade. Reads return only non-sensitive fields; the token is write-only.

DnsAccountRow fields: id, name, provider (cloudflare | bunny | hetzner | desec | gcore), zoneId (nullable — providers that key off the domain name leave it null), apiBaseUrl (the provider's API endpoint, e.g. a self-hosted or regional override), lastTestState, createdAt, updatedAt. The token is never returned — reads carry only a masked placeholder (hasToken: true); you send the cleartext once on create/update and it's stored encrypted. Writes need settings:edit; the cascade defaults on node/instance/domain are set through the same accounts router.

Self-hosted nameservers & zones

The self-hosted path (PowerDNS on dedicated nameserver nodes) has its own surfaces, separate from the managed-provider accounts above. These power the Networking → Nameservers page. Records live in PowerDNS, not a panel table — the panel writes them to each box over its HTTP API.

dnsServers.nameservers.*

domains:view to read, settings:edit to manage. Lifecycle of the nameserver nodes: list (the boxes

  • their NS-health state), get, enroll (the "Add a nameserver" flow — a full node enrollment with the nameserver role, which can't host instances or sites), installPowerDns, verifyGlue (re-checks the registrar glue for the NS domain), and remove. A box's PowerDNS API key is placed on its own box and never returned to a client.

dnsServers.zones.*

domains:view to read, settings:edit to manage. Per owned domain on the self-hosted fleet: list, get, create (the panel pushes the zone to every NS box independently, so one box being down doesn't block the write), upsertRecord / deleteRecord (A/AAAA/CNAME/MX/TXT + the wildcard), setDnssec (toggle signing — PowerDNS holds the keys), getDsRecord (the DS to enter at the registrar), and verifyDelegation / verifyDs (checks that the registrar points ns1/ns2 here and the DS matches).

Routes

A route is how traffic reaches a site — an http Caddy reverse-proxy vhost (hostname → instance:port) or a raw port-forward (an Incus proxy device + UFW rule) sending a node-host TCP/UDP port to the instance. A route's status is pending | active | error | removed. The panel owns the node's Caddy config, so any hand-edits to it are overwritten — the routes are the source of truth.

routes.listForSite

domains:view{ siteId } → RouteRow[]. The routes serving a site (the workspace Routes view).

routes.listForInstance

domains:view{ instanceId } → RouteRow[]. Every route targeting an instance.

RouteRow fields: id, instanceId, siteId, kind, hostname, targetPort, listenPort, proto, deviceName, edgeConfig, status, configVersion, createdAt, updatedAt.

routes.createRaw

routes:manage

{ instanceId, siteId?, listenPort: 1..65535, targetPort: 1..65535, proto?: "tcp" | "udp" }
  → RouteRow & { reconcileEnqueued: true }

Opens a raw TCP/UDP port-forward (e.g. exposing a Valkey): records the route (kind raw, no hostname) and reconciles the node to add the proxy device + UFW rule. The http attach-domain route is created by domains.attach, not here. Audited as route.open-raw.

routes.reconcile

routes:manage{ id } → { reconcileEnqueued: true, nodeId }. Re-applies the route on its node (re-pushes the Caddy config, proxy devices, and UFW rules). Useful after a hand-edit. 404 if unknown. Audited as route.reconcile.

routes.remove

routes:manage{ id } → { removed: boolean, reconcileEnqueued: true }. Marks the route removed, reconciles the node to close the vhost or proxy device + UFW rule, then deletes it. 404 if unknown. Audited as route.remove.

Programmatic onboarding

You can onboard a new branded subdomain end to end over /api/v1 with an API token — no session cookie, no panel dependency, just fetch. Mint the token with the DNS onboarding preset (domains:view + domains:manage + settings:view), which is exactly what the sequence needs.

The sequence

StepCallNotes
1POST /api/v1/dnsServers/zones/records/upsertSelf-hosted only — write the record onto your PowerDNS cluster ({ id, name, type, ttl, values }). For a managed provider, skip this — the panel writes the record itself at attach-time.
2POST /api/v1/domains/attachAttach the domain to the site + run the flow (DNS verify → route → ACME → smoke). Returns { domainId, hostname }.
3POST /api/v1/domains/getPoll { id } until certState === "issued" and dnsState === "verified" (or stop on failed with failureRemedy).

The whole sequence is idempotent — re-running it for the same hostname reaches the same end state (the upsert is a PATCH-replace; attach upserts-or-returns the existing domain), so a retrying onboarding job is safe. Verify any path against the live contract at GET /api/v1/openapi.json.

Worked example

The repo ships a runnable example at scripts/onboard-subdomain.mjs that drives exactly this sequence for both postures:

# Self-hosted DNS (you create the record, then attach + issue):
PANEL_URL=https://panel.example.com \
SPAT_TOKEN=spat_xxxxxxxx \
SITE_ID=<site-uuid> \
HOSTNAME=app.roadmap.example.com \
ZONE=roadmap.example.com \
RECORD_TARGET=<node-public-ip> \
node scripts/onboard-subdomain.mjs --posture=self-hosted

# Managed DNS (the panel writes the record at attach-time):
PANEL_URL=... SPAT_TOKEN=... SITE_ID=... HOSTNAME=app.example.com \
node scripts/onboard-subdomain.mjs --posture=managed

Mobile-app ready

These are the same procedures the web attach wizard and Routes view call — a mobile client attaches, renews, detaches, and manages routes through this same surface. There's no separate "mobile API".

On this page