Skip to content

Instantly share code, notes, and snippets.

@lonormaly
Last active July 7, 2026 09:54
Show Gist options
  • Select an option

  • Save lonormaly/df934d5340b887ff1d6d5d393b25ad54 to your computer and use it in GitHub Desktop.

Select an option

Save lonormaly/df934d5340b887ff1d6d5d393b25ad54 to your computer and use it in GitHub Desktop.
White-label Dodo Payments checkout modal — embedded, on-domain, themed to your design tokens (Apple Pay / Google Pay in-context). Extracted from production.

White-label Dodo Payments checkout modal

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

White-label checkout modal with Google Pay

Architecture (3 files)

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 official dodopayments SDK. Only the checkout_url reaches the client; the API key never does.
  • dodo-checkout.ts — thin client wrapper around dodopayments-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.

Install

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

The gotchas (each one cost us real debugging time)

  1. checkout.form_ready is NOT reliably delivered in inline mode. If you gate your loader on it, the skeleton hangs forever. Treat checkout.opened, checkout.payment_page_opened, or the first checkout.resize as "the form is alive".
  2. 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 every open() and on component unmount.
  3. checkout.error means 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 every checkout.error, a customer who cancels the Apple Pay sheet loses the whole form. Only treat it as fatal pre-mount.
  4. 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).
  5. manualRedirect: true + handling checkout.redirect yourself is what makes success land on your page with your celebration UI. There is no checkout.success event — the redirect event is success.
  6. 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.
  7. 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).
  8. 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.
  9. Test vs live mode needs no client env var. Infer it from the URL host (test.checkout.dodopayments.com vs checkout.dodopayments.com).
  10. Collect the minimum address. minimal_address: true asks only country + ZIP (the tax minimum for a Merchant of Record) instead of a full street address form. Fewer fields, better conversion.

