# Embed the connect flow (https://dodomain.io/docs/widget)

Open the DoDomain connect flow inside your product: the showDoDomain widget with callbacks and postMessage events, or the zero-code hosted link.

Two ways to put the flow in front of your user: the **hosted link** (zero client code; works today for every integrator) and the **embeddable widget**, which opens the same hosted flow in a modal iframe on your page and reports back through callbacks.

## The hosted link (zero client code) [#the-hosted-link-zero-client-code]

Every session comes with a `connectUrl`. Link it, redirect to it, or open it in a new tab: the hosted page at `app.dodomain.io/connect/<token>` handles provider detection, one-click Cloudflare, guided records, and verification. Pass `returnUrl` when creating the session so the user gets a way back into your app after the domain connects. The token is the only thing in the URL, and it expires with the session (24 h).

```ts
// Server-side: mint the session, then just send the user to connectUrl.
const res = await fetch("https://app.dodomain.io/api/v1/sessions", {
  method: "POST",
  headers: {
    authorization: "Bearer " + process.env.DODOMAIN_SECRET_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    domain: "customer.com",
    records: [{ type: "CNAME", host: "app", value: "edge.yourproduct.com" }],
    returnUrl: "https://yourproduct.com/settings/domains", // the way back to you
  }),
});
if (!res.ok) throw new Error("dodomain session failed: " + res.status);
const session = await res.json();

redirect(session.connectUrl); // or render it as a link / open in a new tab
```

## The widget [#the-widget]

```bash
npm install @dodomain/connect
```

`showDoDomain(options)` draws a full-viewport scrim and a centered iframe sheet loading the same hosted flow; the sheet sizes itself to its content (the flow reports its height as steps expand and collapse) and adopts the theme you pass, so it reads as part of your page rather than a foreign popup. It is dependency-free and framework-agnostic: call it from any click handler. Clicking the backdrop closes it; it returns a handle with `close()`.

```ts
import { showDoDomain } from "@dodomain/connect";

// 1. Get a session token from YOUR server (which holds the dd_sk_ secret).
const { token } = await fetch("/api/domains/connect-session", { method: "POST" }).then((r) =>
  r.json(),
);

// 2. Open the sheet. The user connects; you get callbacks.
const handle = showDoDomain({
  token,
  theme: "dark", // pass the theme YOUR page is rendering; the sheet matches it
  onVerified: ({ domain }) => {
    // DNS verified: refresh your domain settings UI. Your server still gets
    // the signed connection.verified webhook as the source of truth.
    refreshDomains();
  },
  onClose: ({ state }) => {
    // "verified" | "pending" | "failed" | "unknown": closing tells you where
    // the user got to, so you don't have to re-poll your backend to find out.
    if (state !== "verified") keepDomainPromptVisible();
  },
  onError: (err) => {
    // err.code === "MOUNT_BLOCKED" ⇒ the iframe never mounted (your CSP, the
    // network, a content blocker). Fall back to the same session full-page.
    if (err.code === "MOUNT_BLOCKED") return location.assign(err.hostedUrl);
    showToast("Domain connect failed: " + err.code);
  },
});

// handle.close() tears the modal down programmatically.
```

### Options [#options]

