# Documentation > Everything you need to let your SaaS customers use their own domain with Custom Domain API, from the first API call to verifying requests at your origin. Source: https://customdomainapi.com/docs/ Custom Domain API lets the customers of your SaaS use their own hostname, such as `forms.acme.com`, for their workspace in your product. Your backend registers the hostname with one API call. Your customer publishes two DNS records. The edge verifies them, issues the certificate, and forwards every request to your app with a **signed assertion** naming the workspace. ## Start here - **[Quickstart](/docs/quickstart/)**: from sign-up to a customer hostname served over HTTPS, on the Free plan. - **[How it works](/docs/how-it-works/)**: the edge, the four checks, and each status a domain goes through. - **[API reference](/docs/api/)**: endpoints, the domain resource, errors, idempotency and limits. - **[Verifying requests](/docs/verify-requests/)**: selecting the workspace safely at your origin, in Python or any language. - **[Webhooks](/docs/webhooks/)**: being told when a domain is ready, needs attention, recovers or is deleted. - **[Your customers' DNS](/docs/customer-dns/)**: what to show the people who add the records. - **[For AI coding agents](/docs/ai-agents/)**: a compact integration brief to hand to a coding assistant. - **[Self-hosting](/docs/self-hosting/)**: running the open-source service on your own server. ## The pieces | Piece | What it is | |---|---| | Application | Your product. It has an API key, an origin and its customers' domains. | | Domain | One customer hostname, registered for one workspace `reference` of yours. | | Edge | The server customers' hostnames point at. It terminates HTTPS and forwards to your origin. On Free it is `edge.customdomainapi.com`. | | Origin | Your app's backend, where the edge forwards requests. | | Assertion | A signed header on every forwarded request that names your application, the domain and the workspace. | ## Plans **Free** runs your application on a shared edge, for up to 25 customer hostnames, at no cost and without a card. **Paid plans** give you an edge of your own on your own domain, in Falkenstein (Germany) or Helsinki (Finland) on every paid plan, or in Ashburn (Virginia, USA) on the Scale plan only. **Self-hosting** runs the same open-source service ([sireto/custom-domain](https://github.com/sireto/custom-domain)) on your server. Your integration (API, SDK, verification) is the same on all three. For machines: every docs page is also available as Markdown at the same path ending in `.md` (for example [/docs/quickstart.md](/docs/quickstart.md)), and [/llms.txt](/llms.txt) lists them all. --- # Quickstart > Let a customer of your SaaS use their own hostname in about fifteen minutes on the Free plan, from sign-up to HTTPS, with the API and the Python SDK. Source: https://customdomainapi.com/docs/quickstart/ This guide takes one customer hostname from nothing to live over HTTPS, on the Free plan. You need a backend reachable on the internet over HTTPS (your **origin**), and a way to run a few lines of code. ## 1. Start Free Sign in at [app.customdomainapi.com](https://app.customdomainapi.com) with your email, open **Free** and start. No card is needed. Your application is set up in a few seconds. On Free, your customers point their hostnames at: ``` edge.customdomainapi.com ``` and your backend calls the API at: ``` https://edge.customdomainapi.com/v1 ``` ## 2. Add and verify your origin On the **Origin** tab, add your backend's hostname, for example `app.yourproduct.com`. The page shows a token and a URL. Make your origin answer that URL with exactly the token as a plain-text body: ``` GET https://app.yourproduct.com/.well-known/custom-domain-origin-verification → 200, body: ``` If your origin is behind a CDN (such as Cloudflare's proxy) or a reverse proxy that routes by hostname, tick **"behind a CDN or proxy"**: the edge then sends your origin's own name as `Host`, and your customer's hostname in `X-Forwarded-Host` ([why](/docs/how-it-works/#origins-behind-a-cdn-or-a-proxy)). Then press **Verify and use**. Traffic for your customers' hostnames goes to this origin from now on. ## 3. Get your assertion key On the same tab, press **Get your assertion key**. You see, once: - the **key id** (it starts with `app_`), - the **secret**, - your **application id**. Store the secret with your origin's secrets. Every request the edge forwards carries an `X-Custom-Domain-Assertion` header signed with this key; your origin verifies it and selects the workspace from it (step 6). ## 4. Create your API key On **API keys**, create your key. It is shown once. Free includes one key, which may make 60 requests a minute. Store it in your backend's secrets, for example as `CUSTOM_DOMAIN_API_KEY`. ## 5. Register a customer's hostname When a customer enters their hostname in your product, register it for their workspace: ```bash curl -X POST https://edge.customdomainapi.com/v1/domains \ -H "Authorization: Bearer $CUSTOM_DOMAIN_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: signup-ws_8f3a1c" \ -d '{"hostname": "forms.acme.com", "reference": "ws_8f3a1c"}' ``` Or with the Python SDK (`pip install custom-domain-sdk`): ```python from custom_domain import Client client = Client("https://edge.customdomainapi.com", credential=CUSTOM_DOMAIN_API_KEY) domain = client.create_domain( "forms.acme.com", # the customer's exact subdomain reference="ws_8f3a1c", # your workspace id; it comes back on every request idempotency_key="signup-ws_8f3a1c", ) print(domain.render_dns_instructions()) ``` The response contains `dns_records`: one **TXT** record that proves the customer owns the name and one **CNAME** that sends its traffic to the edge. Show both to your customer exactly as returned (see [Your customers' DNS](/docs/customer-dns/)). ## 6. Verify requests at your origin Your origin must select the workspace from the verified assertion, never from the `Host` header. With the Python SDK in FastAPI or Starlette: ```python import os from custom_domain import CustomDomainMiddleware app.add_middleware( CustomDomainMiddleware, keys={"app_…": os.environ["CUSTOM_DOMAIN_ASSERTION_SECRET"]}, # your key id and secret application_id="…", # your application id workspace_lookup=load_workspace, # returns the workspace for a reference, or None ) @app.get("/") def home(request): workspace = load_workspace(request.state.custom_domain.reference) ``` The middleware also answers `GET /.well-known/custom-domain-workspace`, which the service calls through the edge to prove that the hostname reaches the right workspace before it goes live. Other languages: see [Verifying requests](/docs/verify-requests/). ## 7. Wait for ready Once your customer has published the records, the service checks ownership, routing, the certificate and your origin. When all four pass, the domain's `status` becomes `ready` and the hostname is served over HTTPS. Poll `GET /v1/domains/{id}`, or subscribe to the `domain.ready` [webhook](/docs/webhooks/). If your customer fixes a record, ask for an early re-check with `POST /v1/domains/{id}/checks`. That's it. Repeat step 5 for every customer hostname, up to 25 on Free. --- # How it works > How Custom Domain API verifies a customer's hostname, issues its certificate and routes each request to the right workspace, and what each domain status and check means. Source: https://customdomainapi.com/docs/how-it-works/ ## The path of a request 1. **TLS.** A visitor opens `https://forms.acme.com`. The customer's CNAME sends them to the edge, which presents a certificate for that exact hostname. Certificates are issued from Let's Encrypt on demand, only for hostnames that passed verification, and renewed automatically. 2. **Route.** The edge finds the application the hostname belongs to. A hostname that belongs to no application gets a 404 and never reaches an origin. 3. **Assert.** The edge removes any `X-Custom-Domain-*` header the visitor sent, checks that the domain may be served right now, and adds a fresh signed `X-Custom-Domain-Assertion` naming your application, the domain and the workspace `reference`. 4. **Proxy.** The request goes to your origin with `Host` set to the customer's hostname (or, for an origin behind a CDN or a proxy, the origin's own name; see below), `X-Forwarded-Host` set to the customer's hostname, and `X-Forwarded-Proto: https`, so absolute URLs, cookies and redirects your app builds are correct. Your origin verifies the assertion and selects the workspace from its `reference` ([Verifying requests](/docs/verify-requests/)). It never trusts the `Host` header to pick a tenant. ## Origins behind a CDN or a proxy If your origin sits behind something that routes by hostname, it refuses requests for your customers' hostnames. That includes a CDN or WAF proxy (Cloudflare's orange cloud answers `403`) and a shared reverse proxy (nginx, Traefik, an ingress) with no route for them. The domain then fails the `origin` check with `workspace_probe_failed`. Tick **"behind a CDN or proxy"** when you add the origin, or switch it on the Origin page. The edge then sends: | | Normal | Behind a CDN or proxy | |---|---|---| | `Host` | `forms.customer.example` | your origin's name, such as `app.yourproduct.com` | | `X-Forwarded-Host` | `forms.customer.example` | `forms.customer.example` | Your CDN and proxy route the request like any other request to your origin. Your app reads the customer's hostname from `X-Forwarded-Host` (which the edge always sets, replacing any value the visitor sent). It verifies the assertion against that header ([Verifying requests](/docs/verify-requests/#origin-behind-a-cdn-or-a-proxy)). Make sure your proxy passes `X-Forwarded-Host` through unchanged. ## The four checks A domain goes live only when all four checks pass. They are re-checked regularly, and serving stops the moment one fails. | Check | Passes when | |---|---| | `ownership` | The TXT record `_custom-domain-challenge.` holds this registration's token. | | `routing` | The hostname's CNAME chain ends at your edge name (on Free, `edge.customdomainapi.com`). | | `certificate` | The edge serves a valid certificate for the hostname. | | `origin` | Your origin is verified, and answers `GET /.well-known/custom-domain-workspace` through the edge with the workspace it selected from the assertion. | Each check has a `status` (`pending`, `passing` or `failing`) and, when failing, a stable `error_code` and a `message` in plain words: | Check | `error_code` | Meaning | |---|---|---| | ownership | `txt_record_not_found` | No TXT record at that name yet. | | ownership | `txt_token_mismatch` | A TXT record exists, but not with this registration's value. | | ownership | `txt_token_stale` | The TXT holds the token of an earlier registration. | | routing | `cname_not_found` | No CNAME, or A/AAAA records instead of a CNAME. | | routing | `cname_target_mismatch` | The CNAME points somewhere else. | | either | `dns_timeout` | DNS didn't answer; retried automatically. | | origin | `origin_not_ready` | No verified origin yet. | | origin | `workspace_probe_failed` | The workspace path didn't answer 200. | | origin | `workspace_probe_invalid` | It answered 200 without a JSON `reference`. | | origin | `workspace_mismatch` | The origin selected another workspace: it isn't using the assertion. | Failing checks are retried after 1, 2, 5, 15 and 30 minutes, then hourly; passing ones every 6 hours. Ask for an early re-check with `POST /v1/domains/{id}/checks` after your customer fixes a record. ## Statuses | Status | Meaning | Served | |---|---|---| | `pending_dns` | Waiting for the TXT and CNAME records. | no | | `provisioning` | Records verified; certificate and origin checks running. | no | | `ready` | All four checks pass. | yes | | `attention_required` | A check that passed is failing again (often a changed CNAME). | no | | `suspended` | Ownership lost for 24 hours, or suspended by policy. | no | | `deleting` | Deleted. The hostname can be registered again at once. | no | `ready`, `attention_required`, recovering to `ready`, and deletion each send a [webhook](/docs/webhooks/). --- # API reference > The Custom Domain API v1 reference. Register customer hostnames, read their DNS records and checks, re-check and delete them, with idempotency, pagination, errors, limits and how bandwidth is counted. Source: https://customdomainapi.com/docs/api/ **Base URL:** `https://edge.customdomainapi.com/v1` on Free; on a paid or self-hosted edge, `https:///v1`. **Authentication:** every request sends your API key: ``` Authorization: Bearer cd_... ``` The application is derived from the key, never from a request field. Keys are not accepted in query strings or cookies. **Machine-readable contract:** the OpenAPI document is at [`/v1/openapi.json`](https://edge.customdomainapi.com/v1/openapi.json) on every edge, and in the repository as [docs/openapi.json](https://github.com/sireto/custom-domain/blob/main/docs/openapi.json). ## Endpoints | Method and path | Purpose | Success | |---|---|---| | `POST /v1/domains` | Register a hostname for a workspace. | `201`, or `200` on an idempotent replay | | `GET /v1/domains` | List domains. Filters: `reference`, `status`, `include_deleted`. Pages: `limit` (1 to 200, default 50), `offset`. | `200` | | `GET /v1/domains/{id}` | One domain with its current checks. | `200` | | `POST /v1/domains/{id}/checks` | Re-run all checks now. | `202` | | `DELETE /v1/domains/{id}` | Stop serving and delete. | `202` | | `POST /v1/webhooks` | Subscribe to events ([Webhooks](/docs/webhooks/)). | `201` | | `GET /v1/webhooks` | List subscriptions. | `200` | | `POST /v1/webhooks/{id}/rotate` | New signing secret. | `200` | | `DELETE /v1/webhooks/{id}` | Revoke a subscription. | `200` | ## Register a domain ``` POST /v1/domains Authorization: Bearer cd_... Idempotency-Key: signup-ws_8f3a1c Content-Type: application/json {"hostname": "forms.acme.com", "reference": "ws_8f3a1c", "metadata": {"plan": "pro"}} ``` - `hostname`: the customer's exact subdomain. It's normalized to lowercase punycode. Apex domains (`acme.com`), wildcards and IP addresses are refused. - `reference`: your opaque workspace id (up to 255 characters). It is returned verbatim and delivered to your origin in the assertion. - `metadata`: optional, up to 32 scalar values, echoed back. Response `201`: ```json { "id": "6f1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f", "hostname": "forms.acme.com", "reference": "ws_8f3a1c", "status": "pending_dns", "dns_records": [ {"name": "_custom-domain-challenge.forms.acme.com", "type": "TXT", "value": "custom-domain-verify=…", "purpose": "ownership", "help": "…"}, {"name": "forms.acme.com", "type": "CNAME", "value": "edge.customdomainapi.com", "purpose": "routing", "help": "…"} ], "checks": [ {"type": "ownership", "status": "pending", "error_code": null, "message": null, "observed_at": null, "next_check_at": null}, {"type": "routing", "status": "pending"}, {"type": "certificate", "status": "pending"}, {"type": "origin", "status": "pending"} ], "metadata": {"plan": "pro"}, "created_at": "2026-10-04T14:00:00Z", "updated_at": "2026-10-04T14:00:00Z", "deleted_at": null } ``` Show both `dns_records` to your customer exactly as returned. Statuses and checks are explained in [How it works](/docs/how-it-works/). ## Idempotency Send `Idempotency-Key` (up to 255 characters, unique per application) on `POST /v1/domains`, and retries are safe: - the same key and body returns the first result with `200` and `Idempotent-Replayed: true`; - the same key with a different body is `422 idempotency_key_reused`; - a retry while the first is still running is `409 idempotency_request_in_progress`; - keys expire after 24 hours. ## Errors Every error has the same shape: ```json {"error": {"code": "hostname_already_claimed", "message": "forms.acme.com is already claimed", "details": {}}} ``` | Status | `code` | When | |---|---|---| | 401 | `unauthorized` | Missing, unknown, revoked or expired key. | | 403 | `application_suspended` | Your application is suspended. | | 404 | `domain_not_found` | No such domain in your application. Another application's domains are never visible. | | 409 | `hostname_already_claimed` | The hostname is live in this or another application. | | 409 | `domain_limit_reached` | Your application is at its domain limit (25 on Free). Existing domains keep working. | | 409 | `invalid_status_transition` | Not possible in the domain's status, such as re-checking a deleted domain. | | 409 | `idempotency_request_in_progress` | The first request with this key hasn't finished. | | 422 | `validation_error` | The body or query doesn't match the schema; `details.errors` lists where. | | 422 | `apex_not_supported`, `wildcard_not_supported`, `ip_literal_not_supported`, `invalid_hostname`, `invalid_label`, `hostname_too_long`, `empty_hostname` | The hostname can't be registered. | | 422 | `invalid_reference` | The reference is empty or longer than 255 characters. | | 422 | `idempotency_key_reused` | The key was used with a different body. | | 429 | `rate_limited` | Too many requests; wait `Retry-After` seconds. | ## Limits on Free | Limit | Value | |---|---| | Customer hostnames | 25 | | API keys | 1 (two during a rotation's 24-hour overlap) | | API requests | 60 a minute per key | | Proxied requests | 600 a minute and 100 a second, across all your hostnames; over either, visitors get `429` with `Retry-After` | | Manual re-checks | 1 per domain a minute, 60 per application an hour | | Bandwidth | 100 GB a month (UTC calendar month) of response bodies sent to visitors; headers and request bodies don't count | | Above 100 GB | Proxied requests are limited to 60 a minute and 5 a second until the month ends; over either, `429` with `Retry-After`. We email you at 80% and at 100%. Nothing is billed, deleted or switched off. | Paid plans have their own edge, with more domains, 1 TB to 20 TB of bandwidth a month (then $0.05 per GB) and no proxied-request limit of ours. ## Bandwidth Bandwidth is the response bodies the edge sends to visitors; request bodies and headers don't count. 1 GB is 1,000,000,000 bytes and 1 TB is 1,000 GB. The edge counts response bodies per hostname, from its own counters, and they are added up per application and UTC day. A few minutes of traffic around an edge restart or a change to its configuration may go uncounted; nothing is counted twice. | Plan | Included each month | Above the allowance | |---|---|---| | Free | 100 GB, per UTC calendar month | Not billed: proxied requests are limited to 60 a minute and 5 a second until the month ends | | Starter | 1 TB | $0.05 per whole GB | | Growth | 10 TB | $0.05 per whole GB | | Scale | 20 TB | $0.05 per whole GB | On a paid edge, all its applications share the plan's allowance. A billing period counts the UTC days from its start date up to, but not including, its end date, so every day counts in exactly one period. What is above the allowance is billed per whole GB, rounded down. On a yearly plan, extra bandwidth and extra domains are billed monthly, on a separate invoice. ## SDK The Python SDK wraps all of the above, with typed errors and safe retries: ``` pip install custom-domain-sdk ``` See the [SDK README](https://github.com/sireto/custom-domain/blob/main/sdk/README.md). --- # Verifying requests > How your origin verifies the edge's signed X-Custom-Domain-Assertion header and selects the workspace from it, with the Python SDK, a Node.js example and the exact algorithm for any language. Source: https://customdomainapi.com/docs/verify-requests/ Every request the edge forwards to your origin carries: ``` X-Custom-Domain-Assertion: v1... ``` It says which application, domain and workspace the edge routed the request for. **Select the workspace from the verified assertion, never from the `Host` header**: a hostname alone doesn't prove which workspace may be served. You need two values, both on the **Origin** page of your application: your **application id**, and your **assertion key** (its id, starting with `app_`, and its secret). ## Python (FastAPI, Starlette, any ASGI app) ```python import os from custom_domain import CustomDomainMiddleware app.add_middleware( CustomDomainMiddleware, keys={"app_…": os.environ["CUSTOM_DOMAIN_ASSERTION_SECRET"]}, application_id=os.environ["CUSTOM_DOMAIN_APPLICATION_ID"], workspace_lookup=load_workspace, # reference -> workspace, or None on_missing="reject", # "passthrough" if this app also serves its own domain ) @app.get("/") def home(request): assertion = request.state.custom_domain workspace = load_workspace(assertion.reference) ``` Any other Python framework: ```python from custom_domain import WorkspaceResolver, AssertionInvalid resolver = WorkspaceResolver(keys={"app_…": SECRET}, application_id=APPLICATION_ID) try: assertion = resolver.resolve(request.headers, request.host) except AssertionInvalid as exc: return forbidden(exc.code) # missing, expired, bad_signature, wrong_application, ... workspace = load_workspace(assertion.reference) ``` ## Node.js ```js import crypto from "node:crypto"; // keys: { "app_…": "" }; keep the previous key here during a rotation. export function verifyAssertion(token, keys, { applicationId, host, skew = 30 }) { const parts = (token || "").split("."); if (parts.length !== 4 || parts[0] !== "v1") throw new Error("malformed"); const [, keyId, payload, signature] = parts; const secret = keys[keyId]; if (!secret) throw new Error("unknown_key"); const expected = crypto.createHmac("sha256", secret).update(`v1.${keyId}.${payload}`).digest(); const given = Buffer.from(signature, "base64url"); if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) { throw new Error("bad_signature"); } const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); const now = Math.floor(Date.now() / 1000); if (data.iat > now + skew) throw new Error("not_yet_valid"); if (data.exp + skew < now) throw new Error("expired"); if (data.app !== applicationId) throw new Error("wrong_application"); if (data.host !== host.toLowerCase().split(":")[0]) throw new Error("wrong_hostname"); return { reference: data.ref, domainId: data.dom, hostname: data.host, requestId: data.rid }; } ``` ## The algorithm, for any language 1. Read `X-Custom-Domain-Assertion`. Reject the request if it is missing. 2. Split it on `.`; require exactly four parts, the first being `v1`. 3. Look up the secret by the key id (the second part). Reject unknown ids. 4. Compute HMAC-SHA256 with that secret over `v1..` and compare it, in constant time, with the base64url-decoded fourth part. 5. Decode the payload (base64url, then JSON). Reject it if `exp` plus 30 seconds is in the past, or `iat` is more than 30 seconds in the future. 6. Require `app` to equal your application id, and `host` to equal the request's hostname: the `Host` header, or `X-Forwarded-Host` for an origin behind a CDN or a proxy. 7. Only then use `ref` to select the workspace. | Payload field | Meaning | |---|---| | `app` | Your application id. | | `dom` | The domain id. | | `ref` | The workspace `reference` you registered the domain with. | | `host` | The canonical hostname. | | `iat`, `exp` | Issued and expires, Unix seconds (60 seconds apart). | | `rid` | The edge's request id. | ## Origin behind a CDN or a proxy When your origin is set to **behind a CDN or proxy**, `Host` is your origin's own name and the customer's hostname is in `X-Forwarded-Host`. Check the assertion's `host` against `X-Forwarded-Host` instead. In Python this needs `custom-domain-sdk` 0.10.0 or later: ```python app.add_middleware(CustomDomainMiddleware, keys=..., application_id=..., workspace_lookup=load_workspace, host_header="origin") ``` In Node.js, pass the header's first value as `host`: ```js const customerHost = (req.headers["x-forwarded-host"] || "").split(",")[0].trim(); verifyAssertion(req.headers["x-custom-domain-assertion"], keys, { applicationId, host: customerHost }); ``` ## The workspace endpoint Before a domain goes live, the service calls your origin through the edge: ``` GET /.well-known/custom-domain-workspace ``` Verify the assertion as above, and answer `200` with the workspace you selected: ```json {"reference": "ws_8f3a1c", "application": ""} ``` Answer `404` if the workspace doesn't exist. The Python middleware does this for you. A `workspace_mismatch` here means your origin isn't selecting the workspace from the assertion. ## Rotating the key On the Origin page, **Rotate the key** issues a new one that starts signing 24 hours later. Add the new key id and secret to your keyring next to the current one; after the switch, remove the old one. ## Keep the origin behind the edge An assertion is valid for 60 seconds and bound to one hostname and workspace. Even so, don't let the custom-domain path of your origin be reached directly: allow only requests with a valid assertion there, or restrict it to the edge at the network level. --- # Webhooks > Get notified when a customer's domain is ready, needs attention, recovers or is deleted. Events, payloads, HMAC signatures, retries and replay. Source: https://customdomainapi.com/docs/webhooks/ ## Subscribe On the **Webhooks** tab of your application, or with the API: ``` POST /v1/webhooks {"url": "https://app.yourproduct.com/hooks/custom-domain", "events": ["domain.ready", "domain.attention_required", "domain.recovered", "domain.deleted"]} ``` The response includes the signing `secret`, shown once. The URL must be `https` on a public address. Up to ten subscriptions per application. ## Events | Type | Sent when | |---|---| | `domain.ready` | A domain first passes all four checks and is served. | | `domain.attention_required` | A ready domain fails a check; serving has stopped. | | `domain.recovered` | It is ready again. | | `domain.deleted` | A domain was deleted. | ```json { "id": "b7e2c1a0-9d8f-4e7a-b6c5-d4e3f2a1b0c9", "type": "domain.ready", "created_at": "2026-10-04T15:00:00+00:00", "data": {"domain": {"id": "6f1c…", "hostname": "forms.acme.com", "reference": "ws_8f3a1c", "status": "ready", "checks": ["…"], "dns_records": ["…"]}} } ``` `data.domain` is the full domain as it was when the event happened, so you need no follow-up request to know the hostname and workspace. ## Verify the signature Each delivery carries: ``` X-Custom-Domain-Signature: t=,v1=[,v1=] X-Custom-Domain-Event: domain.ready X-Custom-Domain-Event-Id: ``` Each `v1` is HMAC-SHA256 over `.` with a subscription secret. Recompute it with every secret you hold, compare in constant time against any listed `v1`, and reject the delivery if `t` is more than five minutes from now. Python: ```python from custom_domain import verify_webhook, parse_event, SignatureInvalid try: verify_webhook(request.headers.get("X-Custom-Domain-Signature"), raw_body, secrets=[SECRET]) except SignatureInvalid: return 400 event = parse_event(raw_body) # event.id, event.type, event.created_at, event.domain ``` Node.js: ```js import crypto from "node:crypto"; export function verifyWebhook(header, rawBody, secrets, toleranceSeconds = 300) { let t = null; const signatures = []; for (const item of (header || "").split(",")) { const [k, v] = item.split("=", 2); if (k === "t") t = Number(v); if (k === "v1") signatures.push(Buffer.from(v, "hex")); } if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; return secrets.some((secret) => { const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest(); return signatures.some((s) => s.length === expected.length && crypto.timingSafeEqual(s, expected)); }); } ``` ## Delivery - Any `2xx` answer is success. Failures are retried after 1, 5 and 30 minutes, then 2, 12 and 24 hours, up to 8 attempts. - Delivery is **at least once** and may be **out of order**: deduplicate on the event `id`, and compare `created_at` before changing state. - **Rotating** the secret (`POST /v1/webhooks/{id}/rotate`) signs with both the new and the previous secret for 24 hours. --- # Your customers' DNS > The two DNS records your customers add to connect their own domain to your SaaS, how to word the instructions, and the usual DNS console mistakes. Source: https://customdomainapi.com/docs/customer-dns/ When you register a hostname, the API returns two records. Show both to your customer **exactly as returned**: the values are unique to each registration. | Type | Name | Value | Why | |---|---|---|---| | TXT | `_custom-domain-challenge.forms.acme.com` | `custom-domain-verify=…` | Proves they control the name. | | CNAME | `forms.acme.com` | `edge.customdomainapi.com` (Free) or your edge name | Sends the hostname's traffic to the edge. | ## Instructions you can give your customer > To use **forms.acme.com** for your workspace, add these two records at > the company that manages your domain's DNS (your registrar, Cloudflare, > Route 53, …). It usually takes a few minutes; we'll let you know when your > address works. ## Common mistakes Each record's `help` text in the API response covers these too. - **The console adds the domain itself.** Many consoles want only the part before the domain: `_custom-domain-challenge.forms` and `forms` for `acme.com`. Entering the full name there produces `forms.acme.com.acme.com`. - **An A or AAAA record is still there.** A name can't have a CNAME and other records at once: remove old A/AAAA records for the hostname. - **A proxy is on.** With Cloudflare, set the CNAME to **DNS only** (grey cloud). The edge must see the visitor's TLS connection to issue the certificate. - **Quotes.** Some consoles add quotes around TXT values; that's fine. Don't add extra spaces. - **Apex domains.** Only subdomains such as `forms.acme.com` or `www.acme.com` can be used, not `acme.com` itself. ## After they add the records The domain's checks report what was found, in plain words, such as `cname_target_mismatch: CNAME points to old-host.example`. Show the `message` to your customer, and let them press a "check again" button that calls `POST /v1/domains/{id}/checks`. --- # For AI coding agents > A compact, complete brief for AI coding agents integrating Custom Domain API into a SaaS codebase. Contracts, invariants, and a step-by-step checklist. Source: https://customdomainapi.com/docs/ai-agents/ Give this page to your coding assistant, or point it at `https://customdomainapi.com/docs/ai-agents.md`. Everything an integration needs is below, with links to the full pages. ## Goal Let each workspace (tenant) of a SaaS app serve the app on a hostname its customer owns, such as `forms.acme.com`, over HTTPS. ## Facts - API base URL: `https://edge.customdomainapi.com/v1` on the Free plan, otherwise `https:///v1`. Auth: `Authorization: Bearer `. - OpenAPI: `https://edge.customdomainapi.com/v1/openapi.json`. - Python SDK: `pip install custom-domain-sdk` (`custom_domain.Client`, `CustomDomainMiddleware`, `WorkspaceResolver`, `verify_webhook`). - MCP server: `uvx custom-domain-mcp` lets an assistant register, inspect, recheck and delete domains with the application's API key (see [MCP server](#mcp-server) below). - Customers CNAME their hostname to `edge.customdomainapi.com` on Free. - Secrets the app needs (from app.customdomainapi.com, each shown once): `CUSTOM_DOMAIN_API_KEY`, `CUSTOM_DOMAIN_ASSERTION_SECRET` with its key id (`app_…`), `CUSTOM_DOMAIN_APPLICATION_ID`, and a webhook secret if webhooks are used. Read them from the environment; never commit them. ## Invariants (do not break) 1. **Never select the tenant from the `Host` header.** Select it from the verified `X-Custom-Domain-Assertion` (`ref` field) only. See [Verifying requests](/docs/verify-requests/). 2. Verify the assertion before trusting it: HMAC-SHA256, key looked up by id, constant-time compare, `exp`/`iat` with 30 s skew, `app` equals the application id, `host` equals the request host. If the origin is behind a CDN or a proxy (its "Host sent" setting is the origin's own name), the customer's hostname is `X-Forwarded-Host`, not `Host`; the SDK takes `host_header="origin"`. 3. Serve `GET /.well-known/custom-domain-workspace` returning `{"reference": ""}` (404 if the workspace doesn't exist). The domain can't go live without it. 4. Store the domain `id` with the workspace; show the customer the `dns_records` exactly as returned. 5. Send `Idempotency-Key` on `POST /v1/domains` so retries are safe. 6. Treat webhooks as at-least-once and unordered: verify the signature, dedupe on event `id`, compare `created_at`. 7. Handle `409 hostname_already_claimed`, `409 domain_limit_reached`, `422 apex_not_supported` and the other 422 hostname codes with clear messages to the customer; respect `429` `Retry-After`. ## Checklist 1. Data model: add to the workspace (or a new table) `custom_hostname`, `custom_domain_id`, `custom_domain_status`. 2. Settings UI: a field for the hostname. On save, call `POST /v1/domains` with `hostname` and `reference` = the workspace id; store `id` and `status`; render the two `dns_records` with copy buttons. 3. Status: show `status` and each failing check's `message`. A "check again" button calls `POST /v1/domains/{id}/checks`. 4. Updates: subscribe to `domain.ready`, `domain.attention_required`, `domain.recovered` and `domain.deleted` (`POST /v1/webhooks`), and update the stored status in the handler; or poll `GET /v1/domains/{id}`. 5. Removing: `DELETE /v1/domains/{id}` when the customer removes the hostname or the workspace is deleted. 6. Request routing: add the assertion verification middleware; map `ref` to the workspace for every request that carries the header. Requests on the app's own domain (no header) keep their existing routing. 7. Absolute URLs: build them from the request's `Host` and `X-Forwarded-Proto`; the edge sets both to the customer-facing values. Cookies should not set a `Domain` attribute bound to the app's own domain. 8. Tests: a forged or expired assertion is refused; a valid one selects the right workspace; `Host` alone never selects a workspace. ## MCP server [`custom-domain-mcp`](https://pypi.org/project/custom-domain-mcp/) exposes the v1 API as tools, so an assistant can manage an application's domains directly: `create_domain`, `get_domain`, `list_domains`, `dns_instructions`, `recheck_domain`, `delete_domain` and `list_webhooks`. It uses one application's API key, never an operator token, and can't create webhooks, because their signing secret would end up in the conversation. It is listed on the official MCP Registry as `io.github.sireto/custom-domain`. Claude Code: ```bash claude mcp add custom-domain \ --env CUSTOM_DOMAIN_API_URL=https://edge.customdomainapi.com \ --env CUSTOM_DOMAIN_API_KEY=cd_... -- uvx custom-domain-mcp ``` Claude Desktop, Cursor and other clients (`mcpServers` in their config): ```json { "mcpServers": { "custom-domain": { "command": "uvx", "args": ["custom-domain-mcp"], "env": { "CUSTOM_DOMAIN_API_URL": "https://edge.customdomainapi.com", "CUSTOM_DOMAIN_API_KEY": "cd_..." } } } } ``` On a paid plan, `CUSTOM_DOMAIN_API_URL` is `https://`. On Free an application has one API key, so the MCP server uses your backend's key and shares its 60 requests a minute: an assistant listing or rechecking a lot of domains slows your backend's own calls, which get `429` with `Retry-After` while the limit lasts. Hostnames, references and check messages come from your customers and from DNS: the server tells the assistant to treat them as data, never as instructions, and to delete a domain only when you asked for it by name. See the [README](https://github.com/sireto/custom-domain/blob/main/mcp-server/README.md). ## Full pages [Quickstart](/docs/quickstart.md) · [How it works](/docs/how-it-works.md) · [API reference](/docs/api.md) · [Verifying requests](/docs/verify-requests.md) · [Webhooks](/docs/webhooks.md) · [Your customers' DNS](/docs/customer-dns.md) --- # Self-hosting > Run the open-source Custom Domain service on your own server or cloud, with one-click templates for Azure, AWS, Google Cloud, Hetzner and DigitalOcean. Source: https://customdomainapi.com/docs/self-hosting/ The hosted plans run the open-source service [sireto/custom-domain](https://github.com/sireto/custom-domain) (Apache 2.0). You can run it yourself, on one server, with the same API, SDK and assertion. Your integration doesn't change between hosted and self-hosted. ## Install On a fresh Ubuntu 22.04/24.04 or Debian 12 server with a public address: ```bash curl -fsSL https://raw.githubusercontent.com/sireto/custom-domain/main/deploy/install.sh -o install.sh sudo CUSTOM_DOMAIN_VERSION=0.11.0 EDGE_HOSTNAME=edge.yourcompany.com \ ACME_EMAIL=ops@yourcompany.com bash install.sh ``` Pin a [release](https://github.com/sireto/custom-domain/releases) as above. Point `edge.yourcompany.com` (an A record, and AAAA if you use IPv6) at the server. The installer prints the operator portal's address and password, and `custom-domain doctor` checks the installation. One-click and guided options: [Azure](https://github.com/sireto/custom-domain/blob/main/docs/hosting-azure.md), [AWS](https://github.com/sireto/custom-domain/blob/main/docs/hosting-aws.md), [Google Cloud](https://github.com/sireto/custom-domain/blob/main/docs/hosting-gcp.md), [Hetzner](https://github.com/sireto/custom-domain/blob/main/docs/hosting-hetzner.md), [DigitalOcean](https://github.com/sireto/custom-domain/blob/main/docs/hosting-digitalocean.md). ## Then 1. Create your application, register and verify its origin, and issue an API key, in the operator portal or with the `custom-domain` command. 2. Give the application its own assertion key if other parties' applications share the deployment (`custom-domain assertion-key issue`). 3. Integrate as in the [Quickstart](/docs/quickstart/), with your own edge name as the base URL. Upgrades: `custom-domain upgrade `. Operations, backups and monitoring are in the [repository's docs](https://github.com/sireto/custom-domain/tree/main/docs).