Easy Labs
PaymentsGuides

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/react or @easylabs/browser (frontend) installed.
  • Origin added to allowed_origins via client.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.
  • useEmbeddedCheckout is a React hook; in vanilla JS the equivalent signal is the onReady callback on mountEmbeddedCheckout.

On this page