# Changelog

Source: https://connectdomain.app/docs/changelog

Connect Domain doesn't yet cut numbered releases — the `main` branch is the
running state of the product, and the API is versioned separately (below).
For the authoritative, line-by-line history, see the
[commit history on GitHub](https://github.com/ever-just/Customdomain/commits/main/app).

## API versioning policy

The public API is versioned by URI major (`/v1`) and is **additive-only within
a version** — new optional fields and endpoints can appear, but existing fields
never change meaning or disappear without a new major version. See the
[API reference](/docs/api-reference) for the current surface.

## Notable milestones

- **Entri-parity feature set** — embeddable widget/SDK, email DNS (MX/SPF/DKIM/DMARC)
  generation and apply, signed webhooks with retry/replay, and live DNS-provider
  detection with logos in the connect flow.
- **19-provider DNS auto-write fleet** — BYO-token adapters (Cloudflare,
  DigitalOcean, Gandi, deSEC, Hetzner, Vercel, DNSimple, Porkbun, Linode, Vultr,
  Name.com, GoDaddy, IONOS, Netlify), machine-credential adapters (Route 53,
  Google Cloud DNS, Azure DNS), and Domain Connect.
- **Widget robustness pass** — white-label theming, richer detection capability
  flags, and hardening around `email:apply` to prevent writing DNS for an
  arbitrary (non-connection) domain.
- **Marketing site + brand kit** — the current warm-stone, black & white
  editorial design (Instrument Serif + Inter + Geist Mono) at
  [connectdomain.app](https://connectdomain.app), including the `/compare/entri`
  page, `/demo` booking flow, and the provider logo wall.
- **This documentation site** — migrated from a single-page Markdown viewer to
  a proper information architecture with a generated, always-in-sync API
  reference (see [how this doc site stays in sync](/docs/api-reference#keeping-this-in-sync)).

---

# FAQ

Source: https://connectdomain.app/docs/faq

### Which DNS providers auto-configure records?

19 providers have a built-in adapter (Cloudflare, Route 53, GoDaddy, Namecheap,
and more) plus Domain Connect as a redirect-based, tokenless option. See
[DNS providers](/docs/dns/providers) for the full list and
[Provider setup](/docs/dns/provider-setup) for per-provider credential steps
and eligibility gates.

### Does it support apex domains as well as subdomains?

Yes — a connection's `kind` is `apex` or `subdomain` (see the `Connection`
schema in the [API reference](/docs/api-reference)). Apex support depends on
the provider's capabilities (ALIAS/ANAME/CNAME flattening); `POST /v1/domains:check`
returns `apex_flattening` and `www_redirect_recommended` so you can steer the
customer to the right setup.

### Does it support wildcard domains?

`POST /v1/domains:check` returns a `capabilities.wildcard` flag per detected
provider, reflecting whether that provider's DNS supports wildcard records.
Whether wildcard is offered in your product depends on how you use that
capability flag in your own onboarding flow.

### How long does DNS propagation usually take?

It depends on the TTL of any records being replaced and the customer's
resolver/registrar — from minutes (delegated auto-write with fresh records) to
longer if a previous record had a long TTL. `POST /v1/connections/{id}/records:check`
reports live propagation state per record; see
[Troubleshooting](/docs/troubleshooting#propagation-never-finishes--connection-is-stalled).

### What happens if I revoke or delete an API key?

Revocation is immediate — a revoked key stops authenticating on its very next
request. Keys are never returned by list endpoints after creation; only
metadata (`last4`, scopes, timestamps) is visible.

### Can a connection lose its "live" status?

Yes — if a live domain's records stop resolving to their intended values, it
transitions to `drifted` and fires `domain.drift`. See
[Connections](/docs/concepts/connections#propagation-and-drift).

### Is there a sandbox / test mode?

Yes — API keys are prefixed `sk_test_`/`sk_live_`, and applications have a
`test`/`live` environment. `make dev` seeds a demo application and
`sk_test_demo` key for local development (see the [Quickstart](/docs/getting-started/quickstart)).

---

# Introduction

Source: https://connectdomain.app/docs

**Open-source custom-domain onboarding for multi-tenant SaaS** — DNS
auto-configuration, automatic SSL, and a reverse proxy, in one self-hostable
stack. It's an open alternative to Entri: let your customers point their own
domain at your product in minutes, with a drop-in widget and a REST API.

## What it does

- **Connect** — a customer enters their domain; the system detects their DNS
  provider and either writes the records automatically (delegated credential) or
  hands them exact records to add, then watches propagation.
- **Secure** — the edge issues a TLS certificate **on demand during the
  handshake**, but only for hostnames you've authorized.
- **Serve** — the edge reverse-proxies the custom domain to your origin.
- **Monitor** — webhooks, a developer console, usage metering, and drift
  detection keep you informed after go-live.

## The two planes

The system is two independent planes that share one database and talk over HTTP:

- **Connect plane** (the control-plane) — the REST API, ownership verification,
  DNS auto-write, quotas, webhooks, and background workers.
- **Edge plane** — on-demand TLS termination + reverse proxy, gated by a single
  internal `ask` call to the control-plane.

See [Concepts](/docs/concepts/architecture) for the full model and
[Self-hosting](/docs/self-hosting/overview) for how to run both planes.

## Start here

| If you want to… | Read |
| --- | --- |
| Connect your first domain end to end | [Quickstart](/docs/getting-started/quickstart) |
| Understand connections, ownership, and states | [Concepts](/docs/concepts/architecture) |
| Authenticate API calls and the widget | [Authentication](/docs/authentication/overview) |
| Embed the connect modal | [The widget SDK](/docs/widget-sdk/overview) |
| Set up email (MX/SPF/DKIM/DMARC) | [Email DNS](/docs/dns/email-dns) |
| Call the API | [API Reference](/docs/api-reference) |
| React to events | [Webhooks](/docs/webhooks/overview) |
| See which DNS providers auto-configure | [DNS providers](/docs/dns/providers) |
| Run the stack yourself | [Self-hosting](/docs/self-hosting/overview) |
| Understand the security model | [Security](/docs/security/overview) |
| Configure plans and quotas | [Billing & Quotas](/docs/billing/plans-and-quotas) |
| Look up enums, env vars, error codes | [Reference](/docs/reference) |
| Fix a stuck connection | [Troubleshooting](/docs/troubleshooting) |

## For AI agents

A machine-readable index of this documentation is at
[`/docs/llms.txt`](/docs/llms.txt), with a full-text version at
[`/docs/llms-full.txt`](/docs/llms-full.txt). Every page is also available as
raw Markdown at `/llms/<page-slug>`.

---

# Reference

Source: https://connectdomain.app/docs/reference

## Enums

**ConnectionState** — `created`, `pending_ownership`, `verifying`, `verified`,
`dns_writing`, `propagating`, `stalled`, `issuing_cert`, `live`, `drifted`,
`failed`, `archived`.

**SetupType** — `automatic`, `manual`, `semiautomatic`, `shared_login`, `async`,
`api`. (Only `automatic`, `manual`, and Domain Connect are implemented in the
widget.)

**UsageKind** — `connect`, `active_domain`, `cert`, `api_call`.

**Scope** — `connections:read`, `connections:write`, `apps:admin`,
`webhooks:write`, `usage:read`.

**Webhook events** — `domain.added`, `domain.verified`, `ssl.issued`,
`domain.flow.completed`, `domain.drift`.

## Common error codes

| Code | HTTP | Meaning |
| --- | --- | --- |
| `unauthorized` | 401 | Missing/invalid credential. |
| `forbidden` | 403 | Valid credential lacks the required scope. |
| `not_found` | 404 | Object doesn't exist or isn't in your tenant. |
| `invalid` | 400 | Malformed request. |
| `HostnameConflict` | 409 | Hostname already held active. |
| `ownership_not_found` | 409 | Ownership TXT missing/incorrect at verify. |
| `QuotaExceeded` | 402 | Plan connect limit reached (carries `upgrade_url`). |
| `billing_unavailable` | 502 | No billing provider configured. |
| `internal` | 500 | Server error (generic message + correlation `ref`). |

## Environment variables

See [Self-hosting → Configuration](/docs/self-hosting/configuration) for the
full control-plane and edge env tables.

## Ownership challenge

- Name: `_customdomain-challenge.<hostname>`
- Type: `TXT`
- Value: the `value` returned in `ownership_challenge` on connection create.

## Webhook signature

- Headers: `X-CD-Timestamp: <unix seconds>` and `X-CD-Signature: sha256=<hex>`
- Algorithm: HMAC-SHA256 over `${timestamp}.${rawBody}` (the timestamp, a
  dot, then the exact request body), keyed by the endpoint's signing secret.
  Reject deliveries whose timestamp is outside a ~5-minute window. See
  [Verifying signatures](/docs/webhooks/verifying-signatures).

## Resources

- `ARCHITECTURE.md` (repository root of `app/`) — the two-plane design and invariants.
- `CONTRACTS.md` — frozen inter-component interfaces.
- `BENCHMARKS.md` — parity vs Entri + performance.
- [`openapi-v1.yaml`](/docs/api-reference) — OpenAPI document (this reference is
  authoritative where the hand-written pages differ from the generated API
  reference).

---

# Troubleshooting

Source: https://connectdomain.app/docs/troubleshooting

## A connection is stuck in `pending_ownership` or `verifying`

The ownership TXT at `_customdomain-challenge.<hostname>` hasn't been seen with
the expected value yet. Verification is **value-checked** — the record must
match exactly (see [Ownership and setup types](/docs/concepts/ownership-and-setup-types)).

- Confirm the TXT record was published at the *exact* name from
  `ownership_challenge.name`, not the bare hostname.
- DNS propagation can take minutes to hours depending on the record's previous
  TTL and the customer's resolver/registrar.
- Call `POST /v1/connections/{id}/verify` to force an immediate re-check, or
  wait for the background worker (`WORKER_ENABLED=1`) to pick it up
  automatically.

## `409 HostnameConflict` on create

Exactly one active claim per hostname exists globally (`verified` /
`issuing_cert` / `live`). Either the same tenant already has a connection for
this hostname, or a different tenant does. [Archive](/docs/connect-flow/offboarding)
the existing connection before retrying if it's stale.

## `402 QuotaExceeded`

The plan's `connect` limit has been reached for the current period. The error
carries `limit`, `plan`, and `upgrade_url` — see
[Plans & quotas](/docs/billing/plans-and-quotas).

## Propagation never finishes / connection is `stalled`

`records:check` reports each desired record's `propagated` state and
`observed_value`. Common causes:

- The customer's DNS provider hasn't applied the record yet (TTL wait).
- A **delegated credential** write failed (check the provider's dashboard for
  the actual record set) — see [DNS providers](/docs/dns/providers) and
  [Provider setup](/docs/dns/provider-setup) for provider-specific gates (e.g.
  GoDaddy's account-tier gate, Namecheap's IP allowlist).
- The customer edited or removed a record after adding it.

## A live domain shows `drifted`

The background worker re-checks live domains on an interval; if records stop
resolving to their intended values it transitions to `drifted` and fires
`domain.drift`. Have the customer restore the records, or re-apply them via a
delegated credential.

## Certificate not issuing

Certificates are issued **on demand at the TLS handshake**, gated by
`GET /internal/ask` returning `allow: true` — which only happens once the
connection is `verified` or later. If the edge is misconfigured
(`EDGE_ISSUER`, `CONTROLPLANE_URL`), see
[Self-hosting → Configuration](/docs/self-hosting/configuration).

## Widget shows "session expired"

Widget tokens expire after 60 minutes (see
[Widget tokens](/docs/authentication/widget-tokens)). Mint a fresh token
server-side and reopen the widget.

---

# User journeys

Source: https://connectdomain.app/docs/user-journeys

Every user-facing flow, the surface that implements it, and the automated test
that verifies it. This is the completeness map: each story below maps to a
working path.

## Personas

- **Developer / operator** — integrates the product, works in the **console**
  and via the **API**.
- **End customer** — the SaaS's customer connecting their own domain, works in
  the **widget**.
- **Platform operator** — self-hosts and monitors the stack.

## End-customer journeys (widget)

| # | As a customer, I want to… | Surface / path | Verified by |
| --- | --- | --- | --- |
| C1 | Enter my domain and have its DNS provider detected | Widget → Enter domain → Domain analysis (`POST /v1/domains:check`) | `browser-e2e` |
| C2 | Connect automatically when possible | Widget → Automatic setup (`POST /v1/connections`, delegated write) | `it.sh` (adapter path) |
| C3 | Get exact records to add when manual | Widget → Manual configuration (copy buttons) | `browser-e2e` |
| C4 | See progress until my domain is live | Widget → In progress (polls `records:check`) → Success | `browser-e2e` |
| C5 | Recover from errors / switch to manual | Widget → Error screen (retry / switch) | widget flow (`widget.ts`) |
| C6 | Not lose my place accidentally | Widget → exit-confirm + session-expired overlays | widget flow |

## Developer journeys (console + API)

| # | As a developer, I want to… | Surface / path | Verified by |
| --- | --- | --- | --- |
| D1 | Create and manage applications | Console → Applications (list/create) + Application detail (edit) | `console-smoke`, `it.sh` (PATCH) |
| D2 | Create, list, and revoke API keys | Console → API keys / Application detail (`POST`/`GET`/`DELETE` keys) | `it.sh` (key lifecycle), `console-smoke` |
| D3 | Connect a domain from the console | Console → Domains → Connect a domain (`POST /v1/connections`) | `console-smoke`, `it.sh` |
| D4 | Inspect a connection and drive it | Console → Domain detail: verify, records:check, archive, DNS diff, cert | `it.sh`, `console-smoke` |
| D5 | Enable automatic DNS for an app | Console → Application detail → Delegated DNS credential | `it.sh` (adapter), `console-smoke` |
| D6 | See which providers auto-configure | Console → Providers grid (`GET /v1/providers`) | `console-smoke` |
| D7 | Receive events, inspect + replay deliveries | Console → Webhooks (register, log, replay) | `it.sh` (deliver/retry/replay), `console-smoke` |
| D8 | Track usage against quota | Console → Usage (`GET /v1/usage`) | `console-smoke` |
| D9 | View and change plan | Console → Billing (`GET /v1/plans`, `/subscription`, `POST /v1/subscriptions`) | `console-smoke`, `it.sh` (quota) |
| D10 | Embed the widget safely | `POST /v1/tokens` server-side; `allowed_origins` | `it.sh`, [Widget SDK](/docs/widget-sdk/overview) |
| D11 | Install the console as an app / use offline shell | PWA (manifest + service worker) | `console-smoke` (PWA assets) |

## Platform-operator journeys

| # | As an operator, I want to… | Surface / path | Verified by |
| --- | --- | --- | --- |
| O1 | Run the whole stack locally | `make dev` | manual / `e2e` |
| O2 | Serve HTTPS for live domains automatically | Edge on-demand TLS gated by `ask` | `e2e` (real TLS) |
| O3 | Monitor health and cert-renewal risk | `GET /metrics` (Prometheus gauges) | `it.sh` (metrics) |
| O4 | Keep tenants isolated | Tenant-scoped handlers; cross-tenant → 404 | `it.sh` (isolation, IDOR guard) |
| O5 | Keep secrets safe | Encrypted credentials, no key material in logs | `it.sh` (log hygiene), unit tests |

## Coverage note

Setup types `semiautomatic`, `shared_login`, `async`, and `api` exist in the data
model but are intentionally not built in the widget yet (documented in
[Ownership and setup types](/docs/concepts/ownership-and-setup-types)). Everything
else above is implemented and exercised by the test suites listed.

---

# Overview

Source: https://connectdomain.app/docs/authentication/overview

There are two credential types. Server-to-server calls use **API keys**;
browser (widget) calls use short-lived **widget JWTs** you mint server-side —
see [Widget tokens](/docs/authentication/widget-tokens).

## API keys

- Prefix `sk_test_` (test) or `sk_live_` (live).
- Sent as `Authorization: Bearer sk_...`.
- Stored only as a SHA-256 hash — the secret is shown **once** at creation.
- Carry a set of **scopes** (below). A key can never mint another key with
  scopes it doesn't itself hold.
- Revocable (soft-revoke); a revoked key stops authenticating immediately.

Create, list, and revoke keys under an application:

```
POST   /v1/applications/{id}/keys       # create (secret returned once)
GET    /v1/applications/{id}/keys       # list (metadata only, never the secret)
DELETE /v1/applications/{id}/keys/{keyId}  # revoke
```

## Scopes

| Scope | Grants |
| --- | --- |
| `connections:read` | read connections, records, usage |
| `connections:write` | create connections, run records:check, mint widget tokens |
| `apps:admin` | create/update applications, keys, credentials, webhook endpoints, subscriptions |
| `webhooks:write` | manage webhook endpoints (where distinguished) |
| `usage:read` | read usage/metering |

Admin mutations require `apps:admin`. All `/applications/{id}/*` mutations also
verify the application belongs to the caller's tenant (404 otherwise).

## Unauthenticated endpoints

`GET /healthz`, `GET /metrics`, and `GET /internal/ask` require no auth and are
meant to be **network-isolated** in production (see [Security](/docs/security/overview)).
They never expose secrets or tenant identifiers.

---

# Widget tokens

Source: https://connectdomain.app/docs/authentication/widget-tokens

The widget must never see an API key. Instead your backend mints a short-lived
JWT and hands it to the browser:

```bash
curl -X POST http://localhost:8080/v1/tokens \
  -H "Authorization: Bearer <YOUR_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"application_id":"<APP_ID>","domain":"app.customer.com"}'
# → { "auth_token": "...", "expires_at": "..." }
```

- The token is bound to the application and (optionally) a single `domain`. A
  domain-bound token may only act on that hostname.
- TTL is 60 minutes; on expiry the widget surfaces "session expired" and your
  host app mints a fresh token.
- Minting requires the calling key to hold `connections:write` (so a read-only
  key can't escalate to write via the widget surface).
- A widget JWT carries `connections:read` + `connections:write` and is accepted
  on the browser-facing endpoints (`domains:check`, `connections`,
  `connections/{id}`, `records:check`).

See [The widget SDK](/docs/widget-sdk/overview) for how the browser uses this
token, and [Create a connection](/docs/connect-flow/create-a-connection) for the
API calls it drives.

---

# Plans & quotas

Source: https://connectdomain.app/docs/billing/plans-and-quotas

## Plans

The catalog is returned by `GET /v1/plans`. The built-in plans are `free`,
`pro`, `growth`, and `enterprise`, each with per-kind limits. View and switch the
current plan in the console's **Billing** view or via the API:

```bash
curl http://localhost:8080/v1/subscription -H "Authorization: Bearer <KEY>"
curl -X POST http://localhost:8080/v1/subscriptions \
  -H "Authorization: Bearer <KEY>" -H "Content-Type: application/json" \
  -d '{"plan":"pro"}'
```

## Metered usage

`GET /v1/usage` returns per-kind counters for the current period:

| Kind | Counts |
| --- | --- |
| `connect` | connections started |
| `active_domain` | live domains |
| `cert` | certificates issued |
| `api_call` | API calls |

## Quota enforcement

Quota is enforced on the control-plane **write path only** — never on the edge
`ask` hot path, so serving traffic never blocks on billing. When a connect would
exceed the plan limit, `POST /v1/connections` returns:

```json
{ "error": { "code": "QuotaExceeded", "message": "…", "limit": 3, "plan": "free", "upgrade_url": "…" } }
```

Reservation is **atomic** under a Postgres advisory lock: N concurrent creates
over a limit yield exactly `limit` successes and the rest `402`.

## Checkout

`POST /v1/billing/checkout` returns a provider checkout URL. The billing provider
is behind an interface; the default is a no-op stub, and a Stripe backend can be
wired in. When no provider is configured, checkout returns
`502 billing_unavailable`.

---

# API reference

Source: https://connectdomain.app/docs/api-reference

Base URL (local): `http://localhost:8080`. All requests and responses are JSON.
The pages below this one are **generated** from [`openapi-v1.yaml`](https://github.com/ever-just/Customdomain/blob/main/app/openapi-v1.yaml)
by `npm run gen:api` (see [how this stays in sync](#keeping-this-in-sync)) —
this page is the only hand-written one in the section.

## Authentication

Send `Authorization: Bearer <token>` — an API key (`sk_...`) or, for
browser-facing endpoints, a widget JWT. See [Authentication](/docs/authentication/overview).

## Error envelope

Every error looks like:

```json
{ "error": { "code": "string", "message": "human readable" } }
```

`code` is an open string set. Quota errors (`402`) also carry `limit`, `plan`,
and `upgrade_url`. Internal errors return a generic message with a correlation
`ref` — details are logged server-side, never returned.

## Conventions

- **Idempotency** — send an `Idempotency-Key` header on creates to make retries
  safe (accepted; honored where supported).
- **Pagination** — list endpoints accept `limit`; results are tenant-scoped.
- **Versioning** — the surface is under `/v1`. Additive-only within a version —
  see the [changelog](/docs/changelog#api-versioning-policy).

## Keeping this in sync

`app/openapi-v1.yaml` is the single source of truth for the wire contract —
docs, SDKs, and request validation all derive from it. This section is
regenerated with:

```bash
cd app/web
npm run gen:api
```

CI (`.github/workflows/docs.yml`) lints the spec, regenerates this section, and
fails the build if the committed pages have drifted from the spec (`git diff
--exit-code`) — so an endpoint change without a regeneration can't merge
silently out of date.

---

# Architecture

Source: https://connectdomain.app/docs/concepts/architecture

## Applications

An **application** represents one product surface in your account (tenant). It
holds the **record template** new connections inherit, the **allowed origins**
for widget embedding, and its environment (`test`/`live`). API keys and delegated
DNS credentials belong to an application.

## The two planes

- **Connect plane** (the control-plane) — the REST API, ownership verification,
  DNS auto-write, quotas, webhooks, and background workers.
- **Edge plane** — on-demand TLS termination + reverse proxy, gated by a single
  internal `ask` call to the control-plane.

## The edge hot path

`GET /internal/ask?domain=<host>` is the only call the edge makes into the
control-plane. It answers from an O(1) covering index — no quota, no joins — so
the TLS handshake never blocks on business logic. Quota and billing are enforced
only on the control-plane write path.

See [Connections](/docs/concepts/connections) for the data model and
[Self-hosting](/docs/self-hosting/overview) for how to run both planes.

---

# Connections

Source: https://connectdomain.app/docs/concepts/connections

A **connection** is one custom domain being onboarded. Its `id` doubles as the
`jobId` used by the widget and webhooks. A connection carries desired records,
observed records, an ownership challenge, and a certificate mirror.

## The connection lifecycle (state machine)

```
created → pending_ownership → verifying → verified
        → dns_writing → propagating → issuing_cert → live
                                             ↘ drifted
        (any) → stalled / failed / archived
```

| State | Meaning |
| --- | --- |
| `created` | Row exists; nothing proven yet. |
| `pending_ownership` | Waiting for the ownership TXT to appear. |
| `verifying` | An ownership check is in progress. |
| `verified` | Ownership proven — **the `ask` gate is now open** for this host. |
| `dns_writing` | A delegated auto-write is in progress. |
| `propagating` | Desired records are being observed until they all resolve. |
| `issuing_cert` | Cert issuance in progress. |
| `live` | Records propagated, cert issued, edge is proxying. |
| `drifted` | A live domain's records stopped resolving to their intended values. |
| `stalled` | Progress paused (e.g. long propagation). |
| `failed` | A terminal error occurred. |
| `archived` | Soft-deleted / offboarded. |

## Hostname collisions

Exactly **one active claim per hostname exists globally** (enforced by a partial
unique index over `verified`/`issuing_cert`/`live`). A second connection to a
hostname already held active is rejected with `409 HostnameConflict`.

## Tenancy

Every object belongs to a tenant. All tenant-scoped endpoints filter by the
caller's tenant; cross-tenant access returns `404`, never another tenant's data.
See [Security](/docs/security/overview).

## Propagation and drift

Propagation is confirmed by resolving each desired record and matching values
(see [Ownership and setup types](/docs/concepts/ownership-and-setup-types)). After
go-live, a background worker periodically re-checks; if a live domain's records
stop resolving correctly it transitions to `drifted` and fires `domain.drift` so
you can alert the customer.

---

# Ownership and setup types

Source: https://connectdomain.app/docs/concepts/ownership-and-setup-types

## Ownership (the `ask` gate)

Before the edge will request a certificate for a hostname, ownership must be
proven. The customer publishes a `TXT` record at
`_customdomain-challenge.<host>` equal to the challenge value. Verification is
**value-checked**: the TXT must equal the expected value, and address records
(A/AAAA) must contain the exact desired value — "the name resolves to something"
is never accepted, because the record set is attacker-controlled.

Only after a connection reaches `verified` does `GET /internal/ask` return
`allow: true`, which is what lets the edge obtain a cert on demand. This is the
central safety gate.

## Setup types

- **automatic** — the application has a delegated DNS credential; the
  control-plane writes the records itself (the scoped token is implicit proof of
  control) and skips the manual TXT step.
- **manual** — the customer copies the records into their DNS provider.
- **Domain Connect** — a redirect-based flow where the provider applies a
  template (no server-to-server token).

Other setup types (`semiautomatic`, `shared_login`, `async`, `api`) exist in the
data model but are not implemented in the widget yet.

---

# Create a connection

Source: https://connectdomain.app/docs/connect-flow/create-a-connection

```bash
curl -X POST http://localhost:8080/v1/connections \
  -H "Authorization: Bearer <YOUR_KEY>" \
  -H "Idempotency-Key: <UUID>" \
  -H "Content-Type: application/json" \
  -d '{"application_id":"<APP_ID>","hostname":"app.customer.com"}'
```

Request fields (see the [API reference](/docs/api-reference) for the full
schema):

- `application_id`, `hostname` — required.
- `setup_type` — override the detected setup type (`automatic`, `manual`, …).
- `dns_records` — override the application's `record_template` for this
  connection only.
- `batch_id` — group connections created together (e.g. a bulk import).

Send an `Idempotency-Key` header on creates to make retries safe.

## Response

The response includes the connection `id` (also the `jobId` used by widget
events and webhooks), its `state`, and — unless a
[delegated DNS credential](/docs/dns/providers) auto-writes the records — an
`ownership_challenge`:

```json
{
  "id": "…",
  "state": "pending_ownership",
  "ownership_challenge": {
    "name": "_customdomain-challenge.app.customer.com",
    "type": "TXT",
    "value": "cd-verify-…"
  }
}
```

## Errors

| Code | HTTP | Meaning |
| --- | --- | --- |
| `QuotaExceeded` | 402 | The plan's connect limit is reached; carries `limit`, `plan`, `upgrade_url`. |
| `HostnameConflict` | 409 | Another (or the same) tenant already holds this hostname active — see [hostname collisions](/docs/concepts/connections#hostname-collisions). |

Next: [verify ownership and go live](/docs/connect-flow/verify-and-go-live).

---

# Offboarding

Source: https://connectdomain.app/docs/connect-flow/offboarding

```bash
curl -X DELETE http://localhost:8080/v1/connections/<ID> \
  -H "Authorization: Bearer <YOUR_KEY>"
```

This soft-deletes the connection: it transitions to `archived` (a terminal
state — see [the connection lifecycle](/docs/concepts/connections)) and
releases its claim on the hostname, so a new connection to the same hostname
is no longer rejected with `409 HostnameConflict`.

Offboarding does not touch DNS records at the customer's provider — the
customer (or your integration) is responsible for removing them if the domain
is being fully decommissioned.

---

# Overview

Source: https://connectdomain.app/docs/connect-flow/overview

Connecting a domain is a pipeline of API calls layered on top of the
[connection state machine](/docs/concepts/connections):

1. [**Create a connection**](/docs/connect-flow/create-a-connection) —
   `POST /v1/connections` starts the process: it returns the desired DNS
   records and (unless a delegated credential auto-writes them) an ownership
   challenge.
2. [**Verify ownership**](/docs/connect-flow/verify-and-go-live) — the customer
   publishes the ownership TXT record; `POST /v1/connections/{id}/verify`
   confirms it (or the background worker does, automatically).
3. **Go live** — `POST /v1/connections/{id}/records:check` reports per-record
   propagation and flips the connection to `live` once everything resolves,
   which is also when the edge issues the TLS certificate on the next
   handshake.
4. [**Offboard**](/docs/connect-flow/offboarding) — `DELETE /v1/connections/{id}`
   soft-deletes the connection when the customer disconnects their domain.

For the fastest path through all four steps with real `curl` commands, see the
[Quickstart](/docs/getting-started/quickstart). To let customers drive this
flow themselves in a UI, see [The widget SDK](/docs/widget-sdk/overview).

---

# Verify ownership & go live

Source: https://connectdomain.app/docs/connect-flow/verify-and-go-live

## Verify ownership

```bash
curl -X POST http://localhost:8080/v1/connections/<ID>/verify \
  -H "Authorization: Bearer <YOUR_KEY>"
```

This confirms the ownership TXT published at
`_customdomain-challenge.<hostname>` matches the challenge value (see
[the ask gate](/docs/concepts/ownership-and-setup-types#ownership-the-ask-gate)).
On success the connection moves to `verified`. The background worker also runs
this check automatically once the TXT is visible, so polling it yourself is
optional.

If the application has a delegated DNS credential, the scoped provider token is
implicit proof of control and this manual step is skipped entirely.

## Watch propagation → live

```bash
curl -X POST http://localhost:8080/v1/connections/<ID>/records:check \
  -H "Authorization: Bearer <YOUR_KEY>"
```

Returns a per-record propagation report:

```json
{
  "records": [
    { "host": "app.customer.com", "type": "CNAME", "value": "edge.connectdomain.app", "propagated": true, "observed_value": "edge.connectdomain.app" }
  ]
}
```

When every desired record resolves to its intended value, the connection
becomes `live`: the edge issues the TLS certificate on the next handshake, and
you receive `ssl.issued` and `domain.flow.completed` [webhooks](/docs/webhooks/overview),
fired exactly once as a pair.

## After go-live: drift

A background worker periodically re-checks live domains. If records stop
resolving to their intended values, the connection transitions to `drifted` and
fires `domain.drift` — see [connections](/docs/concepts/connections#propagation-and-drift).

---

# Email DNS

Source: https://connectdomain.app/docs/dns/email-dns

Connecting a domain points **web** traffic at your product. Many customers also
want **email** on that domain — Google Workspace, Microsoft 365, or Zoho. The
control-plane generates the exact MX / SPF / DKIM / DMARC records for a chosen
provider, and merges SPF into any record the domain already publishes so you
never end up with two `v=spf1` records (which silently breaks SPF).

## Generate records

```bash
curl -X POST http://localhost:8080/v1/email/records \
  -H "Authorization: Bearer <YOUR_KEY_OR_WIDGET_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"domain":"acme.com","provider":"google_workspace"}'
```

```json
{
  "provider": "google_workspace",
  "domain": "acme.com",
  "records": [
    { "host": "acme.com", "type": "MX", "value": "smtp.google.com", "ttl": 3600, "priority": 1 },
    { "host": "acme.com", "type": "TXT", "value": "v=spf1 include:_spf.google.com ~all", "ttl": 3600 },
    { "host": "_dmarc.acme.com", "type": "TXT", "value": "v=DMARC1; p=none;", "ttl": 3600 }
  ],
  "notes": [
    "DKIM: generate a key in Google Admin console … then add a TXT record at google._domainkey.acme.com (pass dkim_value to include it here).",
    "DMARC starts at p=none (monitor only). Once you've confirmed legitimate mail passes, tighten to quarantine, then reject."
  ]
}
```

The endpoint is a **pure generator** — it never writes DNS. Add the records
manually, or use **`POST /v1/connections/{id}/email:apply`** with the same
options to have a delegated credential write the safe records for you (the apex
SPF is returned as `manual_records`, never auto-written). The widget's optional
email step (`email: true`) drives `email:apply` behind a preview/confirm screen.

> `email:apply` writes for the connection's own domain (or its apex). It does
> **not** accept an arbitrary domain, and `records:apply` only re-applies the
> connection's web records — it does not write email records.

## Supported providers

| `provider` | Records emitted |
| --- | --- |
| `google_workspace` | MX `smtp.google.com`, SPF `_spf.google.com`, DMARC, DKIM (with key) |
| `microsoft365` | MX `<domain>.mail.protection.outlook.com`, `autodiscover` CNAME, SPF `spf.protection.outlook.com`, DKIM selector1/2 CNAMEs (with `m365_tenant`), DMARC |
| `zoho` | MX `mx/mx2/mx3.zoho.com`, SPF `zoho.com`, DMARC, DKIM (with key) |

## Request fields

| Field | Notes |
| --- | --- |
| `domain` *(required)* | The email domain, e.g. `acme.com`. |
| `provider` *(required)* | One of the slugs above. |
| `dkim_value` | The DKIM public key. If omitted, DKIM is described in a note instead of emitted (the key is issued by your provider). |
| `dkim_selector` | Overrides the default selector (`google` / `zoho`). |
| `m365_tenant` | Your `<tenant>.onmicrosoft.com` label — required to emit the Microsoft 365 DKIM CNAMEs. |
| `dmarc_policy` | `none` (default), `quarantine`, or `reject`. |
| `dmarc_rua` | Aggregate-report mailbox, e.g. `dmarc@acme.com`. |
| `existing_spf` | An SPF record the domain already publishes; the provider's SPF is **merged** into it. |

## SPF merge

A domain must have exactly **one** `v=spf1` record. If the customer already
sends mail (say via Mailgun), pass their current record as `existing_spf` and the
provider's mechanisms are folded in, deduped, with a single terminal `all` (the
existing record's qualifier wins, so a merge never loosens the policy):

```
existing_spf: v=spf1 include:mailgun.org ~all
provider:     v=spf1 include:_spf.google.com ~all
→ merged:     v=spf1 include:mailgun.org include:_spf.google.com ~all
```

## DKIM & DMARC

- **DKIM** keys are generated in the provider's admin console (they're unique per
  domain), so we can't derive them. Pass `dkim_value` to include the record, or
  follow the returned note to add it yourself.
- **DMARC** starts at `p=none` (monitor only). After confirming legitimate mail
  passes DKIM/SPF, tighten to `quarantine`, then `reject`.

---

# Provider setup

Source: https://connectdomain.app/docs/dns/provider-setup

Exactly what a customer (or you, on their behalf) must do to create the
credential each adapter needs, plus any account-eligibility gates. Store the
resulting value via `POST /v1/applications/{id}/credentials` with the matching
`provider` key (see [DNS providers](/docs/dns/providers)); it is encrypted at rest.

> Scope every credential to the minimum needed (edit rights on the target
> zone). Never reuse an account-wide admin token.

## BYO API token

| Provider | `provider` key | How to create the credential | Gate |
| --- | --- | --- | --- |
| Cloudflare | `cloudflare` | My Profile → API Tokens → Create Token → *Edit zone DNS* scoped to the zone. | none |
| DigitalOcean | `digitalocean` | API → Tokens → Generate (write scope). | none |
| Gandi | `gandi` | Account → Security → API key (LiveDNS). | none |
| deSEC | `desec` | Account → generate a token. | none |
| Hetzner | `hetzner` | DNS Console → API tokens. | none |
| Vercel | `vercel` | Account Settings → Tokens. | none |
| DNSimple | `dnsimple` | Account → Automation → API tokens. | none |
| Porkbun | `porkbun` | Account → API Access → enable + create key/secret → credential `apikey:secretapikey`. | must enable API per domain |
| Linode | `linode` | Cloud Manager → API Tokens (Domains: read/write). | none |
| Vultr | `vultr` | Account → API → enable + key. | IP allowlist optional |
| Name.com | `namecom` | Account → API → token → credential `user:token`. | none |
| Netlify | `netlify` | User Settings → Applications → Personal access token. | domain must be a Netlify DNS zone |
| **GoDaddy** | `godaddy` | Developer portal → API Keys → production key → credential `key:secret`. | **Production API access is gated by account tier (historically ≥10 domains / eligible reseller/pro).** Verify current eligibility. |

## Machine credential (cloud / OAuth2)

These are still "bring your own credential" — the customer creates a machine
identity in their cloud console. No approval of *your* service is required, but
the customer needs cloud-admin access.

### Amazon Route 53 — `route53`
- Create an IAM user/role with a policy allowing `route53:ChangeResourceRecordSets`
  and `route53:ListResourceRecordSets`/`GetHostedZone` on the target hosted zone.
- Credential: `accessKeyId:secretAccessKey` (optionally `:region`).
- The connection's `zone` is the **Hosted Zone ID** (e.g. `Z123ABC`), not the name.
- Auth is AWS SigV4 (implemented in-adapter; no AWS SDK).

### Google Cloud DNS — `gcpdns`
- Create a service account with role `roles/dns.admin` (or narrower) on the project.
- Download its JSON key. Credential = that JSON with a `managed_zone` field added
  (the Cloud DNS zone name). `project_id` is read from the key.
- Auth is OAuth2 via a signed (RS256) JWT assertion (implemented in-adapter).

### Azure DNS — `azuredns`
- Register an app in Entra ID; create a client secret; grant it *DNS Zone
  Contributor* on the resource group holding the zone.
- Credential JSON: `{tenant_id, client_id, client_secret, subscription_id, resource_group}`.
- Auth is the Entra client-credentials flow (implemented in-adapter).

### Namecheap — `namecheap`
- Enable API access (Profile → Tools → API Access) and **whitelist your server's
  public IP**.
- Credential: `apiUser:apiKey:clientIp`.
- **Gate:** API access requires meeting Namecheap's threshold (e.g. 20+ domains,
  or a balance/spend minimum). Verify eligibility.

## Redirect / no-token

- **Domain Connect** (`domain-connect`) — no stored credential; the customer is
  redirected to their provider to apply a template. See
  [DNS providers](/docs/dns/providers#domain-connect).
- **Squarespace** — no third-party write API; use Domain Connect or guided-manual.

## The frictionless "connect account" UX

The one-click *"Log in with your provider, click Allow"* experience needs a
3-legged OAuth app **registered and approved with each provider under your
company's identity** — a business/legal step, not code. Until then, BYO-token,
machine-credential, and Domain Connect cover the same providers without it.

---

# DNS providers

Source: https://connectdomain.app/docs/dns/providers

When a customer's domain is at a supported provider and you've stored a
**delegated credential**, the control-plane writes the DNS records itself — no
manual step. Otherwise the flow falls back to guided-manual (copy the records) or
Domain Connect (provider-applied template).

## Auto-write fleet

These providers have a libdns-shaped writer registered in the adapter registry.
Each is idempotent and reconciles full record sets (multi-value rrsets are
preserved and stale records removed).

| Provider | Key | Credential |
| --- | --- | --- |
| Cloudflare | `cloudflare` | Scoped API token (Bearer) |
| DigitalOcean | `digitalocean` | API token (Bearer) |
| Gandi | `gandi` | LiveDNS API key |
| deSEC | `desec` | API token |
| Hetzner | `hetzner` | DNS API token |
| Vercel | `vercel` | API token (Bearer) |
| DNSimple | `dnsimple` | API token (Bearer) |
| Porkbun | `porkbun` | `apikey:secretapikey` pair |
| Linode | `linode` | Personal access token (Bearer) |
| Vultr | `vultr` | API key (Bearer) |
| Name.com | `namecom` | `user:token` (Basic) |
| GoDaddy | `godaddy` | `key:secret` (sso-key) |
| IONOS | `ionos` | API key (`prefix.secret`) |
| Netlify | `netlify` | API token (Bearer) |
| Amazon Route 53 | `route53` | `accessKeyId:secretAccessKey[:region]` (SigV4); zone arg = Hosted Zone ID |
| Google Cloud DNS | `gcpdns` | Service-account JSON (+ `managed_zone`); OAuth2 JWT → bearer |
| Azure DNS | `azuredns` | JSON `{tenant_id,client_id,client_secret,subscription_id,resource_group}`; Entra OAuth2 |
| Namecheap | `namecheap` | `apiUser:apiKey:clientIp` (IP must be whitelisted) |
| Domain Connect | `domain-connect` | Redirect-based (no server token) |

The live set is returned by `GET /v1/providers` and shown in the console's
**Providers** view with logos.

## Storing a delegated credential

```bash
curl -X POST http://localhost:8080/v1/applications/<APP_ID>/credentials \
  -H "Authorization: Bearer <YOUR_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"provider":"cloudflare","token":"<scoped-provider-token>"}'
```

- The token should be **scoped** to the customer's zone(s) with edit rights.
- It is **encrypted at rest** (AES-256-GCM) when `CREDENTIAL_ENC_KEY` is set;
  production requires the key (see [Security](/docs/security/overview)).
- Once stored, new `POST /v1/connections` for that application auto-write records
  and skip the manual TXT step — the scoped token is implicit proof of control.

## How connecting works — what you need per provider

There are three connection models, with different real-world requirements.
**"Code ✅" means the adapter is built and tested; the "External" column is what
you or your customer must still do — the parts code can't remove.**

| Model | Providers | Code | External step |
| --- | --- | --- | --- |
| **BYO API token** | Cloudflare, DigitalOcean, Gandi, deSEC, Hetzner, Vercel, DNSimple, Porkbun, Linode, Vultr, Name.com, GoDaddy, IONOS, Netlify | ✅ | Customer generates a scoped token and pastes it. No approval for most — **but GoDaddy gates production API keys by account tier (historically ≥10 domains / reseller), and Namecheap requires enabling API access + IP-whitelisting + an account threshold.** |
| **Machine credential (OAuth2 / signed)** | Google Cloud DNS, Azure DNS, Amazon Route 53 | ✅ | Customer creates a service account (GCP) / app registration (Azure) / IAM key (AWS) in their cloud console and pastes the credential. No approval of *your* service; the customer needs cloud-admin access. |
| **3-legged consumer OAuth** ("Log in with `<provider>`, click Allow") | — | ❌ (business step) | **You** register an OAuth app with each provider and pass their partner/approval process (legal, branding, security review). This is per-provider and cannot be done in code. **Domain Connect (below) is the standard alternative that avoids this.** |

So: the built adapters are code-complete. Going live *as a service* means (a) some
providers gate who can get API access, and (b) the frictionless "connect account"
UX requires per-provider OAuth-app approval that only your company can obtain.

## Domain Connect

Domain Connect is redirect-based: the customer is sent to their provider, which
applies a template and returns. There is no server-to-server token. Discovery is
via `TXT _domainconnect.<domain>` → the provider's settings endpoint. It is the
standard way to offer a smooth connect flow **without** registering a bespoke
OAuth app per provider.

## Roadmap

- **Squarespace** — no general third-party DNS-record write API; connect via
  Domain Connect / the guided-manual flow instead of a token adapter.
- Additional libdns providers can be added by implementing a `Writer` +
  conformance test — see the adapter package README. Per-provider credential
  creation steps live in [Provider setup](/docs/dns/provider-setup).

---

# Quickstart

Source: https://connectdomain.app/docs/getting-started/quickstart

Connect your first custom domain end to end in about five minutes. This uses the
API directly; to embed the UI instead, see [The widget SDK](/docs/widget-sdk/overview).

## 0. Run the stack (local)

```bash
cd app
make dev     # boots Postgres + control-plane + edge + demo, prints URLs
```

The control-plane listens on `http://localhost:8080`. A demo application and API
key (`sk_test_demo`) are seeded. For real deployments see
[Self-hosting](/docs/self-hosting/overview).

## 1. Create an application

An **application** is your product surface. It carries the record template new
connections inherit and the allowed embed origins.

```bash
curl -X POST http://localhost:8080/v1/applications \
  -H "Authorization: Bearer sk_test_demo" \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme Sites","allowed_origins":["https://app.acme.example"]}'
```

Creating applications and keys requires a key with the `apps:admin` scope — see
[Authentication](/docs/authentication/overview).

## 2. Create an API key

```bash
curl -X POST http://localhost:8080/v1/applications/<APP_ID>/keys \
  -H "Authorization: Bearer sk_test_demo" \
  -H "Content-Type: application/json" \
  -d '{"name":"server-prod","scopes":["connections:read","connections:write"]}'
```

The `secret` is returned **once** — store it now; it's kept only as a hash. A key
can never grant scopes the calling key doesn't itself hold.

## 3. Start a connection

```bash
curl -X POST http://localhost:8080/v1/connections \
  -H "Authorization: Bearer <YOUR_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"application_id":"<APP_ID>","hostname":"app.customer.com"}'
```

The response includes the connection `id` (also the `jobId`), the desired DNS
records, and an `ownership_challenge` — a `TXT` record the customer must publish
to prove control:

```json
{
  "id": "…",
  "state": "pending_ownership",
  "ownership_challenge": { "name": "_customdomain-challenge.app.customer.com", "type": "TXT", "value": "cd-verify-…" }
}
```

If the application has a stored [delegated DNS credential](/docs/dns/providers), the
records (and no manual step) are written automatically and the connection jumps
straight to `propagating` — you can skip to step 6.

## 4. Publish DNS

The customer adds, at their DNS provider:

- The **ownership TXT** from the challenge.
- The **desired records** (returned on the connection) pointing the hostname at
  the edge.

## 5. Verify ownership

```bash
curl -X POST http://localhost:8080/v1/connections/<ID>/verify \
  -H "Authorization: Bearer <YOUR_KEY>"
```

On success the connection moves to `verified`, which opens the edge `ask` gate.
(The background worker also does this automatically once the TXT is visible.)

## 6. Watch propagation → live

```bash
curl -X POST http://localhost:8080/v1/connections/<ID>/records:check \
  -H "Authorization: Bearer <YOUR_KEY>"
```

When every desired record resolves to its intended value, the connection becomes
`live`, the certificate is issued on the next TLS handshake, and the edge starts
proxying `https://app.customer.com` to your origin. You'll receive
`ssl.issued` and `domain.flow.completed` [webhooks](/docs/webhooks/overview).

## Next steps

- Embed the [widget](/docs/widget-sdk/overview) so customers do steps 3–6 themselves.
- Register [webhooks](/docs/webhooks/overview) to react to go-live and drift.
- Store a [delegated credential](/docs/dns/providers) to skip the manual DNS step.

---

# Overview

Source: https://connectdomain.app/docs/webhooks/overview

Register HTTPS endpoints to receive signed events as connections progress.

## Register an endpoint

```bash
curl -X POST http://localhost:8080/v1/webhook-endpoints \
  -H "Authorization: Bearer <YOUR_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://api.acme.example/webhooks/cd","events":["ssl.issued","domain.drift"]}'
# → { "id": "...", "url": "...", "events": [...], "signing_secret": "..." }
```

The `signing_secret` is returned **once** — store it to verify signatures. An
empty `events` array subscribes to all events.

## Event catalog

| Event | Fires when | `data` |
| --- | --- | --- |
| `domain.added` | A delegated auto-write succeeded at create; connection → `propagating`. | `{hostname, setup_type:"automatic"}` |
| `domain.verified` | Ownership TXT confirmed; connection → `verified`. | `{hostname}` |
| `ssl.issued` | All records propagated, cert mirror written; connection → `live`. | `{hostname}` |
| `domain.flow.completed` | Fired together with `ssl.issued` on go-live. | `{hostname}` |
| `domain.drift` | A live domain's records stopped resolving correctly; → `drifted`. | `{hostname}` |

`ssl.issued` and `domain.flow.completed` always fire as a pair on the live
transition, exactly once (a compare-and-swap guarantees a single actor — the API
`records:check` or the background worker — fires them and meters usage).

## Payload envelope

```json
{
  "type": "ssl.issued",
  "jobId": "<connection id>",
  "data": { "hostname": "app.customer.com" },
  "sent_at": "2026-07-02T12:00:00Z"
}
```

`jobId` is the connection UUID.

## Delivery, retries, and replay

- A delivery is a success on any `2xx`; the HTTP client times out at 5s.
- Every attempt is persisted. Failures retry with exponential backoff
  (1, 2, 4, 8, 16, 32 minutes, capped at 60) up to 6 attempts.
- Inspect deliveries: `GET /v1/webhook-deliveries` (status, attempts,
  `next_retry_at`).
- Re-send manually: `POST /v1/webhook-deliveries/{id}/replay` — also available as
  a button in the console's Webhooks view.

Endpoints should be **idempotent**: the same event may be delivered more than
once (retries, replays). Deduplicate on `jobId` + `type`.

See [Verifying signatures](/docs/webhooks/verifying-signatures) next.

---

# Verifying signatures

Source: https://connectdomain.app/docs/webhooks/verifying-signatures

Each request carries two headers:

- `X-CD-Timestamp: <unix seconds>` — when we signed the delivery.
- `X-CD-Signature: sha256=<hex>` — an HMAC-SHA256, using your endpoint's signing
  secret, over the string `${timestamp}.${rawBody}` (the timestamp, a dot,
  then the **exact bytes** of the request body).

Binding the timestamp into the signature lets you reject **replays**: check that
the timestamp is recent (we recommend a 5-minute window) before trusting the
payload. Each attempt — including retries and replays — is re-signed with a
fresh timestamp.

```js
const crypto = require('crypto');

function verify(rawBody, tsHeader, sigHeader, secret, toleranceSec = 300) {
  const ts = parseInt(tsHeader, 10);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false; // replay window
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(`${ts}.`)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(sigHeader || ''), b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Compute the HMAC over the exact bytes received (don't re-serialize the JSON) and
prepend `${timestamp}.` before hashing.

---

# Installation & embed

Source: https://connectdomain.app/docs/widget-sdk/installation-and-embed

Load the hosted bundle and open the modal with a token minted by your backend:

```html
<script src="https://connectdomain.app/sdk/v1/connectdomain.js"></script>
<button id="connect">Connect your domain</button>
<script>
  document.getElementById('connect').addEventListener('click', async () => {
    // Fetch a fresh token from YOUR backend (which calls POST /v1/tokens with
    // your API key). Never ship the API key to the browser.
    const { application_id, token } = await fetch('/my-backend/mint-widget-token', {
      method: 'POST',
    }).then((r) => r.json());

    window.connectdomain.open({
      applicationId: application_id,
      token,
      brandName: 'Your App',
      email: true, // optional: offer email (MX/SPF/DKIM/DMARC) setup after connect
      onSuccess: (r) => console.log('domain live', r.domain),
      onClose: (r) => console.log('closed', r.succeeded),
    });
  });
</script>
```

### Email setup

Pass `email: true` to add an optional step after the domain connects: the user
picks their mailbox provider (Google Workspace, Microsoft 365, or Zoho) and the
widget writes the MX/DKIM/DMARC records via your delegated DNS credential,
showing the apex SPF record to add manually (so it never clobbers an existing
one). See [Email DNS](/docs/dns/email-dns) for the underlying API.

`window.connectdomain.open()` is the canonical entry point; `showConnect()` is a
kept alias, and `window.customdomain` still points at the same object for the
pre-rename API. `baseUrl` defaults to `https://api.connectdomain.app` — override
it only when self-hosting the control plane.

Signed-in users can grab their `applicationId`, mint a live test token, and copy
a ready-to-paste snippet from the **Embed** tab in the dashboard.

### White-label theming

Pass a `theme` object to match your brand. All fields are optional:

```js
window.connectdomain.open({
  applicationId, token,
  theme: {
    accentColor: '#0ea5e9',      // buttons + progress
    accentTextColor: '#ffffff',
    background: '#ffffff',
    foreground: '#111827',
    radius: '10px',
    logoUrl: 'https://yourcdn.example/logo.svg', // header logo
    hideBranding: true,           // hide the "Secured by Connect Domain" mark
  },
});
```

Theme values are applied as CSS custom properties on the widget's shadow host,
so they never leak into your page. You can also override the properties directly
in CSS on `#connectdomain-widget-root` (`--cd-brand`, `--cd-bg`, `--cd-fg`,
`--cd-radius`, …).

---

# Overview

Source: https://connectdomain.app/docs/widget-sdk/overview

The widget is a drop-in "Connect your domain" modal. It renders inside a Shadow
DOM (host CSS can't leak in or out), is fully keyboard-accessible, and adapts to
mobile as a bottom sheet.

## How it fits together

1. Your backend mints a short-lived [widget JWT](/docs/authentication/widget-tokens)
   (never expose an API key to the browser).
2. The browser loads the widget bundle and opens it with that token.
3. The widget calls the browser-facing API (`domains:check`, `connections`,
   `records:check`) and drives the [connect flow](/docs/connect-flow/overview) to
   success.

## The flow (screens)

- **Enter domain** → validates and analyzes the domain.
- **Domain analysis** → detects the DNS provider and setup type.
- **Automatic setup** → confirm; the connection is created and records written.
- **Manual configuration** → shows the exact records with copy buttons.
- **In progress** → polls propagation; shows a stall hint after ~45s.
- **Success** → the domain is live.
- **Error** → retry, or switch to manual.

Overlays handle exit-confirm (Esc) and session-expired (on 401/403).

Next: [install and embed the widget](/docs/widget-sdk/installation-and-embed).

---

# Reference

Source: https://connectdomain.app/docs/widget-sdk/reference

## Host events

These fire both as `window` CustomEvents (`event.detail`) and, where noted, as
the `onSuccess`/`onClose` callbacks you pass to `open()`.

| Event | Fires | `detail` |
| --- | --- | --- |
| `connectdomain:step` | on every screen change | `{ domain, provider, step, jobId }` |
| `connectdomain:success` | when the domain reaches live | `{ domain, success, setupType, provider, jobId }` (the `onSuccess` callback receives `{ id, domain }`) |
| `connectdomain:close` | when the modal closes | `{ domain, success, setupType, provider, lastStatus, jobId, reason }` (the `onClose` callback receives `{ id, domain, succeeded }`) |

## Token lifecycle

Widget tokens expire after 60 minutes. On expiry the widget shows a
"session expired" state; your host app should mint a fresh token (see
[Widget tokens](/docs/authentication/widget-tokens)) and reopen. A
domain-bound token may only act on the hostname it was minted for.

## Accessibility & mobile

Focus trap, `aria-modal`, a live-region announcer, 44px touch targets, safe-area
insets, `prefers-reduced-motion`, and `prefers-color-scheme` dark mode are all
built in.

---

# Configuration

Source: https://connectdomain.app/docs/self-hosting/configuration

## Control-plane

| Var | Purpose | Default |
| --- | --- | --- |
| `DATABASE_URL` | Postgres DSN | local socket DSN |
| `PORT` | HTTP port | `8080` |
| `TOKEN_HMAC_SECRET` | widget JWT signing secret | dev default (**must set in prod**) |
| `EDGE_ORIGIN_DEFAULT` | fallback proxy origin | `http://localhost:3001` |
| `WORKER_ENABLED` | `1` starts background pollers | off |
| `CD_ENV` | `production` enables guardrails | unset |
| `CREDENTIAL_ENC_KEY` | 32-byte key (base64/hex) to encrypt DNS credentials | unset (required in prod to store credentials) |
| `CORS_ALLOWED_ORIGINS` | optional comma-separated allowlist | unset (reflects) |
| `DEMO_API_KEY` | seeded demo key | `sk_test_demo` |
| `ADAPTER_TEST_MOCK`, `DNS_STUB_FILE`, `DNS_STUB_JSON` | test bypasses (refused in prod) | unset |

## Edge

| Var | Purpose | Default |
| --- | --- | --- |
| `CONTROLPLANE_URL` | control-plane base for `ask` | `http://localhost:8080` |
| `EDGE_TLS_PORT` | on-demand-TLS listener | `8443` |
| `EDGE_HTTP_PORT` | plain-HTTP admin/health | `8081` |
| `EDGE_ISSUER` | `internal` or `letsencrypt` | `internal` |
| `EDGE_STORAGE_DIR` | cert/key + CA storage | `./edge-data` |
| `EDGE_ACME_EMAIL` | ACME contact | `""` |
| `EDGE_ACME_STAGING` | Let's Encrypt staging | `false` |
| `EDGE_ASK_TIMEOUT` | max time for one `ask` | `3s` |
| `EDGE_ASK_POSITIVE_TTL` | cache TTL for `allow=true` | `60s` |
| `EDGE_ASK_NEGATIVE_TTL` | cache TTL for `allow=false` | `5s` |
| `EDGE_PROXY_TIMEOUT` | upstream request timeout | `30s` |

## Production guardrails

Setting `CD_ENV=production` makes the control-plane refuse to start (or store) on
unsafe configuration:

- `TOKEN_HMAC_SECRET` unset or the dev default → refuse to start.
- `ADAPTER_TEST_MOCK` or `DNS_STUB_*` set → refuse to start.
- Storing a DNS credential without `CREDENTIAL_ENC_KEY` → refused.

## Observability

`GET /metrics` exposes Prometheus gauges (aggregate only, no tenant identifiers):

- `customdomain_connections{state=…}`
- `customdomain_certificates{status=…}`
- `customdomain_webhook_deliveries{status=…}`
- `customdomain_certificates_expiring_soon` — the renewal-failure alerting
  signal; alert on this (and `certificates{status="failed"}`) in your monitoring
  system.

## Background worker

Set `WORKER_ENABLED=1` to run the pollers that advance connections on their own:
auto-verify (ownership TXT), auto-propagate (records → live), drift detection,
and webhook retry. Interval 5s, batch 100, cert TTL mirror 90d.

---

# Overview

Source: https://connectdomain.app/docs/self-hosting/overview

## Components

- **control-plane** (Go + Postgres) — REST API, `ask`, `metrics`, workers.
- **edge** (Go + CertMagic) — on-demand TLS + reverse proxy.
- **Postgres** — the system of record.
- Optional: **console** (developer dashboard) and **demo** (host page).

See `ARCHITECTURE.md` in the repository for the full module map.

## Local development

```bash
cd app
make dev     # native processes: Postgres + control-plane + edge + demo
make test    # integration suite (scripts/it.sh)
```

For real environments, `deploy/` contains Dockerfiles, compose, and `k8s/`.

## Deployment topology

1. Run Postgres and apply `db/*.sql`.
2. Run the control-plane; expose only `/v1/*` publicly.
   - Keep `/internal/ask` reachable **only** from the edge.
   - Keep `/metrics` reachable **only** from your scraper.
3. Run the edge with a public IP; point customer domains at it.
   - Set `EDGE_ISSUER=letsencrypt` (with `EDGE_ACME_EMAIL`) for public certs;
     dev uses an internal CA.
4. Point the edge at the control-plane via `CONTROLPLANE_URL`.

Full environment-variable tables are on the [configuration](/docs/self-hosting/configuration)
page.

---

# Overview

Source: https://connectdomain.app/docs/security/overview

## Tenancy & isolation

Every object belongs to a tenant. Tenant-scoped endpoints filter by the caller's
tenant, and all `/applications/{id}/*` mutations verify the application belongs
to the caller before acting. Cross-tenant access returns `404` — never another
tenant's data, and never a `403` that would confirm an object exists.

## Authorization (RBAC)

API keys carry [scopes](/docs/authentication/overview#scopes). Admin mutations require
`apps:admin`. A key can only mint another key (or a widget token) with scopes it
itself holds — no privilege escalation. Minting a widget JWT requires
`connections:write`, so a read-only key can't gain write via the widget surface.

## Ownership is value-checked

Ownership and propagation checks require the **exact desired value** (TXT equals
the challenge; A/AAAA contain the desired IP). Existence alone is never accepted,
because the DNS record set is controlled by whoever is being verified.

## Secrets handling

- API keys are stored as SHA-256 hashes; the secret is shown once and never
  returned again (not by list endpoints, `/metrics`, or `/healthz`).
- Delegated DNS credentials are **encrypted at rest** (AES-256-GCM) under
  `CREDENTIAL_ENC_KEY`; production refuses to store them without a key.
- The widget JWT signing secret (`TOKEN_HMAC_SECRET`) must be set to a
  non-default value in production or the service refuses to start.
- Application secret refs and internal tenant IDs are never serialized into API
  responses.

## Log hygiene

Key material is never logged (only last-4 for correlation). Ownership challenge
values are never logged. Internal errors are logged server-side under a
correlation `ref`; clients receive a generic message — raw driver/SQL errors are
never returned, including on the pre-authentication path.

## Unauthenticated surfaces

`/healthz`, `/metrics`, and `/internal/ask` require no auth and must be
**network-isolated** in production:

- `/internal/ask` answers only `{allow, origin}` — it does **not** expose the
  tenant id.
- `/metrics` exposes only aggregate counts (no tenant identifiers or secrets).

## CORS

The API is bearer-authenticated with no ambient cookies, so origins are
reflected by default. Set `CORS_ALLOWED_ORIGINS` to restrict to a fixed
allowlist as defense-in-depth. Per-application embed control is enforced at the
widget layer via `allowed_origins`.

## Exactly-once side effects

The live transition (and its webhooks + usage metering) is guarded by a
compare-and-swap so the API and the background worker can't double-fire.
Connect-quota reservation is atomic under a Postgres advisory lock.

## Reporting

This is an open-source project; report suspected vulnerabilities privately to the
maintainers before public disclosure.

---

# Create an API key — secret shown ONCE in this response

Source: https://connectdomain.app/docs/api-reference/applications/createApiKey

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/applications/{id}/keys","method":"post"}]} showDescription />
    </>
  );
}

---

# Create an application (integration)

Source: https://connectdomain.app/docs/api-reference/applications/createApplication

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/applications","method":"post"}]} showDescription />
    </>
  );
}

---

# List API keys (hash never returned; last4 only)

Source: https://connectdomain.app/docs/api-reference/applications/listApiKeys

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/applications/{id}/keys","method":"get"}]} showDescription />
    </>
  );
}

---

# List applications

Source: https://connectdomain.app/docs/api-reference/applications/listApplications

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/applications","method":"get"}]} showDescription />
    </>
  );
}

---

# Update allowlist / record template / name

Source: https://connectdomain.app/docs/api-reference/applications/updateApplication

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/applications/{id}","method":"patch"}]} showDescription />
    </>
  );
}

---

# Generate + write email DNS for the connection's domain

Source: https://connectdomain.app/docs/api-reference/connections/applyEmailRecords

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/connections/{id}/email:apply","method":"post"}]} showDescription />
    </>
  );
}

---

# Offboard a domain (soft-delete to state=archived)

Source: https://connectdomain.app/docs/api-reference/connections/archiveConnection

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/connections/{id}","method":"delete"}]} showDescription />
    </>
  );
}

---

# Per-record propagated/missing report

Source: https://connectdomain.app/docs/api-reference/connections/checkConnectionRecords

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/connections/{id}/records:check","method":"post"}]} showDescription />
    </>
  );
}

