Build a Complete Checkout
This tutorial walks through building a complete checkout flow from scratch. Copy-paste ready, with all gotchas inline.Prerequisites
- Khaime API key (
pk_live_xxxorpk_sandbox_xxx) - Next.js/React project (or adapt to your framework)
Step 1: Environment Setup
# .env.local
NEXT_PUBLIC_KHAIME_API_URL=https://api.khaime.com/api/v1
NEXT_PUBLIC_KHAIME_API_KEY=pk_sandbox_your_key_here
NEXT_PUBLIC_KHAIME_SDK_URL=https://js.khaime.com/v1/
Step 2: API Client
Create a reusable API client with proper error handling.// lib/khaime-api.ts
const API_URL = process.env.NEXT_PUBLIC_KHAIME_API_URL;
const API_KEY = process.env.NEXT_PUBLIC_KHAIME_API_KEY;
const headers = {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
};
export interface CartItem {
product_id: number;
quantity: number;
price: number;
has_variation: boolean;
product_image: string;
product_variant_data: string;
least_sub_variant_id?: string;
main_variant?: string;
}
export interface DeliveryDetails {
save_for_future_checkout: boolean;
store_pick_up: boolean;
national_delivery: boolean;
international_delivery: boolean;
address: Array<{
name: string;
email: string;
address: string;
city: string;
location?: string;
state: string;
zip_code: string;
country: string;
country_code: string;
}>;
}
/**
* Validates cart and calculates shipping
* Returns: cart_unique_id, shipping_fee, available_destinations
*/
export async function validateCart(
cart: CartItem[],
currency: string,
deliveryDetails: DeliveryDetails
) {
const response = await fetch(`${API_URL}/cart/validate`, {
method: 'POST',
headers,
body: JSON.stringify({
cart,
currency,
delivery_details: deliveryDetails,
}),
});
const data = await response.json();
if (!data.status) {
throw new Error(data.message || 'Cart validation failed');
}
return data;
}
/**
* Creates payment intent
* Returns: secret, publishable_key, amount_to_pay
*/
export async function createPaymentIntent(payload: {
cart_unique_id: string;
cart: CartItem[];
email: string;
first_name: string;
last_name: string;
currency: string;
product_title: string;
product_type: string;
order_total_charge: number;
delivery_details: DeliveryDetails;
}) {
const response = await fetch(`${API_URL}/payment/intent`, {
method: 'POST',
headers,
body: JSON.stringify({
...payload,
payment_type: 'one',
payment_by: 'customer',
is_coupon_used: false,
is_second_time_payment: false,
}),
});
const data = await response.json();
if (!data.status) {
throw new Error(data.message || 'Payment intent creation failed');
}
return data;
}
Step 3: Build Cart Items Correctly
This is where most errors happen. The fields differ based onhas_variation.
// utils/cart-helpers.ts
interface CartProduct {
id: number;
title: string;
price: number;
has_variation: boolean;
images: string[];
}
interface CartVariant {
id: string;
name: string;
price: number;
}
interface CartEntry {
product: CartProduct;
variant?: CartVariant;
quantity: number;
}
/**
* Builds cart items for API
* CRITICAL: Different fields for variant vs non-variant products
*/
export function buildCartItems(entries: CartEntry[]): CartItem[] {
return entries.map((entry) => {
const hasVariation = entry.product.has_variation;
// Base fields for ALL products
const item: CartItem = {
product_id: entry.product.id, // Must be number
quantity: entry.quantity,
price: entry.variant?.price ?? entry.product.price,
has_variation: hasVariation,
product_image: entry.product.images?.[0] || '',
// CRITICAL: Use 'none' for non-variant products
product_variant_data: hasVariation && entry.variant
? entry.variant.name
: 'none',
};
// ONLY add variant fields if has_variation is true
if (hasVariation && entry.variant) {
item.least_sub_variant_id = entry.variant.id;
item.main_variant = entry.variant.name;
}
return item;
});
}
Step 4: Price Formatting (Multicurrency Safe)
// contexts/CurrencyContext.tsx
import { createContext, useContext, useState, useCallback } from 'react';
interface CurrencyContextType {
currency: string;
exchangeRate: number;
formatPrice: (cents: number, skipConversion?: boolean) => string;
}
const CurrencyContext = createContext<CurrencyContextType | null>(null);
export function CurrencyProvider({ children }) {
const [currency, setCurrency] = useState('USD');
const [exchangeRate, setExchangeRate] = useState(1);
const baseCurrency = 'USD';
/**
* Formats price in cents to display string
*
* @param cents - Amount in cents
* @param skipConversion - TRUE for values already converted by API
* (shipping_fee, amount_to_pay, order totals)
*/
const formatPrice = useCallback((cents: number, skipConversion = false): string => {
// CRITICAL: Some API values are ALREADY converted
// Do NOT apply exchange rate to: shipping_fee, total_sum_with_shipping_fee, amount_to_pay
const amount = (currency === baseCurrency || skipConversion)
? cents
: Math.round(cents * exchangeRate);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
}).format(amount / 100);
}, [currency, exchangeRate, baseCurrency]);
return (
<CurrencyContext.Provider value={{ currency, exchangeRate, formatPrice }}>
{children}
</CurrencyContext.Provider>
);
}
export const useCurrency = () => {
const context = useContext(CurrencyContext);
if (!context) throw new Error('useCurrency must be used within CurrencyProvider');
return context;
};
Step 5: Complete Checkout Page
// app/checkout/page.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import Script from 'next/script';
import { useCart } from '@/contexts/CartContext';
import { useCurrency } from '@/contexts/CurrencyContext';
import { validateCart, createPaymentIntent, CartItem, DeliveryDetails } from '@/lib/khaime-api';
import { buildCartItems } from '@/utils/cart-helpers';
// Declare Khaime SDK types
declare global {
interface Window {
Khaime?: {
confirmPayment: (options: {
secret: string;
publishable_key: string;
stripe_account_id?: string;
amount?: number;
currency?: string;
display?: 'modal' | 'inline';
container?: HTMLElement;
onReady?: () => void;
onSuccess?: (result: unknown) => void;
onError?: (error: Error) => void;
}) => Promise<{ error?: { message: string } }>;
};
}
}
// Shipping destination from API
interface ShippingDestination {
rule_id: number;
rule_name: string;
destination_type: 'nationwide' | 'specific_states' | 'specific_locations' | 'international';
destination_locations?: string[];
shipping_fee?: number;
}
export default function CheckoutPage() {
const router = useRouter();
const { items, subtotal, clearCart } = useCart();
const { formatPrice, currency } = useCurrency();
// Flow state
const [step, setStep] = useState<'shipping' | 'shipping-options' | 'payment'>('shipping');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// API response data
const [cartUniqueId, setCartUniqueId] = useState<string | null>(null);
const [shippingFee, setShippingFee] = useState(0);
const [orderTotal, setOrderTotal] = useState<number | null>(null);
const [shippingOptions, setShippingOptions] = useState<ShippingDestination[]>([]);
const [selectedShippingRule, setSelectedShippingRule] = useState<number | null>(null);
// Payment state
const [showPaymentForm, setShowPaymentForm] = useState(false);
const [paymentCredentials, setPaymentCredentials] = useState<{
secret: string;
publishable_key: string;
stripe_account_id?: string;
amount: number;
currency: string;
} | null>(null);
const paymentContainerRef = useRef<HTMLDivElement>(null);
// Shipping form
const [shippingInfo, setShippingInfo] = useState({
email: '',
firstName: '',
lastName: '',
address: '',
city: '',
state: '',
postalCode: '',
country: 'US',
location: '', // For specific_locations rules
});
// ============================================================
// STEP 1: Submit shipping address, validate cart, get shipping
// ============================================================
const handleShippingSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const cartItems = buildCartItems(items);
const deliveryDetails: DeliveryDetails = {
save_for_future_checkout: false,
store_pick_up: false,
national_delivery: shippingInfo.country === 'US',
international_delivery: shippingInfo.country !== 'US',
address: [{
name: `${shippingInfo.firstName} ${shippingInfo.lastName}`,
email: shippingInfo.email.toLowerCase(),
address: shippingInfo.address,
city: shippingInfo.city,
location: shippingInfo.location || undefined,
state: shippingInfo.state,
zip_code: shippingInfo.postalCode,
country: shippingInfo.country,
country_code: shippingInfo.country,
}],
};
const response = await validateCart(cartItems, currency, deliveryDetails);
const data = response.data;
// Store cart ID for payment
setCartUniqueId(data.cart_unique_id);
// Extract shipping fee and total - ALREADY CONVERTED, don't convert again!
setShippingFee(data.shipping_fee || 0);
setOrderTotal(data.total_sum_with_shipping_fee);
// Check for shipping options
const shippingDetails = data.pricing_summary?.shipping_details;
const availableDestinations = shippingDetails?.available_destinations;
if (availableDestinations && availableDestinations.length > 1) {
// Multiple options - enrich with fees from matched_rules
const matchedRules = shippingDetails.matched_rules || [];
const feeMap = new Map(matchedRules.map(r => [r.rule_id, r.calculated_fee]));
const enriched = availableDestinations.map(dest => ({
...dest,
shipping_fee: feeMap.get(dest.rule_id),
}));
setShippingOptions(enriched);
setSelectedShippingRule(matchedRules[0]?.rule_id || enriched[0].rule_id);
setStep('shipping-options'); // Show shipping selector
} else {
setStep('payment'); // Go directly to payment
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Validation failed');
} finally {
setLoading(false);
}
};
// ============================================================
// STEP 2: Handle shipping option selection
// ============================================================
const handleShippingOptionSelect = (option: ShippingDestination) => {
setSelectedShippingRule(option.rule_id);
// Update shipping fee from selected option
if (option.shipping_fee !== undefined) {
setShippingFee(option.shipping_fee);
// Recalculate total (both values already converted)
const cartSubtotal = orderTotal ? orderTotal - shippingFee : subtotal;
setOrderTotal(cartSubtotal + option.shipping_fee);
}
// For specific_locations, store the location
if (option.destination_type === 'specific_locations' && option.destination_locations?.[0]) {
setShippingInfo(prev => ({ ...prev, location: option.destination_locations![0] }));
}
};
const handleConfirmShipping = () => {
setStep('payment');
};
// ============================================================
// STEP 3: Create payment intent
// ============================================================
const handlePayment = async () => {
setLoading(true);
setError(null);
try {
if (!cartUniqueId) {
throw new Error('Cart not validated. Please go back and try again.');
}
const cartItems = buildCartItems(items);
const firstProduct = items[0]?.product;
const deliveryDetails: DeliveryDetails = {
save_for_future_checkout: false,
store_pick_up: false,
national_delivery: shippingInfo.country === 'US',
international_delivery: shippingInfo.country !== 'US',
address: [{
name: `${shippingInfo.firstName} ${shippingInfo.lastName}`,
email: shippingInfo.email.toLowerCase(),
address: shippingInfo.address,
city: shippingInfo.city,
location: shippingInfo.location || undefined,
state: shippingInfo.state,
zip_code: shippingInfo.postalCode,
country: shippingInfo.country,
country_code: shippingInfo.country,
}],
};
const response = await createPaymentIntent({
cart_unique_id: cartUniqueId,
cart: cartItems,
email: shippingInfo.email.toLowerCase(),
first_name: shippingInfo.firstName,
last_name: shippingInfo.lastName,
currency,
product_title: firstProduct?.title || 'Order',
product_type: firstProduct?.product_type || 'physical_product',
order_total_charge: subtotal,
delivery_details: deliveryDetails,
// Note: Shipping rule is determined by `location` field during cart validation
});
const { secret, publishable_key, amount_to_pay, currency: paymentCurrency, data } = response.data;
// Update total from payment intent (source of truth)
setOrderTotal(amount_to_pay);
// Store credentials and show payment form
setPaymentCredentials({
secret,
publishable_key,
stripe_account_id: data?.stripe_account_id,
amount: amount_to_pay,
currency: paymentCurrency || currency,
});
setShowPaymentForm(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Payment failed');
} finally {
setLoading(false);
}
};
// ============================================================
// STEP 4: Initialize payment form when container is ready
// ============================================================
useEffect(() => {
if (!showPaymentForm || !paymentCredentials) return;
if (!paymentContainerRef.current) return;
if (!window.Khaime?.confirmPayment) return;
const initPayment = async () => {
const result = await window.Khaime!.confirmPayment({
secret: paymentCredentials.secret,
publishable_key: paymentCredentials.publishable_key,
stripe_account_id: paymentCredentials.stripe_account_id,
amount: paymentCredentials.amount,
currency: paymentCredentials.currency,
display: 'inline',
container: paymentContainerRef.current!,
onSuccess: () => {
clearCart();
router.push('/checkout/success');
},
onError: (err) => {
setError(err.message);
},
});
if (result.error) {
setError(result.error.message);
}
};
initPayment();
}, [showPaymentForm, paymentCredentials, clearCart, router]);
// ============================================================
// RENDER
// ============================================================
return (
<>
{/* Load Khaime SDK */}
<Script
src={process.env.NEXT_PUBLIC_KHAIME_SDK_URL}
strategy="afterInteractive"
/>
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Checkout</h1>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 p-4 rounded mb-6">
{error}
</div>
)}
{/* STEP 1: Shipping Form */}
{step === 'shipping' && (
<form onSubmit={handleShippingSubmit} className="space-y-4">
<h2 className="text-lg font-semibold">Shipping Address</h2>
<input
type="email"
name="email"
placeholder="Email"
value={shippingInfo.email}
onChange={(e) => setShippingInfo(prev => ({ ...prev, email: e.target.value }))}
required
className="w-full p-3 border rounded"
/>
<div className="grid grid-cols-2 gap-4">
<input
type="text"
placeholder="First Name"
value={shippingInfo.firstName}
onChange={(e) => setShippingInfo(prev => ({ ...prev, firstName: e.target.value }))}
required
className="p-3 border rounded"
/>
<input
type="text"
placeholder="Last Name"
value={shippingInfo.lastName}
onChange={(e) => setShippingInfo(prev => ({ ...prev, lastName: e.target.value }))}
required
className="p-3 border rounded"
/>
</div>
<input
type="text"
placeholder="Address"
value={shippingInfo.address}
onChange={(e) => setShippingInfo(prev => ({ ...prev, address: e.target.value }))}
required
className="w-full p-3 border rounded"
/>
<div className="grid grid-cols-3 gap-4">
<input
type="text"
placeholder="City"
value={shippingInfo.city}
onChange={(e) => setShippingInfo(prev => ({ ...prev, city: e.target.value }))}
required
className="p-3 border rounded"
/>
<input
type="text"
placeholder="State"
value={shippingInfo.state}
onChange={(e) => setShippingInfo(prev => ({ ...prev, state: e.target.value }))}
required
className="p-3 border rounded"
/>
<input
type="text"
placeholder="ZIP"
value={shippingInfo.postalCode}
onChange={(e) => setShippingInfo(prev => ({ ...prev, postalCode: e.target.value }))}
required
className="p-3 border rounded"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-black text-white py-3 rounded disabled:bg-gray-400"
>
{loading ? 'Calculating Shipping...' : 'Continue'}
</button>
</form>
)}
{/* STEP 2: Shipping Options (if multiple) */}
{step === 'shipping-options' && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Select Shipping Method</h2>
{shippingOptions.map((option) => (
<label
key={option.rule_id}
className={`flex justify-between p-4 border rounded cursor-pointer ${
selectedShippingRule === option.rule_id ? 'border-black bg-gray-50' : ''
}`}
onClick={() => handleShippingOptionSelect(option)}
>
<div>
<input
type="radio"
checked={selectedShippingRule === option.rule_id}
onChange={() => {}}
className="mr-3"
/>
<span className="font-medium">{option.rule_name}</span>
<span className="text-gray-500 ml-2">
({option.destination_type.replace(/_/g, ' ')})
</span>
</div>
{option.shipping_fee !== undefined && (
<span className="font-semibold">
{formatPrice(option.shipping_fee, true)}
</span>
)}
</label>
))}
<button
onClick={handleConfirmShipping}
className="w-full bg-black text-white py-3 rounded"
>
Continue to Payment
</button>
</div>
)}
{/* STEP 3: Payment */}
{step === 'payment' && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Payment</h2>
{/* Order Summary */}
<div className="bg-gray-50 p-4 rounded space-y-2">
<div className="flex justify-between">
<span>Subtotal</span>
<span>{formatPrice(subtotal)}</span>
</div>
<div className="flex justify-between">
<span>Shipping</span>
{/* CRITICAL: skipConversion=true for shipping */}
<span>{formatPrice(shippingFee, true)}</span>
</div>
<div className="flex justify-between font-bold border-t pt-2">
<span>Total</span>
{/* CRITICAL: skipConversion=true for total */}
<span>{formatPrice(orderTotal || subtotal, true)}</span>
</div>
</div>
{!showPaymentForm ? (
<button
onClick={handlePayment}
disabled={loading}
className="w-full bg-black text-white py-3 rounded disabled:bg-gray-400"
>
{loading ? 'Loading...' : `Pay ${formatPrice(orderTotal || subtotal, true)}`}
</button>
) : (
<div ref={paymentContainerRef} className="min-h-[200px]" />
)}
</div>
)}
</div>
</>
);
}
Key Takeaways
Cart Items
Use
'none' for product_variant_data on non-variant products. Only include main_variant and least_sub_variant_id when has_variation: true.Currency Conversion
shipping_fee, total_sum_with_shipping_fee, and amount_to_pay are ALREADY CONVERTED. Pass skipConversion: true to formatPrice.Shipping Options
Check
available_destinations.length > 1 after cart validation. Show selector before payment if multiple options.Payment Timing
Use
useEffect to call confirmPayment AFTER the container renders. Pass the DOM element, not an ID string.Common Errors Reference
| Error | Cause | Fix |
|---|---|---|
main_variant not allowed to be empty | Sent for non-variant product | Only include when has_variation: true |
product_variant_data is required | Missing field | Use 'none' for non-variant |
Container element not found | Called before render | Use useEffect with deps |
| Prices doubled (e.g., 145,800vs8,100) | Double-converted shipping | Use skipConversion: true |
cart_unique_id is undefined | Skipped cart validation | Always validate before payment intent |
