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 an 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: the 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
The app verifies the customer owns the hostname (a token flow, a DNS check — whatever method it already uses) 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
A 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 five 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; probe writes nothing, so it is not.
probe
{ siteId, hostname } → { hostname, verdict, reason, tier, zoneName, records, cnameTarget, registered }
The first call an app makes, and the only read-only one: what would register do for this
hostname? It runs the same planner, collision guard and allow-list check register runs, and
writes nothing at all — no row, no job, no audit entry. The point is the customer-facing branch: the
moment someone types status.client1.com, the app can say "DNS is handled automatically" or render
the exact records to add — before asking them to prove they own it.
curl -X POST https://panel.example.com/api/v1/customHostnames/probe \
-H "Authorization: Bearer spat_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "siteId": "<site-uuid>", "hostname": "status.client1.com" }'{
"hostname": "status.client1.com",
"verdict": "auto",
"reason": null,
"tier": "hosted",
"zoneName": "client1.com",
"records": [
{ "type": "CNAME", "name": "status.client1.com", "value": "app.example.com", "ttl": 300 }
],
"cnameTarget": "app.example.com",
"registered": false
}verdict | Meaning |
|---|---|
auto | register would create the records itself — the zone is Hosted or Connected and allow-listed. |
instructions | register would hand back records for the customer to create. reason says why. |
blocked | register would refuse. Today that means a collision; future refusals extend the same field. |
reason | With verdict | Meaning |
|---|---|---|
null | auto | Nothing in the way. |
external-zone | instructions | This hostname's zone isn't controlled here at all. |
zone-not-allow-listed | instructions | The zone is controlled, but the operator hasn't allow-listed it. |
ambiguous-zone | instructions | More than one connected account holds the zone. |
collision | blocked | Already attached to a site, or registered to a different site. |
records is always the planned set — identical to what register would write or hand out — so an
app renders it once, whatever the verdict. registered is true when this site already holds a row
for the hostname; the probe still returns the live plan, and status is where its state lives.
Everything answers in band: a collision comes back as verdict: "blocked", not a 409. A probe is
a question, and every answer to it is a normal response.
Compatibility — never hard-require the probe
A panel older than this call answers the transport's not-found error. Treat that as "probe
unavailable" and fall back to the original flow: verify ownership, call register, and branch on
its response. An app that hard-requires probe breaks against an older panel for no reason — the
probe is an improvement to the timing of the branch, not a new capability.
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 the call is 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 the 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 belong to somebody else. 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 recommended flow
Probe the hostname the moment it is entered, and branch the interface on the verdict: auto →
"DNS is handled automatically, nothing to do"; instructions → render records for the customer to
create; blocked → surface the collision plainly (the hostname is already in use here). Nothing has
been written and nothing has been asked of the customer yet.
Verify the customer owns the hostname — the app's own flow, unchanged and still mandatory. SpipCP does not verify ownership and never will; the probe is what lets this step be asked for at the right moment, with the right expectations already set.
Register the hostname. If state is instructions, render records to the customer and stop —
re-registering 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 — from the app, 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 the 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. The response carries a
Retry-Afterhint. The two windows are separate, so a keystroke-adjacent probe can never eat a registration's budget:Call Limit per token probe600 / hour register120 / hour -
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 the 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.
