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.
Mint a token on your server
The browser must never see an API key or a client_secret. Your server holds
the credential, asks CustomDomain™ for a short-lived widget token (15 minutes),
and hands only that token to the browser. Which credential you use depends on
how the application was created.
Applications created in the console (every console signup gets one): use an
API key. The application's client_secret is sealed in the console and is
never shown anywhere, so POST /v1/tokens has nothing to authenticate with.
Create a key on the API & SDK page (Create key; copy it when it is
shown, it appears once), keep it in a server environment variable, and call
the console's POST /api/widget-token:
// Your server route, for example POST /api/customdomain-token.
// Only signed-in users should reach it: add your own auth check.
const res = await fetch("https://app.customdomain.ai/api/widget-token", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CUSTOMDOMAIN_API_KEY}`, // server only
"Content-Type": "application/json",
},
body: JSON.stringify({
application_id: "app_...", // optional: defaults to your first application
domain: "app.customer.com", // optional: bind the token to one host
}),
});
const { application_id, token } = await res.json();
// Return { application_id, token } to your own front end. Never the API key.The response is 200 { "application_id", "token", "expires_in": 900, "token_type": "Bearer" }.
The errors you can get back:
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | The API key is missing or was rejected. |
| 403 | no_application | The key's account has no applications. |
| 403 | forbidden | That application_id does not belong to the key's account. |
| 503 | no_widget_credentials | The application was not created in the console. Use the client secret path below. |
| 500 / 502 | unseal_failed / mint_failed | A server-side failure. Try again, then contact support. |
Applications created over the API (POST /v1/applications): that call
returns the client_secret once. Keep it on your server and exchange it with
POST /v1/tokens:
const res = await fetch("https://api.customdomain.ai/v1/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
application_id: process.env.CUSTOMDOMAIN_APPLICATION_ID,
client_secret: process.env.CUSTOMDOMAIN_CLIENT_SECRET, // server only
domain: "app.customer.com", // optional: bind to one host
}),
});
const { token } = await res.json(); // 201, expires_in 900, scope "connect"Do not put an API key in client_secret: /v1/tokens only accepts the
application's own secret and answers 401 unknown application or secret.
Do not rotate the client secret of a console-created application either: the
console keeps its own copy and its widget preview and /api/widget-token stop
working for that application.
What runs where:
| Runs on your server | Runs in the browser |
|---|---|
Holds the API key or client secret. Mints the token (/api/widget-token or /v1/tokens). Exposes one route of your own, such as /api/customdomain-token. | Calls your route, never CustomDomain™'s mint endpoint. Passes the token to open(). getToken is a function that calls your route again for a fresh token. |
See Widget tokens for the full token lifecycle, scopes and both mint paths.
Install and open
Pick one way to load the SDK.
| Way | How | Current version |
|---|---|---|
| Hosted script (no build step) | <script src="https://app.customdomain.ai/widget-assets/customdomain-sdk.js"></script> attaches window.customdomain | Always the latest build |
| npm | npm install customdomain-js | 0.5.5 |
| React | npm install @customdomain/react customdomain-js react react-dom | 0.5.5 |
getToken, wwwRedirect and onFallback need customdomain-js 0.5.0 or
newer; removeDomain(), onRemoved and the customdomain:removed event need
0.5.2 or newer; CustomDomainError.status and the full checkDomain result
need 0.5.4; the "reconnect" mode's types, reconnected and advisories need
0.5.5. The hosted script always has all of them.
import { customdomain } from "customdomain-js";
// (the hosted <script> build attaches window.customdomain instead)
// Asks your server (step 1) for a fresh widget token.
async function fetchToken() {
const res = await fetch("/api/customdomain-token", { method: "POST" });
if (!res.ok) throw new Error("Could not get a widget token");
return res.json(); // { application_id, token }
}
document.getElementById("connect").addEventListener("click", async () => {
const { application_id, token } = await fetchToken();
customdomain.open({
applicationId: application_id,
token,
getToken: async () => (await fetchToken()).token, // refresh during long setups
domain: "app.customer.com", // optional prefill
endUserRef: "user_42", // your id for this customer
// wwwRedirect: true, // root domains: also connect www
onSuccess: (r) => console.log("live:", r.domain, r.jobId, r.setupType),
onFallback: (d) => console.log("manual:", d.reason),
onClose: (d) => console.log("closed:", d.lastStatus),
onStepChange: (d) => console.log("step:", d.step),
onError: (err) => console.error(err.code, err.message),
});
});open() returns { close } so you can dismiss the modal programmatically. Only
one modal is shown at a time. The default control-plane and widget hosts can be
overridden with apiBase / widgetBase (e.g. for a staging environment).
onError receives a CustomDomainError: { code, message, details?, status? }.
status is the HTTP status of the control plane response the error came from,
and it is unset when the widget itself raised the error. 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 options in the example above:
Prop
Type
Every option, event and status is in the reference.
React
@customdomain/react wraps the SDK in a hook. It turns the customdomain:*
window events into callback props, removes its listeners and closes the modal
when the component unmounts, and is safe to render on the server.
import { useCustomdomain } from "@customdomain/react";
async function fetchToken() {
const res = await fetch("/api/customdomain-token", { method: "POST" });
if (!res.ok) throw new Error("Could not get a widget token");
return res.json(); // { application_id, token }
}
export function ConnectDomainButton({ userId }) {
const { open, ready } = useCustomdomain({
applicationId: "app_...",
token: "", // open() below passes a fresh one
getToken: async () => (await fetchToken()).token,
endUserRef: userId,
onSuccess: ({ domain }) => console.log(`${domain} is live`),
});
return (
<button
disabled={!ready}
onClick={async () => open({ token: (await fetchToken()).token })}
>
Connect your domain
</button>
);
}useCustomdomain returns { open, purchaseDomain, removeDomain, close, ready }. The package
also exports <CustomdomainConnect>, a component that opens the flow when it
mounts. Callbacks: onSuccess, onClose, onStep (the bare step name),
onStepChange (the full detail), onDocClick, onRequestClose, onShared,
onPurchase and onRemoved; onError, onFallback and the other open() options are
passed through to the SDK.
Embedded mode
Render the flow inside your own layout instead of a fullscreen modal by passing a
container selector (implies whiteLabel.embedded):
window.customdomain.open({
applicationId, token,
container: "#connect-panel", // a positioned element you own
whiteLabel: { delegateClose: true },// you own dismissal (see request-close event)
});White-label theming
Pass a whiteLabel object. Quick keys map onto the token surface; tokens
overrides any of the 90 raw design tokens; screens.disable skips optional
screens (the names are in the reference). Theme values are applied as CSS custom properties on the widget's
Shadow-DOM host, so they never leak into your page.
Full custom branding (
whiteLabel) is an Enterprise-plan entitlement when a connect is resumed through a share link (loadSharedFlow) rather than opened directly; see the reference and Plans & quotas.
window.customdomain.open({
applicationId, token,
whiteLabel: {
colors: { primary: "#0ea5e9", background: "#ffffff", text: "#111827" },
borderRadius: "10px",
logo: "https://yourcdn.example/logo.svg",
hideLogo: true, // hide the powered-by footer
hideConfetti: true,
tokens: { "modal-width": "460px" }, // any raw token
},
});See the reference for the
full whiteLabel surface and the token list.
The records screen also offers other ways to finish: an AI assistant prompt, emailing the instructions, and (when your application turns on Domain maintenance) letting you keep the domain working for the user. The first two can be switched off here; DNS hosting is the application's setting:
window.customdomain.open({
applicationId, token,
whiteLabel: {
customProperties: {
aiHandoff: { hide: true }, // no "Let an AI assistant do it"
forwardLink: { hide: true }, // no "Email these instructions"
},
},
});customProperties.managedDns.hide is deprecated: the widget no longer offers
"Let CustomDomain™ host your DNS" to a new session, and the option now hides
only the row for finishing a zone a user already started.
See Other ways to finish.
Security note
The widget JWT is the only credential in the browser. It is short-lived
(15 minutes; pass getToken so the widget can refresh it), scoped to
one application and to the connect flow, and, when you pass domain at mint
time, may only act on that hostname. Mint it per session on your server;
never embed a long-lived API key or client_secret in client code.
How the widget works
Learn how customdomain-js adds a drop-in modal for connecting a domain, how a widget JWT keeps keys out of the browser, and the screens a customer sees.
Reference
Reference for the eight window.customdomain methods, window events and callbacks, the open() config, widget statuses, white-label options and theming tokens.