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_xxxxxxxxA 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
| Permission | Grants |
|---|---|
domains:view | List + get domains, the driver list, list routes, read DNS servers/zones. Held by operators. |
domains:manage | Attach, renew, detach, the orange-cloud toggle, test-credentials (admin-only). |
routes:manage | Open a raw port, reconcile, remove a route (admin-only). |
settings:edit | Save the (encrypted) DNS provider credentials, manage nameserver nodes + zones (admin-only). |
custom-hostnames:view | Read a registered hostname's plan/status, list a site's registered hostnames. Held by operators. |
custom-hostnames:manage | Register 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 thenameserverrole, which can't host instances or sites),installPowerDns,verifyGlue(re-checks the registrar glue for the NS domain), andremove. 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
| Step | Call | Notes |
|---|---|---|
| 1 | POST /api/v1/dnsServers/zones/records/upsert | Self-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. |
| 2 | POST /api/v1/domains/attach | Attach the domain to the site + run the flow (DNS verify → route → ACME → smoke). Returns { domainId, hostname }. |
| 3 | POST /api/v1/domains/get | Poll { 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=managedMobile-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".