Custom hostnames API
The machine seam for a hosted app whose customers bring their own domains — register a hostname, SpipCP creates the DNS record when it controls the zone or hands back exact records to relay. Site-bound tokens, register/status/list/remove, and the polling recipe. For app authors.
When your app's end-customer brings their own domain — a status page on status.client1.com, a
storefront on shop.client2.ie — someone still has to create a DNS record. This API is the machine
seam that closes that gap: your app registers the hostname, and SpipCP either creates the record
itself (when it controls the zone) or hands back the exact records to show the customer.
Division of labour
Your app verifies the customer owns the hostname (a token flow, a DNS check — however you already do it) and answers the ask endpoint that gates certificate issuance. SpipCP does the DNS write (when it can), the certificate (lazily, on the first HTTPS request), and the routing. Neither side re-derives the other's truth.
Auth — a site-bound token
Every call needs a site-bound spat_… token: minted on the site's Networking → Custom
hostnames card, scoped to custom-hostnames permissions only, bound to exactly one site. It can
never reach another site or any fleet-wide surface — a leaked token's blast radius is one site's
DNS automation, nothing else.
Authorization: Bearer spat_xxxxxxxxDeny-by-default: the automated-zones allow-list
Your token can only trigger a DNS write into a zone the operator explicitly allow-listed on the
card. A hostname in a zone SpipCP fully controls but that isn't allow-listed still comes back as
instructions, never a write — the operator has to opt each zone in on purpose. "*" (every
zone this installation controls) is accepted but shown with a loud warning; most installs list
specific zones.
The four calls
Reachable as typed oRPC (client.customHostnames.*) and as REST on /api/v1 — see
the transport note for how both doors share one procedure.
Every mutation is audited.
register
{ siteId, hostname } → { id, hostname, tier, state, records, reason? }
curl -X POST https://panel.example.com/api/v1/customHostnames/register \
-H "Authorization: Bearer spat_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "siteId": "<site-uuid>", "hostname": "status.client1.com" }'state is one of:
| State | Meaning |
|---|---|
pending | SpipCP is writing the record (Hosted or Connected + allow-listed). Poll status. |
active | Idempotent re-register of an already-live hostname — the current record set is returned. |
instructions | Nothing was written. records is what to show the customer; see reason below. |
reason (only on instructions):
| Reason | Meaning |
|---|---|
external-zone | SpipCP doesn't control this hostname's zone at all — always instructions. |
zone-not-allow-listed | SpipCP controls the zone, but the operator hasn't allow-listed it yet. |
ambiguous-zone | More than one connected account holds this zone — SpipCP refuses to guess which one to write into. |
Hostname validation: lowercase FQDN, ≤253 chars, no wildcard, not a bare IP. 409 if the hostname
collides — already attached to a site as an ordinary domain, or already registered to a different
site. 429 if you're over the rate limit (below). Re-registering the exact same (siteId, hostname) is safe and returns the current row.
status
{ siteId, hostname } → { state, tier, records, dns: { resolves, checkedAt }, cert: { issued, notAfter?, observedAt? }, failureRemedy? }
dns.resolves is a cached, short-timeout lookup asserting the hostname resolves to the planned
target — advisory, never blocking (a slow resolver just means a stale false a little longer, never
a hang). cert joins the node's observed certificate facts; issuance is lazy (see the polling
recipe below), so cert.issued only flips to true after your app primes the first handshake.
failureRemedy is set only when state is failed or removal-manual.
list
{ siteId } → { hostnames: Row[] } — every hostname registered for the site, newest-first.
remove
{ siteId, hostname } → { state: "removed" | "removing" }
Deletes only the records SpipCP itself created — never a guess at what might be yours. A hostname that
never had a record written (an external/instructions row) is deleted immediately → removed. A
Hosted/Connected hostname returns removing and the actual record deletion runs as a background job;
poll status for the terminal outcome — the row disappears (removed) or lands on
removal-manual when the DNS provider has no automated-delete capability yet, in which case its
failureRemedy carries the records to remove by hand. Certificate removal is never attempted —
disabling a hostname is not revoking its cert; already-issued certs expire naturally and harmlessly.
The polling recipe
Register the hostname. If state is instructions, render records to your customer and stop —
re-register later (after they add the record, or after the operator allow-lists the zone) is safe.
Poll status until dns.resolves is true. This can take a few seconds (Hosted writes go
through a worker job) up to however long the customer takes to act (External/instructions cases).
Make ONE HTTPS request to the hostname — yours, or wait for the customer's browser to make one. This primes the lazy first handshake; SpipCP's on-demand TLS issues the certificate at that moment.
Poll status again until cert.issued is true, then mark the domain active in your app.
Hard requirements + failure honesty
- 409 — a collision (already attached, or registered to a different site). Not retryable without changing the hostname.
- 429 — over the per-token rate limit (120 registrations/hour by default). The response carries a
Retry-Afterhint. failed+failureRemedy— a Hosted/Connected write was attempted and genuinely failed (a box unreachable, a bad credential). The remedy is the actual error, not a generic message — surface it or retry later; the row never gets silently stuck.removal-manual— seeremoveabove. Never a silent orphan: the row survives with the records to clean up by hand in itsfailureRemedy, observable viastatus. This also covers a Hosted removal where a nameserver box rejected the delete — the row won't vanish while the record still resolves.- Rotate the token on suspicion. Mint a new one, swap it into your app's environment, then revoke the old one from the site card.
Reference implementation
SpipUptime is the first consumer of this API; SpipRoadmap is next. See the ask contract for the certificate-issuance half, and Custom hostnames for the operator-side story (enable → allow-list → mint token → watch the table).
On-demand ask contract
The frozen contract an app implements to authorize on-demand customer-domain certificates — a single GET the node calls before Caddy issues, 200 to allow, anything else to deny. For app authors.
Panel disaster recovery
How the panel backs ITSELF up (pg_dump + the boot secrets, encrypted offsite) and how to restore it into a fresh VM in under 30 minutes — the migrate-panel runbook.
