CustomDomain Docs

Reference

Reference for the eight window.customdomain methods, window events and callbacks, the open() config, widget statuses, white-label options and theming tokens.

Methods

window.customdomain exposes eight methods. The hosted script (https://app.customdomain.ai/widget-assets/customdomain-sdk.js) and the npm packages (customdomain-js and @customdomain/react, both 0.5.5) have all eight; removeDomain() reached npm in 0.5.2.

MethodPurpose
open(config)Open the connect modal (or embedded panel). Returns { close }.
purchaseDomain(config)Open the in-widget domain purchase flow (Sell). Returns { close }.
removeDomain(config)Open the removal flow for a connected domain: the DNS records that were added, the choice to remove or keep them, one more sign-in when needed, and a summary. Pass connectionId, or the domain as prefilledDomain. Returns { close }. See Removing a domain.
loadSharedFlow(url, config?)Open a shared/delegated setup link in the modal. Returns { close }.
checkDomain(domain, { token, apiBase?, dnsRecords? })Pre-flight feature detection. Resolves a CheckDomainResult. Pass dnsRecords (the set you intend to write) to have conflicts and integrationWarnings computed for those records.
checkRecords(domain, { token, dnsRecords, apiBase? })Verify a desired record set against live public DNS. Resolves a CheckRecordsResult.
load()Resolve when the SDK is ready (CDN/npm loader parity).
close()Close and tear down the current modal.

checkDomain calls POST /v1/domains:check and returns camelCase fields: domain, provider, setupType, supportsAutomatic, oauthAvailable, domainConnect, registered, NSSupport, wildcardSupport, cnameFlattening, spfOverrideSupport, caaSupport, supportsSocialLogin, recordConflicts (a caa-blocks-letsencrypt entry is a certificate advisory, not a record to replace; the REST answer also lists it in advisories, which the packages map since 0.5.5; advisories never count toward willFallbackToManual), conflictTolerance, willFallbackToManual, apexSupported, apexMessage, the Public Suffix List split of the domain (subdomain, registrableDomain, publicSuffix), authoritativeDnsProvider, plus the rail truth: rails ({ oauth, domainConnect, apiKey, manual, cloudBrowser? }, each { available, reason?, caveat? }; cloudBrowser also carries key when the secure sign-in makes an API key), recommendedRail, endUserAutomatic, blockedReason, dashboardUrl, nameservers. Branch on rails and recommendedRail, not on supportsAutomatic, which only means an adapter exists. See Rail truth. Since 0.5.4 the packages also map detectionError (nxdomain or resolver; resolver is transient, so check again), targetMode (edge or custom), integrationWarnings (only when you passed dnsRecords: records the provider cannot write as given, each { code, message }), managedDns (whether the deployment's managed DNS can take the domain, with a reason when it cannot; whether your users are offered it is your application's Domain maintenance setting) and sandbox (the token belongs to a test mode application). For a root domain, apexStrategy (alias, a_records or unsupported), apexRecordType and apexTargets say how the root would be pointed; see Root domains. checkRecords rides POST /v1/monitor:check and returns { domain, inSync, records[] }, where each record is { host, type, ok, observed[] }.

Events & callbacks

Every meaningful transition fires both a window CustomEvent (read event.detail) and, where you provided one, the matching open() callback.

Window eventCallbackFiresdetail / arg
customdomain:steponStepChange(detail)on every screen change{ step, domain?, provider?, user?, error? }. user is your userId. error is the last error shown ({ code, title, details }), present only after an error screen. With several domains queued it also carries pendingDomains and processedDomains
customdomain:successonSuccess(result)when the domain reaches live, or a reconnect renewed its key{ jobId, domain, setupType, provider, success: true }. setupType is automatic or manual, or reconnect with reconnected: true after a reconnect. alreadyConnected: true is added when the domain was already live and this was a resume. With several domains queued it also carries processedDomains and pendingDomains
customdomain:closeonClose(detail)when the modal closes{ lastStatus, error?, shared?, manualScreenDisabled? }. shared is set when the user forwarded the setup to a colleague before closing
customdomain:erroronError(err)when the widget shows an error{ code, message, title?, details? }
customdomain:fallbackonFallback(detail)once per domain, when the user goes to the records: routed there because no way connects automatically, or by choosing Add the records over an open card. A sign-in the user cancels, or that fails, keeps the card and does not fire it{ reason, provider?, screen, message? }. reason is one of no_end_user_rail (no sign-in or one-click at this provider), conflicts_exceed_tolerance, provider_unsupported, unknown_provider, or chose_records (the user picked Add the records from the card); message is the human explanation shown, when there is one
customdomain:purchasenonethe buy flow created a checkout session{ domain, sessionId?, clientSecret?, url? }: mount Stripe Embedded Checkout with clientSecret
customdomain:removedonRemoved(result)a removal finished (npm packages 0.5.2 or newer){ domain, connectionId, records, removed, restored, leftInPlace, user? }. records is removed, partly_removed, kept, manual (the user deletes them; the widget listed how), in_use or not_needed
customdomain:request-closenonehost-delegated dismissal (whiteLabel.delegateClose)provider payload; you call close()
customdomain:doc-clicknoneuser clicked a documentation linklink payload
customdomain:sharednonea setup link was handed over: the instructions were emailed, the user chose Copy link instead, or your forwardLink page took it{ domain, link?, jobId? }

onError(err) receives a CustomDomainError ({ code, message, details?, status? }). status (0.5.4 or newer) is the HTTP status of the control plane response the error came from; it is unset when the widget raised the error itself. A status of 429 or any 5xx is safe to retry after a moment; any other 4xx gives the same answer until the token, domain or records change. checkDomain and checkRecords throw the same class. The hosted script also fires a customdomain:ready window event once window.customdomain is attached. Error codes are an open union, so branch on the ones you handle and treat the rest as a generic failure. The ones the widget raises include SessionError (the token expired or was refused, including a domain-bound token used for another host), InvalidDomainError, DomainAlreadyConnectedError, UnregisteredDomainError, PlatformSubdomainError, InvalidNameservers, ProviderAuthenticationError, AccessDeniedError, PopupBlockedError, DomainConnectError, ProviderError, TimeoutError, RateLimitError, NetworkError, PlanLimitError (the account's plan or allowance does not allow another connection; the end user only sees a neutral message) and GenericError.

A failed connection is not a dead end in the widget: it shows the manual screen with the records and the control plane's reason, and Check now calls POST /v1/connections/{id}:recheck (which revives the connection) before polling again.

customdomain:success is an event, not a dismissal. The widget stays on the success screen and closes itself when the user is done (or when you call close()).

open() / OpenConfig

FieldTypeNotes
applicationId (required)stringPublic application id.
token (required)stringWidget JWT (scope connect), minted on your server: POST /api/widget-token with an API key for applications created in the console, or POST /v1/tokens with the client secret for applications created over the API. See Installation & embed.
getToken() => string | Promise<string>Returns a fresh widget token; have it call your own server route, never the mint endpoint directly. Called when the current one expires mid-setup, so long manual setups never stop. See Keeping a session alive.
domainstringPre-filled domain.
prefilledDomainstring | string[]Alias accepted for the pre-fill.
wwwRedirectbooleanRoot domains only. true connects the root and www and redirects the root to www; false connects the root alone; unset lets the user choose (root and www is the recommended default).
dnsRecordsDNSRecord[] | Record<string, DNSRecord[]>DKIM records for the widget's DKIM screen (a purpose: "dkim" record, or a host under _domainkey). Array, or per-domain object mode { "acme.com": [...] }. Every other record is ignored, and the SDK says so once in the console: customers are shown the records of your application's routing, or the set your backend supplies with PUT /v1/connections/{id}/records.
applicationName / applicationUrlstringBranding shown in the flow.
localestringUI locale.
whiteLabelWhiteLabelTheming + behavior (below).
forceManualSetupbooleanSkip automatic rails; always show records.
forceSubdomainbooleanOnly accept a subdomain; the root option is hidden.
supportForSubdomains / defaultSubdomain / hostRequiredSubdomain-entry behavior.
manualSetupDocumentationstringDeep-link to your own manual-setup guide.
enableDkimbooleanOffer the DKIM screen.
enableEmailbooleanShow the email setup screen (the email record template) before the domain records.
managedbooleanOpt the one-click (Domain Connect) flow into the durable managed rail. The control plane falls back to the standard redirect when the provider or deployment cannot do it. Default false. See Managed connections.
endUserRefstringYour id for this customer. Stored on the connection as end_user_ref, shown in the console, and sent with every webhook as user_id.
userIdstringAn id echoed back on the widget's step events.
containerstringCSS selector for embedded mode (implies whiteLabel.embedded).
apiBase / widgetBasestringOverride the default control-plane (https://api.customdomain.ai) / widget (https://app.customdomain.ai) hosts (for example a staging environment).
iframeSandboxstringAdvanced. Replaces the iframe's sandbox attribute. The default allows scripts, forms and popups that escape the sandbox so provider sign-in windows can open.
searchTypestringSearch mode for the buy-a-domain screen, such as standard or ai.
mode"connect" | "remove" | "reconnect""remove" opens the removal flow (what removeDomain sets; npm packages 0.5.2 or newer). "reconnect" opens straight on the sign-in that renews the domain's lasting API key: one sign-in, a new key, no DNS change. A domain with no such key, or a provider whose key is turned off, gets a short notice instead. The hosted script and the npm packages pass mode through; the packages' types name "reconnect" since 0.5.5 (before, a TypeScript caller cast it).
connectionIdstringThe connection to remove or reconnect, when you kept its id. Otherwise the prefilled domain is looked up. Either way, mint the token for that domain and end user (POST /v1/tokens with domain and end_user_ref).
onSuccess / onClose / onStepChange / onError / onFallback / onRemovedfnCallbacks (mirror the events above).

A DNSRecord is { type, host, value, ttl?, priority?, purpose? }. Supported types include A, AAAA, CNAME, CAA, MX, NS, TXT, plus the meta-types SPFM (SPF-merge) and REDIR301.

What the end user can type

The domain field accepts what people actually paste: a full URL (https://www.acme.com/pricing), an email address ([email protected]), a host with a port, uppercase, internationalized names and full-width dots. The widget reduces each to the hostname, offers a correction for a mistyped ending ("Did you mean acme.com?"), and otherwise says exactly what is wrong.

When the result is a root domain (acme.com), the widget asks before creating anything: root and www (recommended), the root alone, or a subdomain instead. Where the user's DNS provider cannot point a root at a hostname, the widget uses the provider's IP address records if your deployment publishes them, and otherwise steers the user to www or a subdomain. See Root domains.

Manual setup and live DNS checks

On the manual screen the widget shows each record with the Name to type into the provider's panel (@, www, shop) and the provider's own label for the record type, then reads public DNS for every record through GET /v1/connections/{id}/diagnose. Each record shows one of: found, not found yet, saved at the provider and waiting for caches, or still pointing somewhere else (named when it is a known service, such as "Still points to Squarespace"). It also flags the mistakes that block a connection even when a record looks right: the full hostname typed into the Name field, Cloudflare's proxy turned on, a leftover IPv6 record, extra A records, a conflicting record, or a CAA record that blocks certificates. The connection goes live the moment everything is in place.

Each record card says exactly what to type at that provider: the Name for a root domain is @, an empty field, or the full domain depending on the panel; a hostname value keeps its final dot where the panel would otherwise append the zone; on Cloudflare the card adds Proxy: DNS only. The provider's step-by-step guide and known pitfalls sit behind Show me how. Warnings appear only for problems the live check actually found.

Other ways to finish

Below the records, the screen offers three ways out for people who can't, or would rather not, edit DNS themselves.

Let an AI assistant do it. Copy prompt copies one self-contained prompt, and a row of icons opens it in ChatGPT (covers Codex), Claude, Gemini, Grok, Perplexity or Microsoft Copilot. ChatGPT, Claude, Perplexity and Grok open with the prompt already filled in; Gemini and Copilot have no prefill, so the widget copies the prompt, opens the app and says Prompt copied. Paste it in. The prompt (English, so any assistant can act on it) names the domain, the DNS provider and its DNS panel link, and each record with its type, the Name to type at that provider, the value and the TTL. It turns the live check into exact instructions (delete the old A or AAAA record, turn Cloudflare's proxy off, remove a doubled name), adds the provider's pitfalls, works for assistants that drive a browser (the user signs in and handles two factor codes) and for ones that run code (the provider's DNS API with a token the user supplies), forbids touching MX or email TXT records, and ends with dig commands and the answers to expect. It also carries a status page link, minted with POST /v1/sharing/connect the first time the user reaches for the section; that link works once. The prompt shares the domain and its records, nothing about your application beyond applicationName.

Email these instructions to someone else. An inline form takes the recipient's address and an optional note (up to 280 characters) and calls POST /v1/sharing/connect with recipient_email and note; the control plane emails the records and a link to finish. On success the widget shows Sent to [email protected] and reports FINISHED_SUCCESSFULLY_LINK_SHARED with customdomain:shared. A rejected address (400), too many sends (429), or an email that could not be sent (email_error) each get a plain explanation, and the last still hands over the link. Copy link instead mints a link and copies it. If you configured customProperties.forwardLink.url, the row opens your page with the link instead, as before.

The person who opens the link acts as the end user who shared it: a widget token shares as its own end_user_ref, and your API key (or an app scope token) shares as the end_user_ref of your connection for the one domain the link names, so the link keeps working on a connection that names a customer. The link is a credential for finishing that one setup, not the customer: it cannot remove the domain, cannot set the customer's contact address, and is never offered the customer's kept keys (saved access); a key made at a sign-in it starts is not the customer's own and never replaces theirs. One rule governs a link: its token owns exactly what the token that made it owned, and may touch only the shared domains. A widget token can share only a domain whose connection it owns. A link made before this release stops working: ask for a new one. See Widget tokens.

Let your platform keep the domain working. The row reads "Let Acme Sites keep your domain working", with your applicationName. Offered only when your application turns on Domain maintenance (its nameserver_migration setting), the one switch for every DNS hosting offer the widget makes. The user agrees once, and the widget moves their nameservers to CustomDomain™ or to your own DNS host. With the setting off (the default), the widget offers no DNS hosting, even where managed_dns.available is true, and when the offer comes back available: false for a domain it shows no hosting row at all.

Finishing a zone CustomDomain™ already hosts. A connection whose end user started managed DNS in an earlier session (before the setting decided every offer) gets a row titled Let CustomDomain™ host your DNS to pick up where they left off. The widget no longer offers it to anyone new. The row leads to the copied records (where a zone file exported from the current provider can be pasted), then the nameservers to set with the registrar's name and link and short steps, including turning DNSSEC off first. The widget watches GET /v1/connections/{id}/managed-dns until the delegation is active, then continues to the normal success path (IN_PROGRESS, then FINISHED_SUCCESSFULLY with setupType: "automatic"). These screens report MANUAL_CONFIGURATION.

To turn one off, set customProperties.aiHandoff.hide or customProperties.forwardLink.hide (the email row). DNS hosting is turned on or off by the application's setting, not here. customProperties.managedDns.hide is deprecated: it is still accepted and still hides the row for finishing a zone already started, which is all it can hide now.

White-label configuration

whiteLabel accepts quick keys, a full raw-token override, per-screen disables, and two layout behaviors:

KeyEffect
colorsprimary, primaryHover, background, text, success, error, … mapped onto tokens. A primary brings readable text on it, a hover shade, a link color that reads on the page and a focus ring; in dark mode a dark brand color is lifted until it stands out from the surface. A background or text brings the surfaces, borders, inputs and quiet text with it, so a custom page color never leaves white inputs on it. A value that is not a hex color is used as given, with none of this.
font / borderRadius / logoFont family, corner radius, header logo (an https: URL or a base64 image). Every corner of the widget follows borderRadius in proportion, so 0px is square throughout. With a logo set, the header shows it centered on its own (the stock globe and DNS marks under the header are dropped, though an icon you supply for that slot still shows), at the logo-height token (32px by default); a logo that fails to load gives way and the header closes up. A logo uploaded in the console's widget designer is hosted for you at a permanent https: URL, and the install code the console shows already uses it.
logoDark / logoPlateIn dark mode logoDark replaces the logo. With no logoDark, logoPlate: true puts logo on a small light plate, for a logo drawn for light pages.
fontUrl / googleFontfontUrl is an https: stylesheet URL (your own @font-face) loaded into the widget; googleFont is { family, weights?, display? } and loads that Google Font. Both still use font or the family you name, and your page's content security policy must allow the font host.
customCopy / iconscustomCopy overrides screen text, keyed by locale (or "*" for all) and then by copy key. icons replaces icon slots with an https: or data:image URL or inline SVG.
hideLogoHide the powered-by footer.
hideConfettiDisable the success confetti.
tokensOverride any of the ~90 raw design tokens (modal-width, color-primary, radius-md, shadow-modal, …).
screens.disableArray of optional screens to skip: existingRecords, dkim, email, manualConfiguration, unsupported, provider-list and congratulations. A skipped screen moves on as its main button would. With congratulations skipped, onSuccess still fires for each domain, then the widget opens the next queued domain or closes, and that close is not counted as abandoned.
delegateCloseThe widget never tears itself down; it posts customdomain:request-close and you call close().
embeddedRender as a flat, full-bleed panel inside your container instead of a fullscreen modal.
darkMode / darkTokens"disabled" (default), "enabled" or "auto" (follows the OS); darkTokens fine-tunes the dark palette.
customPropertiesGranular toggles: hideCloseButton, hideProgressIndicator, providerLogin.changeProvider.hide, recordsPreview.hide, forwardLink (hide, url, label), gotoManualLink.hide, useCustomExitConfirmation, aiHandoff.hide, managedDns.hide (deprecated: see Other ways to finish).

Tokens are emitted as CSS custom properties (--je-<token>) on the widget's shadow host, so they cannot affect your page. Every value must be a plain CSS value (a color, length, font stack or shadow); anything that could inject CSS or script is dropped, and custom icons are rebuilt from an allowlist of SVG elements and attributes.

The default look follows one system: a 12, 13, 14, 15, 17 and 21 px type scale (font-size-xs through font-size-h2, plus font-size-lg), a 4 px spacing rhythm (space-2xs through space-xl), and one motion curve. Screens slide in the direction of travel (motion-duration, motion-ease, motion-slide-distance), the modal glides between heights, and controls react in motion-duration-fast. Under prefers-reduced-motion all of it is off and spinners only slow down.

Widget statuses

onStepChange / customdomain:step emit the widget's public status, one of a fixed 15-value set of stable names: INITIAL, ENTER_DOMAIN, DOMAIN_ANALYSIS, DOMAIN_SETUP, IN_PROGRESS, EXISTING_RECORDS, LOGIN, LOGIN_2FA, MANUAL_CONFIGURATION, PROVIDER_MANUAL_SELECTION, EXIT_WITH_ERROR, DKIM_SETUP, FINISHED_SUCCESSFULLY, FINISHED_SUCCESSFULLY_MANUAL, FINISHED_SUCCESSFULLY_LINK_SHARED.

The removal flow uses the same set: reading what removal would do is DOMAIN_ANALYSIS, the choice is DOMAIN_SETUP, one more sign-in is LOGIN, removing is IN_PROGRESS, and the summary is FINISHED_SUCCESSFULLY.

Token lifecycle & accessibility

Widget tokens expire (15 minutes by default). With getToken the widget refreshes itself and carries on; without it, the user is told they can close the window while the connection finishes on its own. A domain-bound token may only act on the hostname it was minted for.

Focus trap, aria-modal, a live-region announcer, generous touch targets, safe-area insets, prefers-reduced-motion, and prefers-color-scheme dark mode are built in. Enter submits every form; each new step moves focus to its heading (or its one input) so screen readers announce it; Escape asks before leaving; live checks announce only real changes. On phones the widget takes the full screen. The iframe is sandboxed with allow-popups/ allow-popups-to-escape-sandbox so provider OAuth windows and the AI assistants can open in a new tab.

Analytics

Every widget session reports its funnel (opened, domain entered, provider detected, offered one-click or sign-in, shown records to copy, live, or closed early) with the provider, the rail (oauth, domain_connect, api_key, managed_dns, cloud_browser for secure sign-in, or manual), and whether the domain was a root or a subdomain. Read it in the console under Analytics, or from your server with GET /v1/analytics/connect (an API key). The records screen's alternatives report as their own steps: handoff-ai (copied or opened the AI prompt), share-email (sent the instructions, or copied the link instead), and managed-dns (opened or started DNS hosting).

What a widget session records

Each time the widget opens, CustomDomain™ keeps one record of that session, attributed to your workspace and application, for security, abuse prevention, support and to improve the widget. It never holds the domain name, anything the end user typed, a cookie or an account.

FromWhat is kept
The requestThe end user's IP address. The country, region and city Cloudflare derives from it, and the network's AS number and name where the deployment passes them on. The device type (phone, tablet or computer), operating system and browser (its major version), read from the user agent, which itself is not kept.
The widgetThe screen size and the widget's viewport size, the pixel ratio, whether the screen is a touch screen, the browser's language and the widget's language, the time zone, light or dark mode and reduced motion, the kind of network connection where the browser reports it, the widget's and the SDK's version, whether the widget was embedded or a modal, and the origin of the host page and of its referrer (scheme, host and port, never a path).
How it wentThe first and last step, the rail, the provider, whether it worked, the error code, and the connection and any secure sign-in it started.

The IP address is kept for as long as your account exists, unless the deployment sets a window (IP_RETENTION_DAYS, the number of days after which the address is cleared and the rest of the record stays). The record is deleted with the application it belongs to. You see counts only: the devices, countries, browsers, systems and embeds in Analytics, Devices and places, and in audience on GET /v1/analytics/connect. No address, city or single session is ever shown.

On this page