Usage

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)} />}
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
closeDodoCheckout,
isDodoCheckoutUrl,
openDodoCheckoutInline,
redirectToHostedCheckout,
} from "./dodo-checkout";
// A self-contained checkout modal: your overlay + panel around Dodo's embedded
// payment iframe. It renders a skeleton loader (pulsing bars — no spinner)
// until the iframe reports ready, then the embedded form grows to the
// reported height.
//
// STAY-INLINE POLICY: NEVER auto-redirect a slow checkout to the hosted page —
// the customer pays ON your domain so Apple Pay / Google Pay render
// in-context. The embedded SDK reliably mounts but can be slow (~7-9s to first
// event), so a short timer would bounce customers out prematurely. Instead:
// give the mount a generous deadline, and if it still hasn't reported ready
// (or errors before mounting), show an INLINE retry — stay on-domain. A manual
// "Open secure payment page" link is offered as a user-initiated escape hatch
// only — never an automatic redirect.
//
// Success navigation is owned by dodo-checkout.ts (the checkout.redirect
// handler) — this component never handles success.
// Generous: the embedded form is reliably slow to report ready. We are NOT
// redirecting on this deadline (we show an inline retry), so erring long is
// safe — it just delays when the retry affordance appears for a stuck mount.
const READY_DEADLINE_MS = 20000;
const DEFAULT_HEIGHT = 420;
export function CheckoutModal({
checkoutUrl,
onClose,
}: {
checkoutUrl: string;
onClose: () => void;
}) {
// Stable, DOM-valid id (useId() yields ":r0:" which isn't a valid selector).
const elementId = `dodo_inline_${useId().replace(/:/g, "_")}`;
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
const [height, setHeight] = useState(DEFAULT_HEIGHT);
// Bumping this re-runs the mount effect (the Retry button increments it).
const [attempt, setAttempt] = useState(0);
// Mirrors `ready` for the event handlers (which close over the render) —
// decides whether a checkout.error is a FATAL pre-mount failure or a
// RECOVERABLE in-form error (see onError below).
const readyRef = useRef(false);
// Keep callback props in refs so the mount effect does NOT re-run every
// render. Parents typically pass fresh inline arrows each render — if the
// mount effect depended on them, it would re-mount the iframe on every
// render and the skeleton would pulse forever.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const mount = useCallback(() => {
// Guard: the inline SDK throws on non-Dodo hosts. A mis-wired URL
// degrades to a plain redirect instead of a crash.
if (!isDodoCheckoutUrl(checkoutUrl)) {
redirectToHostedCheckout(checkoutUrl);
return () => {};
}
let cancelled = false;
// Mount didn't report ready in time — show the inline retry. We do NOT
// redirect (stay-inline policy); the form may still be loading behind the
// skeleton, so the retry simply re-mounts it fresh.
const timer = window.setTimeout(() => {
if (cancelled || readyRef.current) return;
setFailed(true);
}, READY_DEADLINE_MS);
// Mark the embedded form alive: clear the skeleton + cancel the deadline.
// Idempotent — called from whichever signal arrives first.
const markReady = () => {
if (cancelled || readyRef.current) return;
window.clearTimeout(timer);
readyRef.current = true;
setReady(true);
setFailed(false);
};
void openDodoCheckoutInline({
checkoutUrl,
elementId,
// form_ready is NOT reliably delivered in inline mode — don't depend on it.
onReady: markReady,
onResize: (h) => {
if (cancelled) return;
// The first resize proves the iframe mounted + measured itself. Treat
// it as ready: without this, a missing form_ready leaves the skeleton up.
markReady();
setHeight(h);
},
onError: () => {
if (cancelled) return;
// checkout.error covers BOTH fatal init failures AND recoverable
// in-form errors (the SDK emits it for "Wallet payment failed" —
// Apple Pay declined / sheet dismissed). If the form already mounted
// it's recoverable: leave it up so the customer can retry the wallet
// inline — NEVER tear down a mounted form. Only a PRE-mount error
// means the form failed to load → show the inline retry (still
// on-domain, no auto-redirect).
if (readyRef.current) return;
window.clearTimeout(timer);
setFailed(true);
},
}).catch(() => {
// SDK import/open threw before mounting → inline retry, no redirect.
if (cancelled) return;
window.clearTimeout(timer);
setFailed(true);
});
return () => {
cancelled = true;
window.clearTimeout(timer);
// Close the SDK's checkout so the NEXT checkout in this session opens
// cleanly instead of silently no-oping with "Checkout is already open"
// (the SDK is a singleton tracking one open checkout).
closeDodoCheckout();
};
}, [checkoutUrl, elementId]);
useEffect(() => {
// Reset per (checkoutUrl, attempt) so a Retry re-mounts cleanly.
readyRef.current = false;
setReady(false);
setFailed(false);
return mount();
}, [mount, attempt]);
// Escape closes. Listener uses the ref so it never re-binds per render.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onCloseRef.current();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
return (
// Overlay — click on the backdrop (not the panel) closes.
<div
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="dialog"
aria-modal="true"
aria-label="Secure checkout"
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
background: "rgba(0, 0, 0, 0.5)", // swap for your design tokens
}}
>
{/* Skeleton pulse — plain CSS, no spinner. */}
<style>{`@keyframes checkout-pulse { 50% { opacity: 0.4; } }`}</style>
{/* Panel */}
<div
style={{
position: "relative",
width: "100%",
maxWidth: 480,
background: "#fff", // swap for your design tokens
border: "1px solid #e5e5e5",
borderRadius: 6,
padding: "40px 32px 32px",
}}
>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
position: "absolute",
top: 12,
right: 12,
border: "none",
background: "none",
cursor: "pointer",
fontSize: 18,
lineHeight: 1,
color: "#666",
}}
>
×
</button>
{/* Skeleton loader — pulsing bars, hidden once ready or failed. */}
{!ready && !failed && (
<div aria-hidden style={{ display: "flex", flexDirection: "column", gap: 12, padding: "16px 0" }}>
{[0, 1, 2, 3].map((i) => (
<div
key={i}
style={{
height: 44,
width: "100%",
border: "1px solid #eee", // swap for your design tokens
background: "#f5f5f5",
borderRadius: 4,
opacity: 1 - i * 0.12,
animation: "checkout-pulse 2s ease-in-out infinite",
}}
/>
))}
</div>
)}
{/* Inline failure — stay on-domain. Retry re-mounts the embedded form;
the manual link is a user-initiated escape, never an automatic
redirect. */}
{failed && (
<div role="alert" style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 16, padding: "32px 0", textAlign: "center" }}>
<p style={{ margin: 0, fontSize: 14, color: "#666" }}>
Loading secure checkout&hellip; Taking longer than expected.
</p>
<button
type="button"
onClick={() => setAttempt((n) => n + 1)}
style={{
padding: "12px 32px",
border: "none",
borderRadius: 4,
background: "#111", // swap for your design tokens
color: "#fff",
fontSize: 13,
cursor: "pointer",
}}
>
Try again
</button>
<button
type="button"
onClick={() => redirectToHostedCheckout(checkoutUrl)}
style={{
border: "none",
background: "none",
textDecoration: "underline",
fontSize: 12,
color: "#888",
cursor: "pointer",
}}
>
Open secure payment page
</button>
</div>
)}
{/* Mount target — the SDK embeds the checkout iframe here. minHeight
grows to the reported height once the form is ready. */}
<div
id={elementId}
style={{ minHeight: ready ? height : 0, transition: "min-height 0.4s ease" }}
/>
</div>
</div>
);
}
export default CheckoutModal;
// Dodo Payments checkout — INLINE (embedded) mode, on-domain, with a hosted
// fallback. The customer pays inside an iframe the SDK mounts into your own DOM
// element (Apple Pay is supported in inline/hosted mode, NOT in overlay mode —
// confirmed by Dodo support). On success the SDK emits a redirect event; with
// manualRedirect we own it and go straight to our own success page.
//
// The SDK is dynamically imported so it never touches SSR / static export
// (client-only). This module is the SINGLE place the SDK is initialized and
// mounted — keep it that way; the modal component is the one caller.
import type { CheckoutMode, ThemeConfig } from "dodopayments-checkout";
// Where we send the customer after a successful payment — your success page.
// A relative path is env-agnostic (works on preview and prod alike). The query
// param lets that page know it arrived from a completed checkout.
const RETURN_PATH = "/thanks?checkout_id=dodo";
// Fallback height for the inline mount before the iframe reports its real size.
const DEFAULT_INLINE_HEIGHT = 420;
// ─────────────────────────────────────────────────────────────────────────────
// THEME — literal hexes are REQUIRED. The payment form is Dodo's cross-origin
// iframe: it CANNOT read your CSS variables, so map your design tokens to hex
// here at build time. The SDK themes colors + radius (+ font size/weight) but
// NOT font family — the iframe can't load your fonts, so type stays Dodo's
// default. Neutral placeholder palette below — swap for your design tokens.
// ─────────────────────────────────────────────────────────────────────────────
const THEME: ThemeConfig = {
radius: "4px", // your --radius
light: {
bgPrimary: "#fafafa", // page background
bgSecondary: "#ffffff", // card / surface
borderPrimary: "#e5e5e5", // hairline borders
borderSecondary: "#d4d4d4",
// READABILITY: every text token that lands on the light bg MUST be a dark
// color, never white/near-white — Dodo's defaults can render light text on
// a light surface if you only theme the backgrounds.
textPrimary: "#171717", // near-black body text
textSecondary: "#525252", // muted text
textPlaceholder: "#737373", // still dark enough on the light bg
buttonPrimary: "#111111", // near-black primary pay button
buttonPrimaryHover: "#262626",
buttonTextPrimary: "#ffffff", // white — sits on the dark button, so it's correct
buttonSecondary: "#f5f5f5",
buttonSecondaryHover: "#e5e5e5",
buttonTextSecondary: "#171717",
inputFocusBorder: "#111111",
},
};
// The only hosts the Dodo inline SDK accepts. A non-Dodo URL (e.g. another
// payment provider's checkout in some environments) must NOT be fed to the
// SDK — it throws "URL must be a Dodo Payments checkout domain".
const DODO_CHECKOUT_HOSTS = ["checkout.dodopayments.com", "test.checkout.dodopayments.com"];
// Is this a Dodo checkout URL (→ embed inline) or something else (→ plain
// redirect)? Keeps the caller provider-agnostic: only Dodo embeds; anything
// else degrades to its own hosted page instead of crashing the SDK.
export function isDodoCheckoutUrl(url: string): boolean {
try {
return DODO_CHECKOUT_HOSTS.includes(new URL(url).hostname);
} catch {
return false;
}
}
// Navigating directly to the raw checkout URL loads the provider's HOSTED page
// (Dodo's hosted page supports Apple Pay). The bulletproof fallback for any
// inline failure, AND the path for non-Dodo URLs that we don't embed.
export function redirectToHostedCheckout(checkoutUrl: string): void {
window.location.assign(checkoutUrl);
}
let initialized = false;
// The imported SDK, kept so closeDodoCheckout() can reset between checkouts.
let _sdk: { Checkout: { open: (o: unknown) => void; close: () => void } } | null = null;
// Close any open Dodo checkout. The SDK is a singleton that tracks ONE open
// checkout; opening a SECOND time (e.g. two purchase flows in the same
// session) silently no-ops with "Checkout is already open" and the new iframe
// never mounts. Call this before opening, and on unmount, so each open is clean.
export function closeDodoCheckout(): void {
try {
_sdk?.Checkout.close();
} catch {
/* nothing open */
}
}
// Initialize's onEvent is global + registered once, but each mount needs its
// own callbacks. The global onEvent dispatches to whichever mount is currently
// active. Only one inline checkout is ever live at a time.
type ActiveMount = {
onReady?: () => void;
onResize?: (height: number) => void;
onError?: () => void;
};
let active: ActiveMount | null = null;
export async function openDodoCheckoutInline(opts: {
checkoutUrl: string;
elementId: string;
onReady?: () => void;
onResize?: (height: number) => void;
onError?: () => void;
}): Promise<void> {
const { checkoutUrl, elementId, onReady, onResize, onError } = opts;
try {
// Mode is inferred from the URL host (test.checkout… vs checkout…), so the
// client needs no public env var. One deployment is single-mode, so
// Initialize-once is sufficient.
const mode: CheckoutMode = new URL(checkoutUrl).hostname.startsWith("test.") ? "test" : "live";
const { DodoPayments } = await import("dodopayments-checkout");
_sdk = DodoPayments as unknown as typeof _sdk;
if (!initialized) {
DodoPayments.Initialize({
mode,
displayType: "inline",
onEvent: (e) => {
switch (e.event_type) {
case "checkout.redirect":
case "checkout.redirect_requested":
// SUCCESS — Dodo has NO checkout.success event; the redirect
// event IS success. With manualRedirect we own the navigation
// → go straight to our own success page.
window.location.assign(RETURN_PATH);
break;
// Any of these prove the embedded form is alive. form_ready alone
// is NOT reliably delivered in inline mode, so we also accept
// opened / payment_page_opened (and the caller should treat the
// first resize as ready too) — otherwise the loader hangs and any
// fallback timer fires for no reason.
case "checkout.form_ready":
case "checkout.opened":
case "checkout.payment_page_opened":
active?.onReady?.();
break;
case "checkout.resize": {
// Read the reported iframe height defensively (payload shape is
// Record<string, unknown>); fall back to the default minHeight.
const h = Number(e.data?.height);
active?.onResize?.(Number.isFinite(h) && h > 0 ? h : DEFAULT_INLINE_HEIGHT);
break;
}
case "checkout.error":
// NOTE: checkout.error also fires for RECOVERABLE in-form events
// after mount (e.g. a dismissed Apple Pay sheet) — the caller
// should only treat it as fatal before the form is ready.
console.error("Dodo checkout error", e.data);
active?.onError?.();
break;
default:
break;
}
},
});
initialized = true;
}
// Reset any checkout left open by a prior flow this session, else this open
// no-ops ("Checkout is already open") and the new form never mounts.
closeDodoCheckout();
active = { onReady, onResize, onError };
DodoPayments.Checkout.open({
checkoutUrl, // raw session url — the SDK builds the /inline URL + embeds the iframe
elementId, // REQUIRED for inline: id of the mounted <div> target
options: { showTimer: false, showSecurityBadge: true, manualRedirect: true, themeConfig: THEME },
});
} catch (err) {
// Any init/import/open failure → the caller falls back to hosted.
console.error("Dodo inline checkout failed to open", err);
onError?.();
throw err;
}
}
// server-create-checkout.ts — server-side Dodo Payments checkout session creation.
//
// Uses the official `dodopayments` server SDK (Stainless, fetch-based — works in
// Node and edge runtimes like Cloudflare Workers under nodejs_compat). Only the
// resulting `checkout_url` should ever reach the browser; the API key never does.
//
// Env:
// DODO_ACCESS_TOKEN — API key (Dodo dashboard → Developer → API Keys)
// DODO_SERVER — "test" | "live"
// APP_URL — your app's public origin, e.g. https://yourapp.com
// DODO_DEMO_PRODUCT_ID — optional; see ensureDemoProduct()
import DodoPayments from "dodopayments";
// Lazily built singleton — the SDK throws on a missing bearer token, so don't
// construct it at module load (import-time crash on unconfigured environments).
// The SDK derives the base URL from `environment` — no hardcoded hosts.
let _client: DodoPayments | null = null;
function client(): DodoPayments {
if (!process.env.DODO_ACCESS_TOKEN?.trim()) {
throw new Error("DODO_ACCESS_TOKEN is not set");
}
if (!_client) {
_client = new DodoPayments({
bearerToken: process.env.DODO_ACCESS_TOKEN.trim(),
environment: process.env.DODO_SERVER === "test" ? "test_mode" : "live_mode",
});
}
return _client;
}
export async function createCheckoutSession(opts: {
productId: string;
customerEmail: string;
customerName?: string;
metadata?: Record<string, string>;
}): Promise<{ url: string }> {
if (!process.env.APP_URL) throw new Error("APP_URL is not set");
// Some fields below (`minimal_address`, `feature_flags`) are honored by the
// POST /checkouts endpoint but not modeled in the SDK's params type, so the
// body is cast. Stainless serializes it verbatim (it never strips unknown
// keys), so the wire request carries them fine.
const checkout = await client().checkoutSessions.create({
product_cart: [{ product_id: opts.productId, quantity: 1 }],
// Explicitly allow the wallets you want. Without this Dodo falls back to a
// default set that OMITS Apple Pay. (Apple Pay also needs your domain
// verified with Apple via Dodo — host their association file at
// /.well-known/apple-developer-merchantid-domain-association and ask
// support if it still doesn't render.)
allowed_payment_method_types: ["apple_pay", "google_pay", "credit", "debit"],
// Identity passthrough — this metadata is echoed on the payment (and
// subscription) object in EVERY webhook, so your handler can resolve your
// own user/order with a one-field read. No mapping table needed.
metadata: opts.metadata ?? {},
customer: {
email: opts.customerEmail,
...(opts.customerName ? { name: opts.customerName } : {}),
},
// Keep the buyer's name editable on the form even when prefilled (Dodo
// locks attached-customer fields by default; this flag re-opens the input).
feature_flags: { allow_customer_editing_name: true },
// Collect only country + ZIP — the tax minimum for a Merchant of Record —
// instead of a full street/city/state form. Fewer fields, better conversion.
minimal_address: true,
// Returning customers see their saved card.
show_saved_payment_methods: true,
// Where the customer lands after payment. Your success page can key off
// the query param to show its own celebration UI.
return_url: `${process.env.APP_URL}/thanks?checkout_id=dodo`,
} as unknown as Parameters<DodoPayments["checkoutSessions"]["create"]>[0]);
if (!checkout.checkout_url) throw new Error("Dodo returned no checkout_url");
return { url: checkout.checkout_url };
}
// ── Demo helper — for demos/tests ONLY, not production code ──────────────────
// Lets this example run with nothing but a test API key: if you haven't
// pre-created a product, it creates a $10 one-time test product and returns its
// id. Set DODO_DEMO_PRODUCT_ID to skip creation (and avoid piling up products
// on repeated runs).
export async function ensureDemoProduct(): Promise<string> {
if (process.env.DODO_DEMO_PRODUCT_ID) return process.env.DODO_DEMO_PRODUCT_ID;
const product = await client().products.create({
name: "Demo — White-label Checkout",
tax_category: "saas",
price: {
type: "one_time_price",
currency: "USD",
price: 1000, // smallest denomination — $10.00
discount: 0,
purchasing_power_parity: false,
},
});
return product.product_id;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment