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.
When an app enables Custom customer domains, the node's Caddy asks the
app before it issues a certificate for an unknown hostname. This page is the frozen contract that
ask endpoint must satisfy. It's for app authors — the operator side is the
Custom customer domains page.
This endpoint authorizes SERVING — the DNS step has its own API
Answering ask only tells Caddy whether to issue a certificate. It says nothing about who creates
the customer's DNS record — that's the Custom hostnames API,
a separate machine seam your app can use to register a hostname and let SpipCP write the record
when it controls the zone.
The request
The node calls your endpoint over plain HTTP, across the instance bridge network (not through Caddy, so there's no TLS chicken-and-egg):
GET <askPath>?domain=<hostname>- 200 → allow issuance (the response body is ignored).
- anything else → deny. A
4xx, a5xx, a timeout, a connection refused — all deny.
There is no authentication header. The path is whatever you configured on the card (default
/api/v1/caddy/ask).
Hard requirements
Your ask endpoint is the only gate between internet scanner noise (random SNIs hitting port 443) and your ACME rate limits. A slow or fail-open endpoint is a security bug, not a nuisance:
- Answer from a local, indexed lookup only — e.g. a unique index on
(customDomain, verified). No outbound calls. Target under 10 ms. - Deny by default. Unknown, unverified, malformed, empty, over-long, or non-hostname input → a non-200. Never return 200 on an error path.
- Tolerate bursts without falling over into a 200, and don't rate-limit legitimate handshakes into false denials.
- Idempotent and side-effect-free. The node may call it more than once for the same hostname.
Unauthenticated is acceptable — the endpoint leaks only "is X a customer domain of yours" — but it must hold the line above under load.
A minimal handler
An indexed lookup against your own "verified customer domains" table, denying everything else:
// Hono / Express-style — the whole handler is one indexed read.
app.get("/api/v1/caddy/ask", async (c) => {
const domain = c.req.query("domain")?.toLowerCase() ?? "";
// Reject obviously-bad input before touching the DB (deny by default).
if (!domain || domain.length > 253 || !/^[a-z0-9.-]+$/.test(domain)) {
return c.body(null, 400);
}
// Local, indexed lookup: is this a VERIFIED customer domain?
const ok = await db.customDomains.exists({ domain, verified: true }); // unique index
return ok ? c.body(null, 200) : c.body(null, 403);
});That's the entire contract: one fast, deny-by-default lookup. Your app owns the truth of which hostnames are yours (you verified ownership your own way — a token flow, a DNS check, whatever); the node only asks "did the app say yes."
Reference implementation
SpipUptime implements this endpoint in its own redesign — it is the first consumer of custom customer domains and the reference for the contract.
Monitoring API
The /api/v1 probes surface — list, get, history, watch (live), create, update, pause, archive — plus the public token-authed heartbeat ping endpoint.
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.