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 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_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

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
}
verdictMeaning
autoregister would create the records itself — the zone is Hosted or Connected and allow-listed.
instructionsregister would hand back records for the customer to create. reason says why.
blockedregister would refuse. Today that means a collision; future refusals extend the same field.
reasonWith verdictMeaning
nullautoNothing in the way.
external-zoneinstructionsThis hostname's zone isn't controlled here at all.
zone-not-allow-listedinstructionsThe zone is controlled, but the operator hasn't allow-listed it.
ambiguous-zoneinstructionsMore than one connected account holds the zone.
collisionblockedAlready 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:

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 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.

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-After hint. The two windows are separate, so a keystroke-adjacent probe can never eat a registration's budget:

    CallLimit 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 — 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 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 this page