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.
| Method | Purpose |
|---|---|
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 event | Callback | Fires | detail / arg |
|---|---|---|---|
customdomain:step | onStepChange(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:success | onSuccess(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:close | onClose(detail) | when the modal closes | { lastStatus, error?, shared?, manualScreenDisabled? }. shared is set when the user forwarded the setup to a colleague before closing |
customdomain:error | onError(err) | when the widget shows an error | { code, message, title?, details? } |
customdomain:fallback | onFallback(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:purchase | none | the buy flow created a checkout session | { domain, sessionId?, clientSecret?, url? }: mount Stripe Embedded Checkout with clientSecret |
customdomain:removed | onRemoved(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-close | none | host-delegated dismissal (whiteLabel.delegateClose) | provider payload; you call close() |
customdomain:doc-click | none | user clicked a documentation link | link payload |
customdomain:shared | none | a 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:successis an event, not a dismissal. The widget stays on the success screen and closes itself when the user is done (or when you callclose()).
open() / OpenConfig
| Field | Type | Notes |
|---|---|---|
applicationId (required) | string | Public application id. |
token (required) | string | Widget 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. |
domain | string | Pre-filled domain. |
prefilledDomain | string | string[] | Alias accepted for the pre-fill. |
wwwRedirect | boolean | Root 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). |
dnsRecords | DNSRecord[] | 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 / applicationUrl | string | Branding shown in the flow. |
locale | string | UI locale. |
whiteLabel | WhiteLabel | Theming + behavior (below). |
forceManualSetup | boolean | Skip automatic rails; always show records. |
forceSubdomain | boolean | Only accept a subdomain; the root option is hidden. |
supportForSubdomains / defaultSubdomain / hostRequired | Subdomain-entry behavior. | |
manualSetupDocumentation | string | Deep-link to your own manual-setup guide. |
enableDkim | boolean | Offer the DKIM screen. |
enableEmail | boolean | Show the email setup screen (the email record template) before the domain records. |
managed | boolean | Opt 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. |
endUserRef | string | Your id for this customer. Stored on the connection as end_user_ref, shown in the console, and sent with every webhook as user_id. |
userId | string | An id echoed back on the widget's step events. |
container | string | CSS selector for embedded mode (implies whiteLabel.embedded). |
apiBase / widgetBase | string | Override the default control-plane (https://api.customdomain.ai) / widget (https://app.customdomain.ai) hosts (for example a staging environment). |
iframeSandbox | string | Advanced. Replaces the iframe's sandbox attribute. The default allows scripts, forms and popups that escape the sandbox so provider sign-in windows can open. |
searchType | string | Search 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). |
connectionId | string | The 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 / onRemoved | fn | Callbacks (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:
| Key | Effect |
|---|---|
colors | primary, 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 / logo | Font 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 / logoPlate | In 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 / googleFont | fontUrl 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 / icons | customCopy 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. |
hideLogo | Hide the powered-by footer. |
hideConfetti | Disable the success confetti. |
tokens | Override any of the ~90 raw design tokens (modal-width, color-primary, radius-md, shadow-modal, …). |
screens.disable | Array 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. |
delegateClose | The widget never tears itself down; it posts customdomain:request-close and you call close(). |
embedded | Render 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. |
customProperties | Granular 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.
| From | What is kept |
|---|---|
| The request | The 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 widget | The 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 went | The 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.
Installation & embed
Install customdomain-js, mint a widget token on your server, open the connect modal in the browser, and use React, embedded mode and white-label theming.
Widget tokens
Mint short-lived widget JWTs with POST /v1/tokens, choose between the connect and app scopes, and keep a long browser session alive without exposing an API key.