SpipCP
Reference

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, a 5xx, 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.

On this page