SpipCP
Reference

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_xxxxxxxx
The Custom hostnames card
📷Site → Networking → Custom hostnames — mint a site-bound token, the automated-zones allow-list, and the registered-hostnames table.img/sites-on-demand-api.avif
Site → Networking → Custom hostnames — mint a site-bound token, the automated-zones allow-list, and the registered-hostnames table.

Deny-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:

StateMeaning
pendingSpipCP is writing the record (Hosted or Connected + allow-listed). Poll status.
activeIdempotent re-register of an already-live hostname — the current record set is returned.
instructionsNothing was written. records is what to show the customer; see reason below.

reason (only on instructions):

ReasonMeaning
external-zoneSpipCP doesn't control this hostname's zone at all — always instructions.
zone-not-allow-listedSpipCP controls the zone, but the operator hasn't allow-listed it yet.
ambiguous-zoneMore 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-After hint.
  • 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 — see remove above. Never a silent orphan: the row survives with the records to clean up by hand in its failureRemedy, observable via status. 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 this page