| Option          | Type                           | Notes                                                                                                                                                                                                                                                        |
| --------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `token`         | string, **required**           | The `dd_sess_…` token from `POST /api/v1/sessions`. Mint it on your server; only the token goes to the browser.                                                                                                                                              |
| `baseUrl`       | string                         | DoDomain origin the iframe loads from. Defaults to `https://app.dodomain.io`.                                                                                                                                                                                |
| `onVerified`    | `({ domain? }) => void`        | All records verified. Update your UI optimistically, and still treat the signed webhook as the source of truth.                                                                                                                                              |
| `onClose`       | `({ state, domain? }) => void` | The user dismissed the modal. `state` is `"verified"`, `"pending"` (the flow mounted, no outcome), `"failed"` (the flow reported an error), or `"unknown"` (the flow never came up). A zero-argument handler written against an older version keeps working. |
| `onError`       | `(err) => void`                | `err.type` is `"load-timeout"`, `"load-error"`, or `"session-error"`; every one also carries `err.code` and `err.hostedUrl`. `code === "MOUNT_BLOCKED"` means the iframe never mounted; see [Origins & CSP](#origins--csp).                                  |
| `loadTimeoutMs` | number                         | How long to wait for the flow's ready handshake before `onError({ type: "load-timeout" })`. Default `15000`.                                                                                                                                                 |
| `theme`         | `"light" \| "dark"`            | Pass the theme your page is currently rendering; the sheet adopts it (and hides its own theme toggle), and the frame pre-paints in that theme so a slow load never flashes the wrong brightness. Omitted ⇒ the flow resolves its own theme.                  |

## White-label (Pro and Scale) [#white-label-pro-and-scale]

Every plan can set a logo and a brand color. On **Pro** and **Scale** the flow can also speak in your product's voice, with no "Powered by DoDomain" line. Set it with the `update_app_branding` tool or the Branding card on the app page. It applies to the hosted page, and because the widget loads that same page, to the widget too. There is no new widget option and no code change.

| Setting              | Limit                                      | What it changes                                                                                                                         |
| -------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| Headline             | 80 characters                              | Replaces "Connect your domain to _your app_" at the top of the flow.                                                                    |
| Subheadline          | 200 characters, one line                   | Adds a supporting line under the domain.                                                                                                |
| Success button label | 40 characters                              | Replaces "Back to _your app_" (or "Done" in the widget) once the domain connects.                                                       |
| Success redirect URL | `https://` only, no credentials            | Where the success button goes when the session was created without a `returnUrl`. A session's own `returnUrl` always wins.              |
| Font                 | System UI, Humanist sans, Serif or Rounded | Re-fonts the flow with a system font stack. Nothing is downloaded, so your CSP needs no `font-src` for it. DNS records stay monospaced. |
| Hide footer help     | On or off                                  | Drops the "DNS can take a few minutes to propagate" line under the card. The link-expiry line stays.                                    |

All copy is plain text; markup is shown as typed, never rendered. The DNS-admin setup link (`/setup/<token>`) picks up the font and the footer setting only, because its copy is addressed to the admin rather than to your user. `GET /api/v1/apps` returns the stored values read-only.

On **Free** these settings are shown but locked, and the flow keeps today's copy and attribution. If a team moves down to Free, its saved settings are kept but stop showing immediately; they come back on upgrade. A Free team can still clear them.

## Origins & CSP [#origins--csp]

The widget loads the hosted flow from **one** origin, so a Content-Security-Policy on your page has to allow it as a frame source. Browsers fall back `frame-src` → `child-src` → `default-src`, so allow it in whichever of those your policy actually sets:

```
Content-Security-Policy: frame-src https://app.dodomain.io;
```

`https://app.dodomain.io` is the production origin: the default `baseUrl`, and the same origin that serves `/api/v1/*` and the hosted `/connect/<token>` page. `api.dodomain.io` and `connect.dodomain.io` are cosmetic names for that same deployment and are not served today: don't allowlist them. If you pass your own `baseUrl` (staging, self-hosted), allowlist that origin instead. Nothing else needs a directive; the widget injects no scripts, styles, fonts or images into your page; the flow's UI lives entirely inside the iframe.

Set **Allowed origins** in your app's dashboard to restrict which sites may embed its connect flow. A non-empty list is enforced by the hosted page's `Content-Security-Policy: frame-ancestors` response header. Every ancestor must be allowed, including outer pages when frames are nested. Add the full origin, such as `https://app.example.com` or `http://localhost:3000` for local development. The widget's `origin` query parameter cannot bypass this restriction. Opening the connect link as a full page is unaffected.

An empty list keeps the default: any site may embed the flow (`frame-ancestors *`). That wildcard matches network schemes only, not `file:`, `data:` or `blob:`, so a test page opened straight from disk (`file://`) is refused by the browser: serve it over `http://localhost` instead. If the allowed-origins lookup fails, framing is refused until the lookup succeeds.

If the frame is blocked anyway (a missed CSP, a content blocker, an offline network), `onError` fires with `code: "MOUNT_BLOCKED"` (fast, off your page's own `securitypolicyviolation` event, when the browser reports one; otherwise after `loadTimeoutMs`). Fall back to the same session full-page, which needs no second API call:

```ts
showDoDomain({
  token,
  onError: (err) => {
    if (err.code === "MOUNT_BLOCKED") location.assign(err.hostedUrl); // {baseUrl}/connect/{token}
  },
});
```

`hostedUrl` omits the `embed`/`origin`/`theme` params the iframe carries; those put the flow into embedded mode, which is wrong for a top-level navigation. Pass `returnUrl` when you mint the session so the user lands back in your app afterwards.

## postMessage events [#postmessage-events]

Under the hood the hosted flow talks to the widget with `window.postMessage`. The callbacks above are the supported API, but if you embed the hosted page in your own iframe instead of using the widget, these are the messages you can rely on. Always check `event.origin` against the DoDomain origin before trusting one.

| Message type        | Payload               | Meaning                                                                                                                                                                                                                                    |
| ------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `dodomain:ready`    | none                  | The flow mounted inside the iframe (the widget uses this to cancel its load timeout).                                                                                                                                                      |
| `dodomain:verified` | `{ domain?: string }` | All records verified for the session's domain.                                                                                                                                                                                             |
| `dodomain:error`    | `{ code: string }`    | The session failed: a `verify()` error mid-flow, or a token that was already expired/unknown when the sheet opened (that case arrives WITHOUT a preceding `dodomain:ready`, since the flow never mounts; it cancels the load timeout too). |
| `dodomain:close`    | none                  | The user asked to close the flow.                                                                                                                                                                                                          |
| `dodomain:height`   | `{ height: number }`  | The flow's natural content height, posted on mount and on every resize; the widget uses it to size the sheet to the content (clamped between 280px and 92% of the window). If you embed the hosted page yourself, apply it the same way.   |

The flow scopes its messages to your page's origin (passed on the iframe URL), so other frames can't eavesdrop on the verified domain.

There is one further type, `dodomain:popup-verified`, which is **not** part of this contract and never reaches your page. It travels same-origin only, from the hosted flow running inside a provider popup back to the hosted flow that opened it, and it is how the sheet flips to "connected" while the popup is still on screen (see below). Ignore it if you see it; a widget build that doesn't know it already does.

## Sizing and behavior [#sizing-and-behavior]

- The sheet is `min(560px, 94vw)` wide and **content-sized**: it opens at `min(480px, 92vh)`, then follows the flow's reported height (clamped between `280px` and `92vh`, animated): no dead canvas under short content, no inner scrollbar under tall content. 14 px radius, hairline border, and the frame declares its own `box-sizing` so your page's CSS resets can't distort it.
- The pre-paint matches the `theme` you pass (white for light, graphite for dark), so a slow load never flashes the wrong brightness inside your page.
- One-click provider connect (e.g. Cloudflare, or Domain Connect at the user's registrar) opens the provider's own consent in a sized popup window, because those pages refuse to be framed. The popup comes back **inside the embedded flow**, not on the standalone hosted page: it reports the result to the sheet that opened it, closes itself, and the sheet flips to "connected" without waiting for anything. If the browser blocks the popup, the same happens in the new tab that takes its place. Popup-closed, `focus` and `visibilitychange` re-checks stay as fallbacks, so the user never has to press a "check" button either way.
- The scrim is a warm-ink overlay at the maximum z-index; backdrop click and the flow's own close affordance both dismiss it.
- No webfonts, no external CSS, no runtime dependencies are injected into your page; the flow's UI lives entirely inside the iframe.
- An expired or invalid token surfaces as `onError({ type: "session-error", code: … })`: mint sessions on demand rather than storing them; they're free.

<Callout title="The webhook is the source of truth">
  `onVerified` fires in the browser, where anything can be spoofed. Treat it as a cue to refresh
  your UI, and act on the signed `connection.verified` webhook your server receives. See
  [Webhooks](https://dodomain.io/docs/webhooks) and [SDKs](https://dodomain.io/docs/sdks).
</Callout>
