Build a custom checkout
Drive the checkout lifecycle yourself when the embedded iframe isn't enough.
Goal
Build a checkout where you own the surrounding page composition, multi-step navigation, and post-payment logic, while delegating the actual card / bank-account entry to a securely hosted surface. The pattern: create the checkout session on your server, pass the client_secret to the browser, mount the iframe inside your own multi-step UI, and react to the iframe's lifecycle events. For most teams this is the right escape valve when Embed hosted checkout feels too constraining but going all the way to raw card tokenization is more compliance scope than you want to take on.
Prerequisites
- Easy Labs API key — see Quickstart.
@easylabs/node(server) and@easylabs/reactor@easylabs/browser(frontend) installed.- Origin added to
allowed_originsviaclient.updateEmbeddedCheckoutConfig. - A frontend that can manage multi-step state (your own framework / store).
Implementation
1. Server: create the session with your own line items
import { createClient } from "@easylabs/node";
const easy = await createClient({ apiKey: process.env.EASY_API_KEY! });
export async function POST(req: Request) {
const { cart, buyerEmail } = (await req.json()) as {
cart: Array<{ priceId: string; quantity: number }>;
buyerEmail: string;
};
const { data: session } = await easy.createEmbeddedCheckoutSession({
mode: "payment",
customer_email: buyerEmail,
line_items: cart.map((i) => ({ price_id: i.priceId, quantity: i.quantity })),
success_url: "https://your-app.com/checkout/success",
cancel_url: "https://your-app.com/checkout/cancel",
metadata: { source: "custom_checkout_v2" },
payment_methods: ["card"],
});
return Response.json({
clientSecret: session.client_secret,
sessionId: session.id,
amountTotal: session.amount_total,
currency: session.currency,
});
}2. Frontend: orchestrate your own steps, mount the iframe at the payment step
"use client";
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
useEmbeddedCheckout,
} from "@easylabs/react";
import { useState } from "react";
type Step = "review" | "payment" | "done";
export function CustomCheckout({ clientSecret }: { clientSecret: string }) {
const [step, setStep] = useState<Step>("review");
if (step === "review") {
return (
<ReviewStep onContinue={() => setStep("payment")} />
);
}
if (step === "payment") {
return (
<EmbeddedCheckoutProvider
config={{
clientSecret,
onSuccess: () => setStep("done"),
onClose: () => setStep("review"),
onError: (err) => alert(err),
}}
>
<CheckoutShell />
</EmbeddedCheckoutProvider>
);
}
return <DoneStep />;
}
function CheckoutShell() {
const { status } = useEmbeddedCheckout();
return (
<div>
{status === "loading" && <p>Preparing payment…</p>}
<EmbeddedCheckout
clientSecret={/* …passed in via props or context */ ""}
/>
</div>
);
}useEmbeddedCheckout() exposes the current status ("loading" | "ready" | "complete" | "error") so you can render skeletons, disable a parent "Pay now" CTA, or trigger analytics events as the iframe's lifecycle progresses.
3. Server: confirm and fulfill on the webhook
The browser callback is convenient for UI transitions but is not the source of truth. Use the checkout.session.completed webhook to fulfill the order:
import { EasyWebhooks } from "@easylabs/node";
app.post("/webhooks/easy", async (req, res) => {
const event = EasyWebhooks.constructEvent(
req.rawBody,
req.header("x-easy-webhook-signature") ?? "",
process.env.EASY_WEBHOOK_SECRET!,
);
if (event.type === "checkout.session.completed") {
// Look up your internal cart by session.metadata, then fulfill.
}
res.status(204).end();
});Tradeoffs
- The iframe still renders the actual payment fields — you own the page chrome and step navigation but not the form layout. If you need a fully custom card form rendered with your own components, contact your account team about the white-label tokenization SDK; it carries additional PCI scope.
- One session = one payment attempt. If the buyer changes their cart between steps, create a new session. Cache the session at the cart-hash level to avoid creating one per render.
useEmbeddedCheckoutis a React hook; in vanilla JS the equivalent signal is theonReadycallback onmountEmbeddedCheckout.