DoDomain

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.

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 tab

The widget

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().

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

OptionTypeNotes
tokenstring — requiredThe dd_sess_… token from POST /api/v1/sessions. Mint it on your server; only the token goes to the browser.
baseUrlstringDoDomain origin the iframe loads from. Defaults to https://app.dodomain.io.
onVerified({ domain? }) => voidAll records verified. Update your UI optimistically — and still treat the signed webhook as the source of truth.
onClose({ state, domain? }) => voidThe 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) => voiderr.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.
loadTimeoutMsnumberHow 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)

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 under Apps → your app → Branding in the dashboard. 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.

SettingLimitWhat it changes
Headline80 charactersReplaces "Connect your domain to your app" at the top of the flow.
Subheadline200 characters, one lineAdds a supporting line under the domain.
Success button label40 charactersReplaces "Back to your app" (or "Done" in the widget) once the domain connects.
Success redirect URLhttps:// only, no credentialsWhere the success button goes when the session was created without a returnUrl. A session's own returnUrl always wins.
FontSystem UI, Humanist sans, Serif or RoundedRe-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 helpOn or offDrops 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

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

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

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

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 and SDKs.

On this page