# 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.

Source: https://docs.customdomain.ai/docs/widget-sdk/installation-and-embed

### 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`:

```js
// 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`:

```js
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](/docs/authentication/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                   |

<UsageStats />

`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.

```js
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:

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `applicationId` (required) | `string` |  | Public application id. |
| `token` (required) | `string` |  | Widget token (scope connect) minted on your server in step 1. |
| `getToken` | `() => string \| Promise<string>` |  | Returns a fresh widget token from your own server route. Called when the current one expires mid-setup, so a long manual setup does not stop. |
| `domain` | `string` |  | Pre-filled domain. |
| `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. |
| `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. |
| `onSuccess` | `(result) => void` |  | When the domain reaches live: { jobId, domain, setupType, provider, success: true }. |
| `onFallback` | `(detail) => void` |  | Once per domain, when the user goes to the records (no automatic way, or they chose Add the records): { reason, provider?, screen, message? }. |
| `onClose` | `(detail) => void` |  | When the modal closes: { lastStatus, error?, shared?, manualScreenDisabled? }. |
| `onStepChange` | `(detail) => void` |  | On every screen change: { step, domain?, provider?, user?, error? }. |
| `onError` | `(err: CustomDomainError) => void` |  | When the widget shows an error: { code, message, details?, status? }. |
| `container` | `string` |  | CSS selector for embedded mode (implies whiteLabel.embedded). See Embedded mode below. |
| `whiteLabel` | `WhiteLabel` |  | Theming and behavior. See White-label theming below. |
| `apiBase` | `string` | `https://api.customdomain.ai` | Control plane host, for example for a staging environment. |
| `widgetBase` | `string` | `https://app.customdomain.ai` | Widget host. |

Every option, event and status is in the [reference](/docs/widget-sdk/reference#open--openconfig).

## 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.

```jsx
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`):

```js
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](/docs/widget-sdk/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](/docs/widget-sdk/reference#white-label-configuration) and
> [Plans & quotas](/docs/billing/plans-and-quotas#plans).

```js
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](/docs/widget-sdk/reference#white-label-configuration) 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](/docs/guides/nameserver-migration)) letting you keep the
domain working for the user. The first two can be switched off here; DNS hosting
is the application's setting:

```js
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](/docs/widget-sdk/reference#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.