---

# Start connecting a domain (idempotent via Idempotency-Key)

Source: https://connectdomain.app/docs/api-reference/connections/createConnection

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/connections","method":"post"}]} showDescription />
    </>
  );
}

---

# Connection status (state, records desired vs observed, cert)

Source: https://connectdomain.app/docs/api-reference/connections/getConnection

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/connections/{id}","method":"get"}]} showDescription />
    </>
  );
}

---

# List/filter connections (cursor pagination)

Source: https://connectdomain.app/docs/api-reference/connections/listConnections

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/connections","method":"get"}]} showDescription />
    </>
  );
}

---

# Detect provider / setup type for a domain (read-only)

Source: https://connectdomain.app/docs/api-reference/domains/checkDomain

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/domains:check","method":"post"}]} showDescription />
    </>
  );
}

---

# Generate email DNS records (MX/SPF/DKIM/DMARC) for a mailbox provider

Source: https://connectdomain.app/docs/api-reference/email/emailRecords

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/email/records","method":"post"}]} showDescription />
    </>
  );
}

---

# Mint a short-lived widget JWT for one connect flow

Source: https://connectdomain.app/docs/api-reference/tokens/createWidgetToken

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/tokens","method":"post"}]} showDescription />
    </>
  );
}

---

# Metered counters for the current period (billing reconciliation)

Source: https://connectdomain.app/docs/api-reference/usage/getUsage

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/usage","method":"get"}]} showDescription />
    </>
  );
}

---

# Register a webhook receiver

Source: https://connectdomain.app/docs/api-reference/webhooks/createWebhookEndpoint

{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

export default function Layout(props) {
  const { APIPage, OpenAPIPage } = props.components ?? {};
  // "APIPage" is the old name from v10, this allows both for backward compatibility
  const Comp = OpenAPIPage ?? APIPage;
  return (
    <>
      {props.children}
      <Comp document="../openapi-v1.yaml" operations={[{"path":"/webhook-endpoints","method":"post"}]} showDescription />
    </>
  );
}