An embedded, on-domain Dodo Payments checkout that looks like your product, not a payment vendor's. The customer never leaves your domain, the modal chrome / loading states / error states / success flow are 100% yours, and the payment form iframe is themed to your design tokens. Apple Pay and Google Pay render in-context (they work in Dodo's inline and hosted modes — not in overlay mode).
Extracted from a production Next.js + Cloudflare app processing real payments.
Honest scope: the payment form itself is still Dodo's cross-origin iframe (exactly like Stripe Elements). What's vendor-agnostic is everything around it — the dialog, theme, skeleton, retries, and the success redirect. The only vendor trace left is fine print inside the frame.
The modal live, verified end-to-end in test mode (Google Pay rendering in-context):
Browser Your server
┌─────────────────────────┐ ┌──────────────────────────┐
│ CheckoutModal.tsx │ 1. POST │ server-create-checkout.ts │
│ your dialog chrome │──────────▶│ dodopayments SDK │
│ skeleton / retry / done │ │ checkoutSessions.create │
│ │◀──────────│ → { checkout_url } │
│ dodo-checkout.ts │ 2. url └──────────────────────────┘
│ dodopayments-checkout │
│ SDK, inline mount, │ 3. SDK embeds themed iframe into your div
│ themed to YOUR tokens │ 4. success event → redirect to YOUR page
└─────────────────────────┘
server-create-checkout.ts— creates the checkout session server-side with the officialdodopaymentsSDK. Only thecheckout_urlreaches the client; the API key never does.dodo-checkout.ts— thin client wrapper arounddodopayments-checkout. Initializes once, maps SDK events to per-mount callbacks, themes the iframe.CheckoutModal.tsx— a self-contained React modal: your overlay + panel, a skeleton loader while the iframe boots, an inline retry on failure, and the SDK mount target.
npm i dodopayments # server
npm i dodopayments-checkout # client
Env (server only):
DODO_ACCESS_TOKEN=... # API key — https://app.dodopayments.com → Developer → API Keys
DODO_SERVER=test # "test" | "live"
APP_URL=https://yourapp.com
checkout.form_readyis NOT reliably delivered in inline mode. If you gate your loader on it, the skeleton hangs forever. Treatcheckout.opened,checkout.payment_page_opened, or the firstcheckout.resizeas "the form is alive".- The SDK is a singleton that tracks ONE open checkout. Opening a second checkout in the same session silently no-ops with "Checkout is already open" and the new iframe never mounts. Call
Checkout.close()before everyopen()and on component unmount. checkout.errormeans two different things. Before the form mounts it's fatal (show retry). After mount it also fires for recoverable in-form events — e.g. an Apple Pay sheet dismissed emits it. If you tear the iframe down on everycheckout.error, a customer who cancels the Apple Pay sheet loses the whole form. Only treat it as fatal pre-mount.- Theme colors must be literal hexes. The iframe is cross-origin — it cannot read your CSS variables. Map your design tokens to hex at build time. The SDK themes colors, radius, and font size/weight — not font family (the iframe can't load your fonts).
manualRedirect: true+ handlingcheckout.redirectyourself is what makes success land on your page with your celebration UI. There is nocheckout.successevent — the redirect event is success.- Don't auto-redirect to the hosted page on slow mounts. The embedded form can take 7–9 s to report ready. A short timeout that bounces customers to Dodo's hosted page defeats the whole point. Use a generous deadline (20 s) and show an inline retry; offer the hosted page only as a user-clicked escape hatch.
- Apple Pay needs explicit opt-in. Pass
allowed_payment_method_types: ["apple_pay", "google_pay", "credit", "debit"]when creating the session — the default set omits Apple Pay. Apple Pay also requires your domain to be registered with Apple via Dodo (host their association file at/.well-known/apple-developer-merchantid-domain-association, then email Dodo support with your domain). - Guard the URL before feeding it to the SDK. The inline SDK throws on non-Dodo hosts.
isDodoCheckoutUrl()keeps a host allowlist so a mis-wired URL degrades to a plain redirect instead of a crash. - Test vs live mode needs no client env var. Infer it from the URL host (
test.checkout.dodopayments.comvscheckout.dodopayments.com). - Collect the minimum address.
minimal_address: trueasks only country + ZIP (the tax minimum for a Merchant of Record) instead of a full street address form. Fewer fields, better conversion.
const [checkoutUrl, setCheckoutUrl] = useState<string | null>(null);
async function buy() {
const res = await fetch("/api/checkout", { method: "POST", body: JSON.stringify({ productId }) });
const { url } = await res.json();
setCheckoutUrl(url);
}
{checkoutUrl && <CheckoutModal checkoutUrl={checkoutUrl} onClose={() => setCheckoutUrl(null)} />}
