Skip to content

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.

Two columns on the businesses row track a domain (apps/scheduler-api/src/db/schema.ts):

  • domain_statusnonependingverified / failed
  • domain_sourcebyo (DNS-verified) or purchased (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).

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, and isValidHostname() 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 to none. 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-domain domain_verification_token (random 32 hex chars). Verification uses Node’s resolveTxt; a missing record returns failed, not an error.

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.ts asks claude-haiku-4-5 for ~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 confirmedTotalCents at cent precision (drift → 409), confirms the business exists, then creates a Stripe invoice (contract businesses are netted to $0 via the NEXUS_CONTRACT_COMP coupon) and finally calls Porkbun domain/create. The order carries an idempotency key (domain-register:{businessId}:{domain}) so a retry never double-charges; on registrar failure the invoice is refunded.

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.

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-authenticatedresolve, 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).

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)

Porkbun posts domain lifecycle events to /api/webhooks/porkbun (porkbun-webhook.routes.ts). The receiver fails closed:

  • SignatureHMAC-SHA256(secret, "{timestamp}.{rawBody}"), hex, in header x-porkbun-signature (an optional sha256= 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 bare x-porkbun-timestamp is a different, wrong header name.)
  • Dedup — header x-porkbun-webhook-id is the primary key of the porkbun_webhook_events table. Gating on processed_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 return 401.

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.

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.

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).