# TypeScript custom domains SDK (/docs) Domain SDK handles the custom-domain lifecycle across Vercel, Cloudflare for SaaS, Railway, Render, Netlify, and bunny.net. Add a hostname, show your customer the exact DNS records, and track provider state until the domain is ready. ```ts title="domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { vercel } from "@opencoredev/domain-sdk/vercel"; export const domains = createDomainClient({ provider: vercel({ token: process.env.VERCEL_TOKEN!, projectId: process.env.VERCEL_PROJECT_ID!, }), }); ``` Use Domain SDK when your SaaS needs to show exact DNS instructions, track provider-authoritative status, and keep provider-specific APIs out of the rest of your application. It does not register domains, host DNS, deploy apps, proxy traffic, or store tenant data. ## Read next [#read-next] # Install Domain SDK for TypeScript (/docs/installation) ## 1. Install the package [#1-install-the-package] ```bash title="Terminal" bun add @opencoredev/domain-sdk ``` Domain SDK is server-only. Keep it in API routes, server actions, workers, or another trusted backend. ### Install the agent skill [#install-the-agent-skill] Give Codex, Claude Code, Cursor, and other compatible coding agents the Domain SDK workflow and safety rules: ```bash title="Terminal" npx skills add opencoredev/domain-sdk --skill domain-sdk ``` The skills CLI asks which detected agents should receive the skill. Add `-g` to make it available across all of your projects. ## 2. Choose your provider [#2-choose-your-provider] Use the platform that currently receives traffic for your application. Each adapter is a separate import and uses credentials scoped to one project, service, site, or zone. ## 3. Create the client [#3-create-the-client] Put the client in a server-only module. Pick the tab that matches your provider; its provider page lists the required credentials and platform-specific behavior. ```ts title="src/server/domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { vercel } from "@opencoredev/domain-sdk/vercel"; export const domains = createDomainClient({ provider: vercel({ token: process.env.VERCEL_TOKEN!, projectId: process.env.VERCEL_PROJECT_ID!, }), }); ``` ```ts title="src/server/domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { cloudflareSaaS } from "@opencoredev/domain-sdk/cloudflare"; export const domains = createDomainClient({ provider: cloudflareSaaS({ apiToken: process.env.CLOUDFLARE_API_TOKEN!, zoneId: process.env.CLOUDFLARE_ZONE_ID!, cnameTarget: process.env.CLOUDFLARE_CNAME_TARGET!, }), }); ``` ```ts title="src/server/domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { railway } from "@opencoredev/domain-sdk/railway"; export const domains = createDomainClient({ provider: railway({ token: process.env.RAILWAY_TOKEN!, projectId: process.env.RAILWAY_PROJECT_ID!, environmentId: process.env.RAILWAY_ENVIRONMENT_ID!, serviceId: process.env.RAILWAY_SERVICE_ID!, }), }); ``` ```ts title="src/server/domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { render } from "@opencoredev/domain-sdk/render"; export const domains = createDomainClient({ provider: render({ apiKey: process.env.RENDER_API_KEY!, serviceId: process.env.RENDER_SERVICE_ID!, }), }); ``` ```ts title="src/server/domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { netlify } from "@opencoredev/domain-sdk/netlify"; export const domains = createDomainClient({ provider: netlify({ accessToken: process.env.NETLIFY_ACCESS_TOKEN!, siteId: process.env.NETLIFY_SITE_ID!, }), }); ``` ```ts title="src/server/domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { bunny } from "@opencoredev/domain-sdk/bunny"; export const domains = createDomainClient({ provider: bunny({ apiKey: process.env.BUNNY_API_KEY!, pullZoneId: process.env.BUNNY_PULL_ZONE_ID!, }), }); ``` Never expose provider credentials through `NEXT_PUBLIC_`, `VITE_`, or another browser-accessible environment variable. ## 4. Add and refresh a domain [#4-add-and-refresh-a-domain] ```ts title="src/server/customer-domain.ts" import { domains } from "./domains"; const domain = await domains.add("app.customer.com"); // Show every required record in your customer-facing DNS instructions. const requiredRecords = domain.records.filter((record) => record.required); const latest = await domains.refresh(domain.hostname); if (latest.status === "active") { // The configured provider now reports the domain as ready. } ``` Store the hostname's tenant ownership in your own database. Domain SDK is stateless and the configured provider remains the source of truth for readiness. Read next: [Compare providers](./providers/index.mdx). # createDomainClient (/docs/api-reference/create-domain-client) ```ts title="Signature" createDomainClient(options: { provider: DomainProvider; logger?: DomainLogger }): DomainClient ``` The returned client exposes stable provider ID and capability metadata. Logger methods receive operational messages and sanitized context; hostnames may appear because they are operational identifiers. The SDK never logs credentials. Read next: [`DomainClient`](./domain-client.mdx). # createSubdomainClient (/docs/api-reference/create-subdomain-client) ```ts title="Signature" createSubdomainClient(options: { domainClient: DomainClient; baseDomain: string; reservedLabels?: readonly string[]; }): SubdomainClient ``` | Member | Returns | Behavior | | ---------------------------------------------------- | --------- | ------------------------------------------------------------- | | `baseDomain` | `string` | Normalized parent hostname. | | `wildcardHostname` | `string` | Normalized `*.baseDomain` hostname. | | `supportsWildcard` | `boolean` | Selected provider's wildcard capability. | | `toHostname(label)` | `string` | Normalize and constrain a direct-child label. | | `fromHostname(hostname)` | `string` | Validate the parent boundary and return its normalized label. | | `add/get/refresh/verify/remove(label)` | lifecycle | Run the matching operation for one tenant hostname. | | `waitUntilActive(label)` | `Domain` | Poll one tenant hostname until active. | | `provisionWildcard()` and wildcard lifecycle methods | lifecycle | Add, get, refresh, verify, or remove the wildcard hostname. | | `waitUntilWildcardActive()` | `Domain` | Poll the wildcard hostname until active. | Labels must contain exactly one DNS label. Reserved labels, wildcards, nested subdomains, and hostnames outside `baseDomain` are rejected before any provider request. The client does not allocate labels or store tenant ownership. Claim the normalized label with a unique database constraint and authorize all changes in the application layer. Read next: [Give tenants managed subdomains](../guides/managed-subdomains.mdx). # DnsRecord (/docs/api-reference/dns-record) ```ts title="Type" interface DnsRecord { type: "A" | "AAAA" | "CNAME" | "TXT" | "CAA" | "ALIAS" | "ANAME"; name: string; value: string; ttl?: number; purpose: "routing" | "ownership" | "certificate" | "other"; required: boolean; status: "pending" | "valid" | "invalid" | "unknown"; description?: string; } ``` Read next: [Display DNS records correctly](../guides/display-dns-records.mdx). # DomainClient (/docs/api-reference/domain-client) | Member | Returns | Behavior | | ------------------------------------- | ------------ | ---------------------------------------- | | `provider` | `string` | Stable provider ID. | | `capabilities` | metadata | Optional-operation support. | | `add(hostname, options?)` | `Domain` | Add or safely return an existing domain. | | `get(hostname, options?)` | `Domain` | Retrieve current provider state. | | `refresh(hostname, options?)` | `Domain` | Alias for a fresh get. | | `list(options?)` | `DomainPage` | List with `limit` 1–100 and cursor. | | `verify(hostname, options?)` | `Domain` | Explicitly verify when supported. | | `remove(hostname, options?)` | `void` | Safely remove; absent is success. | | `waitUntilActive(hostname, options?)` | `Domain` | Sequentially poll until active. | Network-aware options accept `signal?: AbortSignal`. Read next: [`Domain`](./domain.mdx). # DomainProvider (/docs/api-reference/domain-provider) ```ts title="Contract" interface DomainProvider { readonly id: string; readonly capabilities: DomainProviderCapabilities; add(input, context): Promise; get(input, context): Promise; list?(input, context): Promise; verify?(input, context): Promise; remove(input, context): Promise; } ``` `ProviderContext` contains only an abort signal and logger. Adapters own fixed API origins and credential-bearing fetch calls. Read next: [Contributing](../project/contributing.mdx). # DomainSdkError (/docs/api-reference/domain-sdk-error) Fields are `code`, optional `provider`, optional `statusCode`, `retryable`, optional `retryAfter` in milliseconds, bounded sanitized `details`, and optional safe `cause`. Codes: `AUTHENTICATION_FAILED`, `PERMISSION_DENIED`, `INVALID_CONFIGURATION`, `INVALID_HOSTNAME`, `DOMAIN_CONFLICT`, `DOMAIN_NOT_FOUND`, `UNSUPPORTED_OPERATION`, `VERIFICATION_FAILED`, `RATE_LIMITED`, `PROVIDER_UNAVAILABLE`, `REQUEST_FAILED`, `TIMEOUT`, `ABORTED`, and `UNKNOWN_ERROR`. Read next: [Error handling](../concepts/error-handling.mdx). # DomainStatus (/docs/api-reference/domain-status) ```ts title="Type" type DomainStatus = | "pending" | "pending_dns" | "pending_verification" | "pending_certificate" | "active" | "misconfigured" | "failed" | "unknown"; ``` Treat only `active` as production-ready. See [Status model](../concepts/status-model.mdx) for semantics. # Domain (/docs/api-reference/domain) | Field | Type | Notes | | ------------------------ | -------------------- | -------------------------------------------------- | | `id` | `string` | Provider-resource identifier safe for persistence. | | `hostname` | `string` | Normalized lowercase ASCII hostname. | | `provider` | `string` | Stable adapter ID. | | `status` | `DomainStatus` | Overall readiness. | | `records` | `DnsRecord[]` | Deduplicated actionable DNS. | | `verification` | `DomainVerification` | Ownership state and records. | | `certificate` | `DomainCertificate` | Managed certificate state. | | `issues` | `DomainIssue[]` | Safe actionable failures. | | `createdAt`, `updatedAt` | `Date?` | Present only when exposed. | Raw provider bodies and credentials are never included. Read next: [`DnsRecord`](./dns-record.mdx). # Domain SDK TypeScript API reference (/docs/api-reference) The Domain SDK API reference documents the public TypeScript exports for managing customer domains. Start with `createDomainClient`, then use the normalized domain types and lifecycle methods regardless of which platform adapter you configure. ## Clients and provider contract [#clients-and-provider-contract] ## Models and utilities [#models-and-utilities] # Testing utilities (/docs/api-reference/testing-utilities) `memoryProvider(options?)` supports initial status, records, certificate state, activation delay, latency, and per-operation failures. Instances expose `activate`, `transition`, `reset`, and immutable call inspection. `createMockDomain`, `createMockDnsRecord`, and `createFailingProvider` generate focused test values. Import all of them from `@opencoredev/domain-sdk/testing`; production root imports remain unaffected. Read next: [Test without external API calls](../guides/test-without-network.mdx). # waitUntilActive (/docs/api-reference/wait-until-active) ```ts title="Signature" waitUntilActive(hostname: string, options?: { timeoutMs?: number; // default 300000 intervalMs?: number; // default 5000, minimum 250 signal?: AbortSignal; onStatus?(domain: Domain): void; }): Promise ``` The first check runs immediately. Retryable errors continue with the larger of interval and `retryAfter`; permanent errors stop. The deadline throws retryable `TIMEOUT`. Read next: [Poll until active](../guides/poll-until-active.mdx). # DNS records UI component (/docs/components/dns-records-table) ## Install [#install] ```bash title="Terminal" bunx --bun shadcn@latest add https://domain-sdk.dev/r/dns-records-table.json ``` ## Usage [#usage] Pass the normalized records returned by Domain SDK. Your application owns refreshing and persistence. ```tsx title="domain-settings.tsx" import { DnsRecordsTable } from "@/components/domain/dns-records-table"; ; ``` The component uses shadcn `Table`, `Badge`, `Button`, and `NativeSelect` with Hugeicons. Names and values are full-cell copy targets, optional records can be filtered, and every copy is confirmed through a polite live region. When your server discovers a supported Domain Connect path, place the separately installable [One-click DNS Setup](./one-click-dns-setup.mdx) component before this manual fallback. # Custom domain UI components (/docs/components) Domain SDK components turn normalized domain records into customer-facing setup interfaces. Use them with your own authenticated server endpoint so provider credentials and tenant authorization stay outside the browser. For the complete server and client flow, follow [Build a custom-domain settings page](/docs/guides/custom-domain-settings-page). # One-click DNS setup component (/docs/components/one-click-dns-setup) ## Install [#install] ```bash title="Terminal" bunx --bun shadcn@latest add https://domain-sdk.dev/r/one-click-dns-setup.json ``` ## Usage [#usage] Render this component only after your server has discovered that the hostname and Domain Connect template are supported. The callback should request a short-lived, signed authorization URL from your backend and then redirect the browser. ```tsx title="domain-settings.tsx" import { OneClickDnsSetup } from "@/components/domain/one-click-dns-setup"; { const response = await fetch(`/api/domains/${domain.id}/dns-authorization`, { method: "POST", }); const { authorizationUrl } = await response.json(); window.location.assign(authorizationUrl); }} onManualSetup={() => setSetupMode("manual")} />; ``` Credentials, DNS discovery, template support checks, request signing, authorization URL generation, callback validation, and post-return refresh belong on the server. Do not construct or sign Domain Connect requests in this component. ## Confirmed provider paths [#confirmed-provider-paths] | Application provider | Customer DNS provider | Setup | | -------------------- | --------------------- | ----------------------------------------------------------- | | Vercel | Cloudflare | Domain Connect | | Railway | Cloudflare | Domain Connect | | Cloudflare for SaaS | Any | Manual unless your own Domain Connect template is onboarded | | Render | Any | Manual records | | Netlify | External DNS | Manual records | | bunny.net | Bunny DNS | Manual records | Cloudflare is the authoritative DNS provider in the confirmed one-click paths above. Eligibility is per hostname and template, so the UI intentionally does not infer support from `domain.provider`. Provider references: [Vercel Domain Connect](https://vercel.com/changelog/automated-dns-configuration-with-domain-connect), [Railway one-click domains](https://blog.railway.com/p/one-click-domains), [Cloudflare Domain Connect](https://developers.cloudflare.com/dns/reference/domain-connect/), [Render custom domains](https://render.com/docs/custom-domains), [Netlify external DNS](https://docs.netlify.com/manage/domains/configure-domains/configure-external-dns/), and [bunny.net SSL setup](https://docs.bunny.net/cdn/ssl-setup). # DNS records (/docs/concepts/dns-records) Every `DnsRecord` has a `type`, full `name`, `value`, `purpose`, `required` flag, and normalized `status`. Identical records are deduplicated; multiple valid verification methods remain available. ```ts title="render-records.ts" for (const record of domain.records.filter((item) => item.required)) { console.log(record.type, record.name, record.value, record.purpose, record.status); } ``` Names are full hostnames where the provider supplies enough context. Your UI may additionally explain registrar shorthand, but it should not replace the authoritative value stored by your application. Read next: [Display DNS records correctly](../guides/display-dns-records.mdx). # Domain lifecycle (/docs/concepts/domain-lifecycle) The lifecycle is `add` → configure returned DNS → `verify` when supported → `refresh` or `waitUntilActive` → `remove`. `add` is safely idempotent when the adapter can prove the hostname already belongs to the configured project, zone, environment, or service. It throws `DOMAIN_CONFLICT` rather than moving a hostname owned elsewhere. `remove` succeeds when the hostname is already absent and never removes a resource from another configured scope. Your application owns tenant authorization and persistence. The provider owns live domain state. Read next: [Status model](./status-model.mdx) and [add and remove domains](../guides/add-remove-domains.mdx). # Error handling (/docs/concepts/error-handling) ```ts title="handle-domain-error.ts" import { DomainSdkError } from "@opencoredev/domain-sdk"; try { await domains.add("app.customer.com"); } catch (error) { if (error instanceof DomainSdkError) { console.error(error.code, error.retryable, error.retryAfter); } } ``` Errors normalize authentication, permission, conflict, not-found, rate-limit, provider availability, timeout, and cancellation states. Response details are bounded and recursively redact credential-shaped fields. Read next: [`DomainSdkError`](../api-reference/domain-sdk-error.mdx). # Custom domain architecture concepts (/docs/concepts) A safe custom-domain system separates tenant authorization, provider state, DNS routing, hostname ownership, and certificate issuance. These concepts explain the boundaries Domain SDK preserves and the state your application needs to represent. # Managed certificates (/docs/concepts/managed-certificates) `domain.certificate.status` is `pending`, `active`, `expiring`, `failed`, or `unknown`. Domain SDK does not issue certificates itself. Cloudflare and Railway expose explicit certificate state. Netlify exposes the site's certificate and its covered domains, which lets the adapter confirm when an alias is active. bunny.net reports whether a hostname has a certificate and how it was provisioned, so the adapter names the issuer but not its expiry. Vercel and Render confirm usable or verified domain configuration without exposing complete certificate metadata, so those adapters avoid inventing issuer or expiration details. Read next: [Troubleshoot certificate issuance](../guides/troubleshoot-certificates.mdx). # Ownership verification (/docs/concepts/ownership-verification) Ownership records have `purpose: "ownership"`. They are separate from routing and certificate validation even when every instruction happens to use TXT or CNAME records. Vercel exposes project-domain verification challenges. Cloudflare exposes `ownership_verification` and may also offer HTTP validation. Railway requires a TXT verification token in addition to routing records. Render verifies ordinary domains from routing and returns extra ownership and certificate records for wildcards. Netlify does not expose per-alias ownership challenges, so an issued certificate covering the alias is the strongest state available through its site API. bunny.net has no separate ownership challenge either: `verify()` requests a certificate, and issuance only succeeds once the routing record resolves to the pull zone. Treat provider verification as authoritative; DNS resolution alone does not establish tenant ownership. Read next: [Tenant authorization and domain security](../guides/tenant-security.mdx). # Platform providers (/docs/concepts/platform-providers) A provider adapter translates one platform's API into the `DomainProvider` contract. Changing adapters keeps your application lifecycle stable, but it does not erase platform limitations or migrate live traffic. Vercel scopes domains to a project. Cloudflare uses Custom Hostnames inside a SaaS zone. Railway scopes custom domains to a project, environment, and service. Render scopes domains to a web service or static site. Netlify manages additional customer domains as site aliases while preserving the primary domain. bunny.net attaches custom hostnames to one CDN pull zone that fronts an origin you already run. DNS adapters for Porkbun, Namecheap, and Spaceship implement a separate `DnsProvider` contract. `createDnsClient()` uses them to write a hosting provider's required records into a zone you control, and refuses to overwrite conflicting records unless you pass `onConflict: "replace"`. The memory provider is isolated, deterministic test infrastructure. Read next: [Provider overview](../providers/index.mdx). # Provider capabilities (/docs/concepts/provider-capabilities) ```ts title="verify-if-supported.ts" if (domains.capabilities.explicitVerification) { await domains.verify("app.customer.com"); } ``` Basic `add`, `get`, `refresh`, and `remove` do not require capability checks. Calling an unsupported optional operation throws `UNSUPPORTED_OPERATION` with the provider ID. `createSubdomainClient` exposes the same wildcard check as `supportsWildcard`. Its `provisionWildcard` methods fail with `UNSUPPORTED_OPERATION` before calling a provider that cannot attach wildcard hostnames. Read next: [Provider overview](../providers/index.mdx). # Security model (/docs/concepts/security-model) Domain SDK accepts provider credentials only in adapter configuration and sends them only to fixed HTTPS provider API origins. It does not log tokens, disable TLS, follow provider-returned URLs, execute commands, store domains, or emit telemetry. Always authorize hostname operations by tenant, normalize before uniqueness checks, rate-limit mutations and verification, and remove provider configuration when a tenant deletes a domain. Protect abandoned hostnames from reassignment and never trust DNS resolution as proof of ownership. Read next: [Production credential setup](../guides/production-credentials.mdx) and [tenant security](../guides/tenant-security.mdx). # Status model (/docs/concepts/status-model) | Status | Meaning | | ---------------------- | ---------------------------------------------------------- | | `pending` | Work has started but the provider gives no narrower state. | | `pending_dns` | Required routing DNS is incomplete. | | `pending_verification` | Ownership verification is incomplete. | | `pending_certificate` | DNS and ownership are ready but TLS is not. | | `active` | The provider reports the hostname ready. | | `misconfigured` | DNS conflicts with provider requirements. | | `failed` | A permanent provider-side step failed. | | `unknown` | The provider does not expose enough detail. | Do not infer activation from a CNAME lookup. Enable traffic only for `active`. Read next: [`DomainStatus`](../api-reference/domain-status.mdx). # Add and remove customer domains (/docs/guides/add-remove-domains) ```ts title="domain-lifecycle.ts" const domain = await domains.add("app.customer.com"); await domains.remove(domain.hostname); ``` Repeated adds return current state only when the adapter can prove the domain belongs to the configured project, zone, environment, or service. Another owner throws `DOMAIN_CONFLICT`. Repeated removal succeeds when already absent and does not cross provider scope. Read next: [Error handling](../concepts/error-handling.mdx). # Handle apex domains and subdomains (/docs/guides/apex-and-subdomains) Domain SDK validates full hostnames but does not guess registrable apex domains with a two-label rule. Use `domains.capabilities.apexDomains` for provider support. Railway apex instructions use ALIAS/ANAME or CNAME flattening. Vercel supports apex configuration. Cloudflare for SaaS exposes CNAME routing and reports apex support unavailable. Render accepts wildcard domains; other current adapters reject them. For tenant hostnames under a domain you own, use [`createSubdomainClient`](./managed-subdomains.mdx) to constrain direct-child labels and choose wildcard or individual provisioning. Read next: [Provider overview](../providers/index.mdx). # Build a custom-domain settings page (/docs/guides/custom-domain-settings-page) The secure flow is: authorize the tenant, add the normalized hostname on the server, store normalized state, render DNS records, refresh in a background job, and enable traffic only after `active`. ```ts title="app/api/domains/route.ts" import { domains } from "@/server/domains"; import { requireTenant } from "@/server/auth"; import { database } from "@/server/database"; export async function POST(request: Request) { const tenant = await requireTenant(request); const { hostname } = (await request.json()) as { hostname: string }; await tenant.assertCanManageDomains(); const domain = await domains.add(hostname); await database.customDomains.upsert({ tenantId: tenant.id, hostname: domain.hostname, provider: domain.provider, providerDomainId: domain.id, status: domain.status, }); return Response.json(domain); } ``` Suggested framework-neutral storage: ```text title="Database model" CustomDomain - id - tenantId - hostname (unique after normalization) - provider - providerDomainId - status - createdAt - updatedAt ``` Never let a tenant query or remove another tenant's hostname. Rate-limit add and verify endpoints, archive or delete local state after provider removal, and defend abandoned hostnames from takeover. Read next: [Store domain state](./store-domain-state.mdx) and [tenant security](./tenant-security.mdx). # Display DNS records correctly (/docs/guides/display-dns-records) Display type, full name, value, purpose, required status, current status, and description. Preserve multiple verification methods. Offer a copy button per value, but do not convert full names to `@` in stored data. ```ts title="dns-table.ts" const rows = domain.records.map(({ type, name, value, purpose, required, status }) => ({ type, name, value, purpose, required, status, })); ``` Explain that registrar UIs may automatically append the zone and that proxying can interfere with provider checks. Read next: [DNS records](../concepts/dns-records.mdx). # Custom domain implementation guides (/docs/guides) These implementation guides show how to turn Domain SDK's provider-independent API into a complete custom-domain workflow for a multi-tenant SaaS application. Start with the settings-page guide, then follow the focused guide for the part of the lifecycle you are building. ## Build the workflow [#build-the-workflow] ## Handle edge cases safely [#handle-edge-cases-safely] # Give tenants managed subdomains (/docs/guides/managed-subdomains) Use `createSubdomainClient` when tenants choose hostnames such as `acme.mydomain.com` under a parent domain controlled by your application. It accepts direct-child labels only, normalizes them, enforces the parent-domain boundary, and blocks labels you reserve. ```ts title="server/subdomains.ts" import { createDomainClient, createSubdomainClient } from "@opencoredev/domain-sdk"; import { render } from "@opencoredev/domain-sdk/render"; const domains = createDomainClient({ provider: render({ apiKey: process.env.RENDER_API_KEY!, serviceId: process.env.RENDER_SERVICE_ID!, }), }); export const subdomains = createSubdomainClient({ domainClient: domains, baseDomain: "mydomain.com", reservedLabels: ["www", "api", "admin", "mail"], }); ``` ## Provision one wildcard [#provision-one-wildcard] When `subdomains.supportsWildcard` is true, attach the wildcard once and configure every required record it returns. After the wildcard becomes active, creating a tenant does not require another platform-domain or DNS mutation. ```ts title="scripts/provision-tenant-domain.ts" const wildcard = await subdomains.provisionWildcard(); // Show or apply every record where record.required is true. console.log(wildcard.records); await subdomains.waitUntilWildcardActive(); ``` Vercel, Render and bunny.net expose wildcard provisioning through Domain SDK. Provider-specific DNS and certificate requirements still apply. ## Claim a tenant label [#claim-a-tenant-label] Generate the hostname inside the same database transaction that claims the unique label. Domain SDK remains stateless and does not decide which tenant owns it. ```ts title="app/api/tenant-subdomain/route.ts" const label = "acme"; const hostname = subdomains.toHostname(label); // acme.mydomain.com const normalizedLabel = subdomains.fromHostname(hostname); await database.tenants.update({ id: tenant.id, subdomainLabel: normalizedLabel, // unique hostname, }); ``` Authorize the tenant, rate-limit changes, and route requests only after looking up the exact normalized hostname. Do not route an unknown hostname to a default tenant. ## Fall back to individual hostnames [#fall-back-to-individual-hostnames] If wildcard provisioning is unavailable, attach each tenant hostname separately. Configure the returned required DNS records in the parent zone you control, then wait for it to become active. ```ts const domain = await subdomains.add("acme"); // Apply domain.records in your authoritative DNS provider. const active = await subdomains.waitUntilActive("acme"); ``` The SDK does not edit the parent DNS zone automatically. This keeps DNS credentials and destructive record ownership outside the normalized custom-domain lifecycle. Read next: [Tenant authorization and domain security](./tenant-security.mdx). # Poll until a domain is active (/docs/guides/poll-until-active) ```ts title="activate-domain.ts" const controller = new AbortController(); const active = await domains.waitUntilActive("app.customer.com", { timeoutMs: 5 * 60_000, intervalMs: 5_000, signal: controller.signal, onStatus(domain) { console.log(domain.status); }, }); ``` Polling checks immediately, never overlaps checks, defaults to five-second intervals and a five-minute timeout, honors longer `Retry-After` values, and stops on permanent errors. It throws `TIMEOUT` or `ABORTED`; it does not guarantee DNS propagation time. Read next: [`waitUntilActive`](../api-reference/wait-until-active.mdx). # Production credential setup (/docs/guides/production-credentials) * **Vercel:** use a token with access to only the target team/project and set project plus optional team ID. * **Cloudflare:** use a scoped API token with Custom Hostnames/SSL access to only the SaaS zone; configure and validate your fallback origin separately. * **Railway:** use a project/workspace token limited to the target project, environment, and service. Store secrets in your server's encrypted environment/secret manager. Rotate them after suspected exposure. Never return adapter options, request headers, or provider errors directly to a browser. Read next: [Security model](../concepts/security-model.mdx). # Store domain state in your database (/docs/guides/store-domain-state) Store the normalized hostname as a unique value together with tenant ID, provider ID, provider-domain ID, cached status, and timestamps. Optionally cache records for UI display, but replace them on every refresh. Do not use your cache to authorize a provider removal without first authorizing the tenant. Domain SDK itself remains stateless and requires no database. Read next: [Status model](../concepts/status-model.mdx). # Switch providers safely (/docs/guides/switch-providers) Create a separately authorized migration workflow: add the hostname to the destination, complete destination ownership/certificate pre-validation when supported, schedule DNS cutover, monitor destination state, then remove the source only after validation and rollback windows. Domain SDK keeps the application contract stable but does not synchronize two providers, automate customer DNS, or guarantee zero downtime. Read next: [Platform providers](../concepts/platform-providers.mdx). # Tenant authorization and domain security (/docs/guides/tenant-security) Authorize every add, get, verify, refresh, and remove by tenant. Normalize before uniqueness checks. Rate-limit mutations. Remove provider configuration when a tenant disconnects a hostname. Do not reassign an abandoned hostname until provider ownership is safely re-established. Never trust current DNS resolution as ownership proof, collect registrar passwords, or expose provider credentials. Provider verification is authoritative. Read next: [Build a settings page](./custom-domain-settings-page.mdx). # Test without external API calls (/docs/guides/test-without-network) ```ts title="domain-flow.test.ts" const memory = memoryProvider({ latencyMs: 5, initialStatus: "pending_dns" }); const domains = createDomainClient({ provider: memory }); await domains.add("app.customer.com"); memory.transition("app.customer.com", "pending_certificate"); memory.activate("app.customer.com"); expect(memory.calls.map((call) => call.operation)).toContain("add"); ``` Use `createFailingProvider(new DomainSdkError(...))` for application error paths. Normal package tests never require credentials or internet access. Read next: [Testing provider](../providers/testing.mdx). # Troubleshoot certificate issuance (/docs/guides/troubleshoot-certificates) Confirm routing and ownership first, then render records with `purpose: "certificate"`. Check CAA/DNSSEC and proxy configuration when the provider reports validation errors. Cloudflare may return HTTP validation instead of DNS; the SDK preserves that as a message. Use `domain.certificate.status` and issues rather than probing HTTPS yourself as the source of truth. Read next: [Managed certificates](../concepts/managed-certificates.mdx). # Troubleshoot pending DNS (/docs/guides/troubleshoot-dns) 1. Refresh the domain and render the newest required records. 2. Compare full names and values exactly, including TXT prefixes. 3. Check that both routing and ownership records exist; Railway requires both. 4. Disable incompatible CDN proxying where the provider requires direct DNS visibility. 5. Inspect `domain.issues` and provider-specific limitations. Do not delete and recreate repeatedly or promise a propagation deadline. Read next: [Provider pages](../providers/index.mdx). # bunny.net custom domains (/docs/providers/bunny) ## Scope and credentials [#scope-and-credentials] The adapter manages the custom hostnames of one bunny.net CDN pull zone. Set `BUNNY_API_KEY` and `BUNNY_PULL_ZONE_ID`; the pull zone ID is the numeric ID shown in the bunny.net dashboard URL, not the pull zone name. ```ts title="domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { bunny } from "@opencoredev/domain-sdk/bunny"; export const domains = createDomainClient({ provider: bunny({ apiKey: process.env.BUNNY_API_KEY!, pullZoneId: process.env.BUNNY_PULL_ZONE_ID!, }), }); const domain = await domains.add("app.customer.com"); ``` bunny.net account API keys are account-wide, so they can reach every zone on the account. Keep the key in server-only configuration and restrict who can read it. ## DNS and certificates [#dns-and-certificates] Subdomains return a CNAME pointing at the pull zone's system hostname, normally `.b-cdn.net`. The adapter reads that target from the pull zone rather than assuming it, so a renamed zone still produces correct instructions. Apex domains return an ALIAS to the same target. bunny.net publishes no fixed A record for pull zones, so the customer's DNS host must support ALIAS, ANAME, or CNAME flattening — Bunny DNS does, as do Cloudflare and Route 53. Adding a hostname does not issue a certificate. bunny.net validates the hostname while issuing one, so the domain stays `pending_dns` until you call `verify()`: ```ts title="verify.ts" await domains.verify("app.customer.com"); ``` `verify()` requests a free Let's Encrypt certificate over HTTP-01, which only succeeds once the routing record resolves to the pull zone. A failure comes back as a retryable `VERIFICATION_FAILED` error; when the certificate already exists the adapter skips the call so repeated verification cannot trip Let's Encrypt rate limits. ### What `active` proves [#what-active-proves] bunny.net reports whether a hostname has a certificate, not whether its DNS still resolves. Certificate issuance is the strongest evidence the API offers, so the adapter reports `active` once a certificate exists — but that evidence is from issuance time. If a customer later points the record elsewhere, bunny.net keeps reporting the certificate and the domain keeps reading as `active`. Treat `active` as "bunny.net accepted this hostname", not as a live reachability check. `domain.verification.message` states the same caveat. If your product needs to detect a customer breaking their DNS after setup, monitor the hostname itself; no bunny.net API field reports it. ## Validation and SSL options [#validation-and-ssl-options] | Option | Effect | | -------------------- | ------------------------------------------------------------------------------------ | | `allowDnsValidation` | Permits DNS-01 validation when a Bunny DNS zone exists for the hostname | | `forceSsl` | Redirects HTTP to HTTPS for the hostname after `verify()` has issued its certificate | Wildcard hostnames such as `*.customer.com` can only be validated over DNS-01, so the adapter always uses it for them and they require the domain to be hosted on Bunny DNS. `forceSsl` is applied during `verify()` rather than `add()`, because forcing HTTPS before a certificate exists would break the hostname. ## Pull zone limits [#pull-zone-limits] The pull zone's own `.b-cdn.net` system hostname is excluded from `get()`, `list()`, and `remove()`, so customer domains never collide with it. bunny.net updates hostnames one request at a time and the adapter re-reads the pull zone after each mutation, so serialize domain changes for the same pull zone. Official references: [bunny.net API](https://bunny.net/docs/api-reference/core/pull-zone/get-pull-zone), [add hostname](https://bunny.net/docs/reference/pullzonepublic_addhostname), and [SSL for custom domains](https://docs.bunny.net/cdn/ssl-setup). Read next: [Store domain state](../guides/store-domain-state.mdx). # Cloudflare for SaaS custom domains (/docs/providers/cloudflare) ## Scope and credentials [#scope-and-credentials] The adapter creates, retrieves, lists, and deletes Custom Hostnames in one zone. It does not create zone DNS records, configure a fallback origin, or manage customer DNS. Set `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ZONE_ID`, and `CLOUDFLARE_CNAME_TARGET`. Use a scoped API token with Custom Hostnames/SSL permissions for only the SaaS zone. ```ts title="domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { cloudflareSaaS } from "@opencoredev/domain-sdk/cloudflare"; export const domains = createDomainClient({ provider: cloudflareSaaS({ apiToken: process.env.CLOUDFLARE_API_TOKEN!, zoneId: process.env.CLOUDFLARE_ZONE_ID!, cnameTarget: process.env.CLOUDFLARE_CNAME_TARGET!, }), }); const domain = await domains.add("app.customer.com"); ``` ## DNS, verification, and certificates [#dns-verification-and-certificates] The configured CNAME target becomes the routing record. `ownership_verification` becomes ownership DNS; `ssl.validation_records` become certificate DNS. HTTP validation is preserved as a safe message because it is not a DNS record. A hostname becomes active only when both custom-hostname and SSL states are active. ## Errors and limitations [#errors-and-limitations] Structured Cloudflare error arrays are sanitized and normalized. Configure a valid fallback/default origin in Cloudflare before adding hostnames; an invalid origin is platform configuration, not customer DNS. v0.1 does not expose wildcards, generic DNS mutation, or apex routing. Official references: [Custom Hostnames](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/), [create hostnames](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/create-custom-hostnames/), and [hostname validation](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/hostname-validation/). Read next: [Ownership verification](../concepts/ownership-verification.mdx). # Custom domain provider comparison (/docs/providers) Choose the provider where your application already serves traffic. Domain SDK normalizes the lifecycle, but credentials, resource scope, DNS requirements, and platform limits remain provider-specific. ## Compatibility [#compatibility] | Capability | Vercel | Cloudflare SaaS | Railway | Render | Netlify | bunny.net | | ---------------------- | :----: | :-------------: | :-----: | :--------------: | :-----: | :-------: | | Add domain | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Get status | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | List domains | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Explicit verification | ✓ | — | — | ✓ | — | ✓ | | Remove domain | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Managed certificates | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Apex domains | ✓ | — | ✓ | ✓ | ✓ | ✓ | | Wildcard domains | ✓ | — | — | ✓ | — | ✓ | | Ownership records | ✓ | ✓ | ✓ | wildcard domains | — | — | | Routing records | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Automatic customer DNS | — | — | — | — | — | — | Cloudflare for SaaS requires a CNAME target, so the current adapter does not claim apex support. Vercel, Render and bunny.net are the adapters that accept wildcard hostnames. Vercel wildcard TLS requires DNS-01 validation, normally through Vercel nameservers; A/CNAME routing alone is insufficient. Eligible delegated subdomains can retain third-party DNS by following Vercel's [qualified `_acme-challenge` NS delegation guide](https://vercel.com/kb/guide/wildcard-domain-without-vercel-nameservers); the adapter does not configure that delegation. bunny.net wildcards require the domain to be hosted on Bunny DNS. Netlify manages aliases without replacing the site's primary domain. bunny.net is the CDN option rather than an application host: it fronts an origin you already run. No hosting adapter edits DNS. When you control the zone, a [DNS adapter](#dns-adapters) can write the records for you. ## DNS adapters [#dns-adapters] DNS adapters write the records a hosting provider asks for into a zone at your registrar. Pass them to `createDnsClient()`, then call `applyDomainRecords()` with the `Domain` a hosting adapter returned. All three write A, AAAA, CNAME, ALIAS, TXT, and CAA records. They do not register domains, change nameservers, or issue certificates. | Capability | Porkbun | Namecheap | Spaceship | | ------------------- | :-------: | :------------: | :-------: | | TTL range (seconds) | 600–86400 | 60–60000 | 60–3600 | | Per-record writes | ✓ | — (whole zone) | ✓ | | Delegation check | ✓ | ✓ | ✓ | Namecheap's API replaces every host in a zone on each write. The adapter reads the zone, changes only the requested records, and writes the result back, so use one writer per zone. See the [Namecheap page](./namecheap.mdx) for details. Provider credentials belong to your SaaS account, never to the customer. The memory adapter is available separately for [testing](./testing.mdx), not as a production provider. # Namecheap DNS (/docs/providers/namecheap) ## Credentials and DNS setup [#credentials-and-dns-setup] The `namecheap` adapter manages DNS records for domains on Namecheap BasicDNS. It does not register domains, switch nameservers, or issue certificates. PremiumDNS and FreeDNS zones cannot be managed through this API. Enable API access in your Namecheap account, generate an API key, and whitelist the IPv4 address your server uses for outbound requests. API access requires an eligible account. Store the API user, API key, and whitelisted IP in server-only configuration. `userName` defaults to `apiUser`; set it explicitly when the account making the request differs from the account owning the domain. ```ts title="dns.ts" import { createDnsClient } from "@opencoredev/domain-sdk"; import { namecheap } from "@opencoredev/domain-sdk/namecheap"; export const dns = createDnsClient({ provider: namecheap({ apiUser: process.env.NAMECHEAP_API_USER!, apiKey: process.env.NAMECHEAP_API_KEY!, clientIp: process.env.NAMECHEAP_CLIENT_IP!, }), }); const zone = await dns.getZone("customer.co.uk"); if (!zone.authoritative) { throw new Error("Configure Namecheap BasicDNS before writing records."); } ``` Use `sandbox: true` with a Namecheap sandbox account to target the sandbox API. Credentials and request data are sent in form-encoded POST bodies, not URL query strings. ## Apply a hosting provider's records [#apply-a-hosting-providers-records] Pass the `Domain` returned by your hosting provider to `applyDomainRecords`. The DNS client writes missing required routing and verification records and leaves matching records alone. ```ts title="connect-domain.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { vercel } from "@opencoredev/domain-sdk/vercel"; import { dns } from "./dns"; const domains = createDomainClient({ provider: vercel({ token: process.env.VERCEL_TOKEN!, projectId: process.env.VERCEL_PROJECT_ID!, }), }); const domain = await domains.add("app.customer.co.uk"); await dns.applyDomainRecords(domain, { zone: "customer.co.uk" }); const verified = await domains.verify(domain.hostname); ``` Conflicting routing records produce `DOMAIN_CONFLICT` by default. Use `onConflict: "replace"` only when your application has permission to remove those records. Set `includeOptional: true` to include optional records returned by the hosting provider. DNS propagation can delay verification after a successful write. ## Supported records and limits [#supported-records-and-limits] The adapter writes A, AAAA, ALIAS, CAA, CNAME, and TXT records. ANAME is not supported. TTLs range from 60 to 60000 seconds; the default is 1800 seconds and can be changed with the adapter's `ttl` option. CAA values use presentation format, such as `0 issue "letsencrypt.org"`. `listRecords()` returns every host record, including MX, URL, FRAME, and NS records that the SDK does not create. All returned records are editable. `getZone()` reports the nameservers and whether Namecheap reports the zone as using its DNS. A zone that is not using BasicDNS cannot be changed by this adapter; it returns `INVALID_CONFIGURATION` rather than changing nameservers. Namecheap limits API usage to 50 requests per minute, 700 per hour, and 8000 per day. A mutation uses at least three requests: read, replace, and verification read. The DNS client may make additional reads for conflict detection and returning results. Rate-limit errors are retryable; when Namecheap supplies a `Retry-After` header, the error exposes `retryAfter` in milliseconds. ## Whole-zone writes [#whole-zone-writes] Namecheap's `setHosts` replaces the entire host list. The adapter reads the current list, applies the requested change, preserves unrelated records and the returned email mode, then reads again to verify the result. MX records and their priorities are retained even when the email mode is MX or MXE. Writes to one zone are serialized within an adapter instance. Separate instances, other processes, and concurrent dashboard edits are not protected by that queue. A change made after the initial read can be overwritten by `setHosts`. Use one DNS writer per zone and avoid dashboard changes during SDK writes. Post-write verification detects a mismatched result but cannot recover a dashboard edit that was already overwritten. An IP whitelist error returns `PERMISSION_DENIED` with guidance to whitelist the server's IPv4 address. Rejected API credentials return `AUTHENTICATION_FAILED`. If verification of a write fails, re-read the zone before deciding whether to retry. Official references: [API introduction](https://www.namecheap.com/support/api/intro/), [getHosts](https://www.namecheap.com/support/api/methods/domains-dns/get-hosts/), [setHosts](https://www.namecheap.com/support/api/methods/domains-dns/set-hosts/), and [getList](https://www.namecheap.com/support/api/methods/domains-dns/get-list/). # Netlify custom domains (/docs/providers/netlify) ## Scope and credentials [#scope-and-credentials] The adapter manages `domain_aliases` on one Netlify site. It never replaces the site's primary `custom_domain`. Set `NETLIFY_ACCESS_TOKEN` and `NETLIFY_SITE_ID`; use a token that can read and update only the intended site where possible. ```ts title="domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { netlify } from "@opencoredev/domain-sdk/netlify"; export const domains = createDomainClient({ provider: netlify({ accessToken: process.env.NETLIFY_ACCESS_TOKEN!, siteId: process.env.NETLIFY_SITE_ID!, }), }); const domain = await domains.add("app.customer.com"); ``` ## DNS and certificates [#dns-and-certificates] Subdomains return a CNAME pointing to the site's `.netlify.app` hostname. Apex domains return an ALIAS to `apex-loadbalancer.netlify.com`; when the customer's DNS provider cannot create ALIAS, ANAME, or flattened-CNAME records, use an A record to `75.2.60.5` instead. Netlify does not expose independent DNS-verification state for each alias. The adapter treats an alias covered by the site's issued TLS certificate as verified and active. Until then, it remains `pending_dns`. Netlify manages certificate issuance, so the adapter does not call the certificate provisioning endpoint. ## Mutation and alias limits [#mutation-and-alias-limits] Netlify updates aliases as one array. Each add or remove reads the current site, preserves unrelated aliases and the primary domain, then writes the updated alias list. Serialize domain mutations for the same site to avoid lost updates from concurrent read-modify-write requests. Netlify recommends no more than 50 aliases per site. The adapter does not impose a lower limit than the API, but you should use multiple sites if your product regularly exceeds that recommendation. Official references: [Netlify API](https://open-api.netlify.com/), [domain aliases](https://docs.netlify.com/manage/domains/configure-domains/add-a-domain-alias/), and [external DNS](https://docs.netlify.com/manage/domains/configure-domains/configure-external-dns/). Read next: [Store domain state](../guides/store-domain-state.mdx). # Porkbun DNS (/docs/providers/porkbun) ## Credentials and scope [#credentials-and-scope] Create an API key and secret API key in Porkbun's account API settings. Enable **API Access** for each domain you want to manage. Store both keys in server-only configuration as `PORKBUN_API_KEY` and `PORKBUN_SECRET_API_KEY`. The adapter writes DNS records, not hosting-provider domains or certificates. A Porkbun DNS zone must already exist for the domain. Writes to a zone delegated to another DNS host do not change public DNS. ## Apply a hosting provider's records [#apply-a-hosting-providers-records] Use a hosting provider to add the domain, then pass its result to `applyDomainRecords()`. This example uses Vercel: ```ts title="domains.ts" import { createDnsClient, createDomainClient } from "@opencoredev/domain-sdk"; import { porkbun } from "@opencoredev/domain-sdk/porkbun"; import { vercel } from "@opencoredev/domain-sdk/vercel"; const domains = createDomainClient({ provider: vercel({ token: process.env.VERCEL_TOKEN!, projectId: process.env.VERCEL_PROJECT_ID!, }), }); const dns = createDnsClient({ provider: porkbun({ apiKey: process.env.PORKBUN_API_KEY!, secretApiKey: process.env.PORKBUN_SECRET_API_KEY!, }), }); const zone = await dns.getZone("customer.com"); if (!zone.authoritative) { throw new Error("Delegate customer.com to Porkbun before applying records."); } const domain = await domains.add("app.customer.com"); await dns.applyDomainRecords(domain, { zone: "customer.com" }); ``` `applyDomainRecords()` writes required records by default and leaves unrelated records alone. Repeating the call does not create records already present. Conflicting routing records produce `DOMAIN_CONFLICT`; pass `onConflict: "replace"` only when you intend to delete those conflicting records. Use `includeOptional: true` to include optional records returned by the hosting provider. Inspect `await dns.listRecords("customer.com")` to check the saved records. DNS propagation takes time; use the hosting provider's verification flow afterward to check routing and certificate readiness. ## Supported records and limits [#supported-records-and-limits] The adapter creates A, AAAA, CNAME, ALIAS, TXT, and CAA records. It does not create ANAME records. Apex names and wildcards are supported. CAA values use presentation format, such as `0 issue "letsencrypt.org"`. The default TTL is 600 seconds. Set `ttl` on the adapter to change the default, or set it on an individual input record. The adapter accepts TTLs from 600 to 86400 seconds. Porkbun's usual minimum is 600 seconds, but account settings can impose a different minimum. Record retrieval uses one request, not pagination. The adapter returns every record the API includes, even types it does not create, and deletes only selected record IDs. Porkbun's retrieve endpoint excludes SOA and default Porkbun NS records. If an apex NS record pointing at a Porkbun nameserver is returned, the adapter marks it non-editable. The nameserver check uses `/domain/getNs/{domain}` and considers the zone authoritative only when every returned nameserver ends in `.porkbun.com`. It reports delegation, not an independent public DNS lookup. ## Errors and testing [#errors-and-testing] Invalid keys produce `AUTHENTICATION_FAILED`. Missing domain API access or insufficient permissions produce `PERMISSION_DENIED` with guidance to enable API Access. A missing domain produces `DOMAIN_NOT_FOUND`. Rate limits and service outages are retryable; `retryAfter` is exposed when Porkbun supplies a `Retry-After` header. Each create request carries a fresh `Idempotency-Key`. A `DUPLICATE_RECORD` response with HTTP 400 is accepted as a successful create. The adapter does not automatically retry requests. Sandbox API keys beginning with `pk1_sb_` use the same endpoint. For tests, pass a custom `fetch` implementation or override `baseUrl`. Do not send production credentials to an untrusted endpoint. # Railway custom domains (/docs/providers/railway) ## Scope and credentials [#scope-and-credentials] The adapter checks availability, creates, retrieves, lists, and deletes Railway custom domains. It does not deploy services or edit customer DNS. Set `RAILWAY_TOKEN`, `RAILWAY_PROJECT_ID`, `RAILWAY_ENVIRONMENT_ID`, and `RAILWAY_SERVICE_ID`. Use a project/workspace token with access only to the target resources. ```ts title="domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { railway } from "@opencoredev/domain-sdk/railway"; export const domains = createDomainClient({ provider: railway({ token: process.env.RAILWAY_TOKEN!, projectId: process.env.RAILWAY_PROJECT_ID!, environmentId: process.env.RAILWAY_ENVIRONMENT_ID!, serviceId: process.env.RAILWAY_SERVICE_ID!, }), }); const domain = await domains.add("app.customer.com"); ``` ## DNS, verification, and certificates [#dns-verification-and-certificates] `status.dnsRecords` becomes routing DNS. `status.verificationToken` becomes the required `_railway-verify` TXT record. Both are required; valid routing alone never becomes active. Certificate `ISSUED` confirms readiness. Root domains use an ALIAS/ANAME or CNAME-flattening instruction because Railway does not publish a static A record. ## Errors and limitations [#errors-and-limitations] GraphQL errors are detected even with HTTP 200. Unavailable domains map to `DOMAIN_CONFLICT`. The current public API has no explicit verification mutation, so refresh state with `refresh()`. Wildcards remain disabled in v0.1. Official references: [Public API](https://docs.railway.com/integrations/api), [manage domains](https://docs.railway.com/integrations/api/manage-domains), and [working with domains](https://docs.railway.com/networking/domains/working-with-domains). Read next: [Poll until active](../guides/poll-until-active.mdx). # Render custom domains (/docs/providers/render) ## Scope and credentials [#scope-and-credentials] The adapter creates, retrieves, lists, verifies, and removes custom domains for one Render service. Set `RENDER_API_KEY` and `RENDER_SERVICE_ID`. The service must be a web service or static site, and the API key must have access to it. ```ts title="domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { render } from "@opencoredev/domain-sdk/render"; export const domains = createDomainClient({ provider: render({ apiKey: process.env.RENDER_API_KEY!, serviceId: process.env.RENDER_SERVICE_ID!, }), }); const domain = await domains.add("app.customer.com"); ``` ## DNS, verification, and certificates [#dns-verification-and-certificates] Subdomains return a CNAME pointing to the service's `onrender.com` hostname. Apex domains return Render's `216.24.57.1` load-balancer address and explain the preferred ALIAS, ANAME, or flattened-CNAME alternative. Wildcards also return Render's certificate and hostname-validation CNAME records. Call `verify()` after the customer configures DNS. Render also verifies new domains automatically, so polling with `waitUntilActive()` remains useful. A verified domain becomes active; Render manages its TLS certificate but does not expose certificate metadata through the custom-domain response. ## Errors and limitations [#errors-and-limitations] Adding a root or `www` hostname can cause Render to create its companion hostname automatically. The adapter returns the hostname you requested, while `list()` includes every domain Render created. Domain limits and charges depend on the Render workspace plan. Official references: [Custom Domain API](https://api-docs.render.com/reference/create-custom-domain), [custom domains](https://render.com/docs/custom-domains), and [external DNS](https://render.com/docs/configure-other-dns). Read next: [Poll until active](../guides/poll-until-active.mdx). # Spaceship DNS (/docs/providers/spaceship) ## Credentials and scope [#credentials-and-scope] Create an API key and secret in your Spaceship account's API Manager. Enable the **DNS Records** permission to list, save, and delete records. Enable **Domain Management** if you use `getZone()` to inspect nameserver delegation. Keep both credentials in server-only configuration. The adapter manages DNS records, not hosting domains or certificates. The domain must already exist in your Spaceship account. Records saved to Spaceship only resolve publicly when the domain uses Spaceship's basic nameservers. ## Apply a hosting domain's records [#apply-a-hosting-domains-records] Use your hosting adapter to add a domain, then pass the returned `Domain` to `applyDomainRecords()`. This example connects Vercel routing and verification records to a Spaceship zone: ```ts title="domains.ts" import { createDnsClient, createDomainClient } from "@opencoredev/domain-sdk"; import { spaceship } from "@opencoredev/domain-sdk/spaceship"; import { vercel } from "@opencoredev/domain-sdk/vercel"; const domains = createDomainClient({ provider: vercel({ token: process.env.VERCEL_TOKEN!, projectId: process.env.VERCEL_PROJECT_ID!, }), }); const dns = createDnsClient({ provider: spaceship({ apiKey: process.env.SPACESHIP_API_KEY!, apiSecret: process.env.SPACESHIP_API_SECRET!, }), }); const zone = await dns.getZone("example.com"); if (!zone.authoritative) { throw new Error("Delegate example.com to Spaceship before applying records."); } const domain = await domains.add("app.example.com"); await dns.applyDomainRecords(domain, { zone: "example.com" }); await domains.verify(domain.hostname); ``` `applyDomainRecords()` writes required records by default and leaves unrelated records alone. Repeating it does not duplicate matching records. DNS propagation can take time, so retry hosting verification after the records become visible if the first attempt does not succeed. Conflicting records raise `DOMAIN_CONFLICT`. Pass `onConflict: "replace"` only when your application intends to remove conflicting custom records. Records managed by Spaceship products or personal nameservers are never replaced or removed by the DNS client. ## Options and limits [#options-and-limits] The adapter writes A, AAAA, CNAME, ALIAS, TXT, and CAA records. It lists every record type, including MX and NS, and reports their editability. CAA values use presentation format such as `0 issue "letsencrypt.org"`. Records have no API IDs. TTL values must be integers between 60 and 3600 seconds. The default is 3600; set the adapter's `ttl` option to change that default, or supply a TTL on an individual record. The `fetch` and `baseUrl` options allow a custom transport or API endpoint. Lists are fetched in pages of 100 records. Saves merge only the supplied records with `force: false`, and deletes send only the selected record identities. Spaceship accepts at most 500 records in a save or delete request; keep individual mutations within that limit. These are not whole-zone replacements, but concurrent edits can still conflict. `getZone()` uses the domain-details endpoint, which allows only five calls per domain per five minutes. Avoid calling it on every record operation. It reports `authoritative: true` when Spaceship identifies the nameserver provider as `basic`, rather than performing a live DNS lookup. HTTP 429 errors report `RATE_LIMITED`, are retryable, and include `retryAfter` when Spaceship sends a `Retry-After` header. Network and server failures are retryable `PROVIDER_UNAVAILABLE` errors. A save rejected with HTTP 422 maps to `DOMAIN_CONFLICT` when Spaceship's message reports a conflict. Other HTTP 422 responses, such as an invalid address, map to `INVALID_CONFIGURATION`. Official reference: [Spaceship API documentation](https://docs.spaceship.dev/). # Testing provider (/docs/providers/testing) The memory provider supports add, get, list, verify, remove, manual transitions, latency, failures, configurable records/certificates, abort signals, and call inspection. Each instance is isolated. ```ts title="domains.test.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { memoryProvider } from "@opencoredev/domain-sdk/testing"; const memory = memoryProvider(); const domains = createDomainClient({ provider: memory }); await domains.add("app.customer.com"); memory.activate("app.customer.com"); expect((await domains.refresh("app.customer.com")).status).toBe("active"); ``` `createMockDomain`, `createMockDnsRecord`, and `createFailingProvider` are also available only from the testing subpath. Read next: [Test without external API calls](../guides/test-without-network.mdx). # Vercel custom domains (/docs/providers/vercel) ## Scope and credentials [#scope-and-credentials] The adapter adds, retrieves, lists, verifies, and removes domains for one Vercel project. It does not deploy applications, edit customer DNS, register domains, or move a domain from another project. Set `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, and optional `VERCEL_TEAM_ID`. Give the token only the project/team access required for domain management. ```ts title="domains.ts" import { createDomainClient } from "@opencoredev/domain-sdk"; import { vercel } from "@opencoredev/domain-sdk/vercel"; export const domains = createDomainClient({ provider: vercel({ token: process.env.VERCEL_TOKEN!, projectId: process.env.VERCEL_PROJECT_ID!, teamId: process.env.VERCEL_TEAM_ID, }), }); const domain = await domains.add("app.customer.com"); ``` ## DNS, verification, and certificates [#dns-verification-and-certificates] Routing records come from Vercel's domain-configuration recommendations; ownership challenges become `ownership` records. `verify()` calls the project-domain verification endpoint. The adapter reports active configuration without inventing certificate issuer or expiration metadata unavailable from the project-domain API. ## Errors and limitations [#errors-and-limitations] Expired tokens map to `AUTHENTICATION_FAILED`; inaccessible resources map to `PERMISSION_DENIED`; another project/account maps to `DOMAIN_CONFLICT`; rate limits preserve `retryAfter`. Duplicate adds return current state only when `projectId` matches. Wildcard hostnames such as `*.customer.com` are attached like any other domain, but A/CNAME routing alone is insufficient for wildcard TLS. Vercel's [SSL documentation](https://vercel.com/docs/domains/working-with-ssl) requires DNS-01 validation for wildcard certificates, normally using Vercel nameservers. For eligible delegated subdomains, the [third-party DNS guide](https://vercel.com/kb/guide/wildcard-domain-without-vercel-nameservers) describes enabling Vercel DNS, delegating `_acme-challenge` with NS records, and configuring wildcard routing. Follow its applicability limits: it warns against this method for apex-level wildcards such as `*.example.com` and notes potential interference with other providers' certificate issuance. The adapter does not configure this delegation; certificate validation is separate from project ownership verification. `configuredBy: A/CNAME` indicates routing, not successful certificate validation. `acceptedChallenges` lists available certificate challenge types (`dns-01` / `http-01`), not DNS records or tokens. `misconfigured` covers both domain configuration and automatic TLS certificate generation; an empty challenge list is not solely a different-project signal. Official references: [project domains](https://vercel.com/docs/rest-api/projects/add-a-domain-to-a-project), [verification](https://vercel.com/docs/rest-api/projects/verify-project-domain), and [domain configuration](https://vercel.com/docs/rest-api/domains/get-a-domain-s-configuration). Read next: [Troubleshoot pending DNS](../guides/troubleshoot-dns.mdx). # Changelog (/docs/project/changelog) ## 0.1.0 [#010] * Added the core client, normalized model, hostname validation, errors, polling, and logging contract. * Added Vercel, Cloudflare for SaaS, Railway, Render, Netlify, and isolated memory providers. * Added fixture-backed adapter tests and complete documentation. * Render supports wildcard domains. Automatic customer DNS configuration remains unsupported. Read next: [Introduction](../index.mdx). # Contributing (/docs/project/contributing) ## Set up the workspace [#set-up-the-workspace] ```bash title="Terminal" bun install bun run dev ``` The repository uses Bun 1.3.14 and Turborepo. The docs run at `http://domain-sdk.localhost:1355` through the sudo-free Portless proxy. ## Work in the right package [#work-in-the-right-package] * `packages/sdk` contains the public client, normalized types, adapters, and tests. * `apps/fumadocs` contains the docs and landing page. * `packages/config` contains shared TypeScript and lint configuration. Keep changes focused. Preserve existing provider boundaries and do not add placeholder adapters, speculative options, or network-dependent tests. ## Test provider changes [#test-provider-changes] Provider tests belong in `packages/sdk/test/providers`. Use an injected `fetch` fixture for every provider request; tests must not require credentials or internet access. Cover request paths, response normalization, idempotent add/remove behavior, malformed responses, and normalized errors. ```bash title="Terminal" cd packages/sdk bun test bun run check-types bun run lint bun run build ``` ## Test documentation changes [#test-documentation-changes] Check generated MDX types and the production build before opening a pull request. ```bash title="Terminal" cd apps/fumadocs bun run check-types bun run lint bun run build ``` ## Run the full repository checks [#run-the-full-repository-checks] ```bash title="Terminal" bun run test bun run typecheck bun run lint bun run build ``` Open a pull request that explains the behavior change, provider API assumptions, and validation you ran. Include screenshots for visible docs or landing-page changes. # Domain SDK project (/docs/project) Domain SDK is an MIT-licensed open-source TypeScript project. Use the changelog to review user-visible releases, or follow the contribution guide to run the workspace and propose a focused change.