Custom domain flow
A business can attach a custom domain to its website, either by bringing one
it already owns (BYO, proved with a DNS TXT record) or by buying one through
us (registered at Porkbun, our domain registrar). Both
paths converge on a single verified domain stored on the businesses row.
Lifecycle states
Section titled “Lifecycle states”Two columns on the businesses row track a domain (apps/scheduler-api/src/db/schema.ts):
domain_status—none→pending→verified/faileddomain_source—byo(DNS-verified) orpurchased(registered via Porkbun)
stateDiagram-v2
[*] --> none
none --> pending: set custom domain (BYO)
pending --> verified: DNS TXT observed
pending --> failed: TXT absent / wrong
failed --> pending: re-set / retry
none --> verified: purchase via Porkbun
verified --> verified: sticky — never downgrades
verified is sticky: once ownership is proven it is never re-checked or
downgraded by a later run (domain-verification.ts, verify routes, and the
ensureDomainVerification convergence activity all short-circuit on it).
Path A — bring your own domain (BYO)
Section titled “Path A — bring your own domain (BYO)”The owner already controls a domain (e.g. bought elsewhere) and proves it with a DNS TXT record.
sequenceDiagram
actor Owner
participant Portal as portal / picker
participant API as scheduler-api
participant DNS
Owner->>Portal: enter domain (acme.com)
Portal->>API: set domain
API->>API: validate hostname → status=pending, mint token
API-->>Owner: add TXT _nexus-verify.acme.com = <token>
Owner->>DNS: create TXT record
Owner->>API: verify
API->>DNS: resolveTxt(_nexus-verify.acme.com)
DNS-->>API: <token>
API->>API: status=verified, source=byo (sticky)
- Capture.
customDomainSchema(apps/scheduler-api/src/lib/domain.ts) trims and lowercases input, andisValidHostname()rejects anything that isn’t a bare registrable hostname: it requires at least one dot, allows alphanumeric labels with interior hyphens only (1–63 chars each, 1–253 total), and rejects IPv4 literals and any scheme/path. An empty string clears the domain back tonone. Setting an unchanged domain is a no-op so it never resets verification state. - Verify. The challenge record is
_nexus-verify.<domain>(TXT) whose value is the per-domaindomain_verification_token(random 32 hex chars). Verification uses Node’sresolveTxt; a missing record returns failed, not an error.
Path B — buy a domain through Porkbun
Section titled “Path B — buy a domain through Porkbun”For owners who don’t have a domain, we suggest available names and register the chosen one. A purchased domain is auto-verified (we control it), so there’s no DNS-TXT step.
sequenceDiagram
actor Admin
participant API as scheduler-api
participant AI as Claude (haiku-4-5)
participant PB as Porkbun v3
participant ST as Stripe
Admin->>API: suggest (business name + keywords)
API->>AI: generate brandable base names
AI-->>API: ["acme", "acmecare", …]
API->>PB: checkDomain(name.tld) × candidates
PB-->>API: available + price (USD)
API-->>Admin: available domains, cheapest first
Admin->>API: purchase (domain, confirmedTotalCents)
API->>API: gate on? price unchanged? business exists?
API->>ST: invoice (comped for contract businesses)
API->>PB: domain/create (cost¢, Idempotency-Key)
PB-->>API: SUCCESS
API->>API: status=verified, source=purchased
- Search.
suggest.service.tsasksclaude-haiku-4-5for ~8 short, brandable base names, then checks availability + price for each across the default TLDs (com,co,io) via Porkbun, returning only available domains sorted by price. If the AI is unavailable it degrades gracefully to a name derived from the business name. Rate-limited to 10/min (LLM + registrar fan-out). - Purchase. Gated on the runtime toggle (below). Before charging it re-checks
the live Porkbun price and compares to the operator’s
confirmedTotalCentsat cent precision (drift →409), confirms the business exists, then creates a Stripe invoice (contract businesses are netted to $0 via theNEXUS_CONTRACT_COMPcoupon) and finally calls Porkbundomain/create. The order carries an idempotency key (domain-register:{businessId}:{domain}) so a retry never double-charges; on registrar failure the invoice is refunded.
The money gate
Section titled “The money gate”Live purchasing is gated by platform_config.domains_purchase_enabled — a
DB-backed boolean (default false) flipped from the admin portal, not an
env var. When it’s off, purchaseDomain short-circuits before any Porkbun call
and returns { dryRun: true, ordered: false }. The no-charge guarantee is therefore
ours and vendor-independent — it does not rely on Porkbun’s own dryRun flag.
The owner picker (token-scoped deeplink)
Section titled “The owner picker (token-scoped deeplink)”So an owner can choose their own domain without an admin account, an admin emails a
picker link: ${PORTAL_BUSINESS_URL}/domains#token=<token>. The token lives in
the URL fragment, which browsers never send to the server or in Referer
headers — so it can’t leak into access logs or CDN. The token is valid for 7
days (domain-picker.ts).
The picker’s public routes are mounted at /api/public/domains/* and are
token-gated, not session-authenticated — resolve, suggest, connect
(BYO), and verify. Note the scope: the picker can search and connect a
domain, but cannot purchase — purchasing stays admin-gated. An unknown or
expired token returns a generic 404 (it never distinguishes the two).
Porkbun integration
Section titled “Porkbun integration”Registrar access is isolated in the @nexus/registrar workspace package behind
a vendor-neutral RegistrarAdapter interface, with PorkbunRegistrar as today’s
implementation. The package is a thin fetch wrapper over Porkbun’s v3 REST API
(https://api.porkbun.com/api/json/v3) — no SDK — and never reads process.env
itself; scheduler-api injects credentials from env. It’s consumed from source
across the workspace.
| Operation | Porkbun call | Notes |
|---|---|---|
| Availability + price | POST /domain/checkDomain/{domain} |
apikey/secretapikey in body; returns avail + USD price string |
| Register | POST /domain/create/{domain} |
cost in cents, agreeToTerms, dryRun; Idempotency-Key header (Porkbun replays the stored response for 24h) |
Webhooks
Section titled “Webhooks”Porkbun posts domain lifecycle events to /api/webhooks/porkbun
(porkbun-webhook.routes.ts). The receiver fails closed:
- Signature —
HMAC-SHA256(secret, "{timestamp}.{rawBody}"), hex, in headerx-porkbun-signature(an optionalsha256=prefix is tolerated). The HMAC is computed over the raw body before JSON parsing. Verified with a constant-time compare. - Timestamp — header
x-porkbun-webhook-timestamp(Unix seconds), accepted only within a ±300 s replay window. (Note the-webhook-infix — the barex-porkbun-timestampis a different, wrong header name.) - Dedup — header
x-porkbun-webhook-idis the primary key of theporkbun_webhook_eventstable. Gating onprocessed_at(not mere row existence) makes delivery crash-safe: a crash between insert and dispatch recovers on retry. - A missing
PORKBUN_WEBHOOK_SECRET, bad signature, or stale timestamp all return401.
Events map to one of three actions (porkbun-webhook.ts):
| Event | Action | Effect |
|---|---|---|
domain.registered, domain.transfer.completed |
mark-owned | status=verified, source=purchased, clear token, nudge convergence |
domain.expiring |
renewal-nudge | log a warning |
everything else (domain.renewed, dns.record.*) |
acknowledge | 200, no state change |
Deliveries for a domain we don’t recognize are acknowledged silently.
Convergence integration
Section titled “Convergence integration”The ensureDomainVerification activity runs inside the businessConvergence
workflow (convergence-activities.ts). For a domain still pending it re-checks
the DNS TXT record and promotes it to verified if the record is now present —
respecting the sticky invariant. This is the same check as the on-demand verify
route, but driven by the workflow so a domain that gets its DNS fixed later
eventually verifies on its own. See Convergence.
Configuration
Section titled “Configuration”Every domain-related variable is optional; the feature degrades gracefully when unset.
| Variable | Gates | When unset |
|---|---|---|
PORKBUN_API_KEY / PORKBUN_SECRET_KEY |
Availability/price checks + registration | DomainsNotConfiguredError on registrar calls |
ANTHROPIC_API_KEY |
AI name suggestions | Falls back to a business-name-derived candidate |
PORKBUN_WEBHOOK_SECRET |
Webhook signature verification | Webhook returns 401 for all deliveries |
PORTAL_BUSINESS_URL |
Picker deeplink prefix | Defaults to the portal-business prod URL |
platform_config.domains_purchase_enabled (DB, not env) |
Live purchasing | Default false → purchase short-circuits, no charge |
This feature was built across PRs #341–346 (capture → verify → search → purchase → webhook sync).