Embed the connect flow
Open the DoDomain connect flow inside your product — the showDoDomain widget (theme-matched, content-sized sheet 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)
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).
// 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 tabThe widget
npm install @dodomain/connectshowDoDomain(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().
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
| 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. |
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. |
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.
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:
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
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 | — | 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 | — | 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.
Sizing and behavior
- The sheet is
min(560px, 94vw)wide and content-sized: it opens atmin(480px, 92vh), then follows the flow's reported height (clamped between280pxand92vh, animated) — no dead canvas under short content, no inner scrollbar under tall content. 14 px radius, hairline border, and the frame declares its ownbox-sizingso your page's CSS resets can't distort it. - The pre-paint matches the
themeyou 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) opens the provider's own consent in a sized popup window; when the popup closes, the flow re-verifies on its own — the user never has to press a "check" button.
- 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.