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.purchaseIntentsfrom 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 get | You 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
- checkout SDK reference: Purchase-intent source, immutable terms, option consent, and confirmation contracts
- CimplifyCheckout: When you want Cimplify-rendered checkout
Vanilla checkout
The framework-agnostic checkout surface. Use this from Vue, Svelte, plain HTML, or anywhere React wrappers are not the right fit. The same CimplifyElements controller backs `<CimplifyCheckout>`.
Appearance API
Cimplify checkout accepts an `appearance` object that controls theme, accent color, font, and corner radius. The same shape works for hosted Pay and embedded checkout.