CustomDomain Docs

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:

StatusCodeMeaning
401unauthorizedThe API key is missing or was rejected.
403no_applicationThe key's account has no applications.
403forbiddenThat application_id does not belong to the key's account.
503no_widget_credentialsThe application was not created in the console. Use the client secret path below.
500 / 502unseal_failed / mint_failedA 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 serverRuns 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.

WayHowCurrent version
Hosted script (no build step)<script src="https://app.customdomain.ai/widget-assets/customdomain-sdk.js"></script> attaches window.customdomainAlways the latest build
npmnpm install customdomain-js0.5.5
Reactnpm install @customdomain/react customdomain-js react react-dom0.5.5
Live from npm

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.

On this page