cimplify
Checkout integration

Headless checkout

Drive the checkout API directly. No iframe, no Cimplify-rendered fields; every screen, every input, every microcopy is yours. Use this when the iframe's look or layout can't accommodate your brand, or when you're building a non-web surface (native app, kiosk, voice).

Two starting points

  • High-level: <CheckoutPage> from @cimplify/sdk/react, a full, opinionated checkout screen built from the regular React component primitives. Style it via Tailwind / classNames; ejectable.
  • Fully custom: Drive client.purchaseIntents from whatever UI you build. You render immutable terms and provider actions; the SDK owns confirm replay and polling.

Using <CheckoutPage>

CheckoutPage is a thin page shell around CimplifyCheckout. It keeps the hosted checkout authority and provider UI inside the Cimplify checkout element while letting you style the surrounding page. Use the fully custom purchase-intent flow below only when you need to render every field yourself.

import { CimplifyProvider, CheckoutPage } from "@cimplify/sdk/react";
import { createCimplifyClient } from "@cimplify/sdk";

const client = createCimplifyClient({
  publicKey: process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY!,
});

export default function Checkout() {
  return (
    <CimplifyProvider client={client}>
      <CheckoutPage />
    </CimplifyProvider>
  );
}

Style it with Tailwind classes on the wrapping container, override sub-components via the registry (cimplify add checkout-page to eject), or skip it entirely and go fully custom.

Fully custom

Mint freezes the cart into a durable intent and returns the immutable terms you must render. Confirmation names the exact revision, amounts, currency, and server-issued payment option the shopper accepted.

import {
  createPurchaseIntentConfirmation,
  MOBILE_MONEY_TENDER_AUTHORITY_KIND,
  ORDER_TYPE,
  PURCHASE_INTENT_ACTION_DECISION,
  PURCHASE_INTENT_CONFIRM_STATUS,
  PURCHASE_INTENT_PAYMENT_METHOD,
  PURCHASE_INTENT_RESOLUTION_KIND,
  PURCHASE_INTENT_SOURCE_KIND,
  purchaseIntentMobileMoneyNetwork,
  purchaseIntentRequiresPaymentOption,
  selectPurchaseIntentPaymentOption,
} from "@cimplify/sdk";

async function handleSubmit(form: MyFormState) {
  const cart = await client.cart.get();
  if (!cart.ok) throw cart.error;
  const momoNetwork = purchaseIntentMobileMoneyNetwork(form.momoProvider);
  if (form.paymentMethod === PURCHASE_INTENT_PAYMENT_METHOD.MOBILE_MONEY && !momoNetwork) {
    throw new Error("Unsupported Mobile Money network");
  }

  const minted = await client.purchaseIntents.mint({
    // Persist this key for ambiguous retries; do not generate one per call.
    client_intent_key: stableKey,
    source: { kind: PURCHASE_INTENT_SOURCE_KIND.CART, cart_id: cart.value.id },
    checkout: {
      customer: {
        name: form.name,
        email: form.email,
        phone: form.phone,
        save_details: form.rememberMe,
      },
      order_type: form.orderType,
      address_info: form.orderType === ORDER_TYPE.DELIVERY ? {
        street_address: form.street,
        city: form.city,
        region: form.region,
      } : {},
      tender_preference: form.paymentMethod === PURCHASE_INTENT_PAYMENT_METHOD.MOBILE_MONEY
        ? {
            method_kind: PURCHASE_INTENT_PAYMENT_METHOD.MOBILE_MONEY,
            authority: {
              authority_kind: MOBILE_MONEY_TENDER_AUTHORITY_KIND.TRANSIENT,
              phone_number: form.momoNumber,
              network: momoNetwork!,
            },
          }
        : { method_kind: PURCHASE_INTENT_PAYMENT_METHOD.CARD },
    },
  });
  if (!minted.ok || !minted.value.current_terms) {
    throw minted.ok ? new Error("Terms unavailable") : minted.error;
  }

  const terms = minted.value.current_terms;
  renderTerms(terms);
  const requiresRail = purchaseIntentRequiresPaymentOption(terms);
  const option = requiresRail
    ? selectPurchaseIntentPaymentOption(terms, { method_kind: form.paymentMethod })
    : null;
  if (requiresRail && !option) throw new Error("Selected tender is unavailable");

  const outcome = await client.purchaseIntents.resolve(minted.value.id, {
    confirmation: createPurchaseIntentConfirmation(terms, option, stableKey),
    onActionRequired: async (action) => {
      if (action.status === PURCHASE_INTENT_CONFIRM_STATUS.REDIRECT_REQUIRED) {
        window.location.assign(action.authorization_url);
        return PURCHASE_INTENT_ACTION_DECISION.STOP;
      }
      showProviderApproval(action);
      return PURCHASE_INTENT_ACTION_DECISION.CONTINUE;
    },
    // Returning null is fail-closed. Render replacement + deltas, then build
    // a new confirmation only after the shopper accepts them.
    onTermsChanged: async (changed) => {
      renderTermsReview(changed.replacement, changed.deltas);
      return null;
    },
  });
  if (!outcome.ok) throw outcome.error;

  if (outcome.value.kind === PURCHASE_INTENT_RESOLUTION_KIND.COMPLETED) {
    router.push(`/orders/${outcome.value.response.order_id}`);
  }
}

resolve replays the same durable confirmation while collection is processing; you do not poll an order endpoint yourself. It returns completed, action_required, review_required, outcome_unknown, or timed_out, so an ambiguous provider outcome is never mistaken for a failed payment or retried as a new purchase.

When rail_amount is zero, the terms deliberately contain no payment option; pass null to createPurchaseIntentConfirmation. A nonzero rail always requires one eligible, server-issued option.

Trade-offs vs Embedded Checkout

You getYou give up
Full visual / UX control.You implement OTP, address validation, payment provider error handling.
Native-app friendly (no iframe).You handle PCI / OTP compliance scope.
Server-side rendering / a11y / i18n on your terms.No automatic theme via Appearance API.
Custom telemetry hooks anywhere.You wire up the full lifecycle UI.

Next

On this page