Checkout is where the cart becomes an order and money moves. AppEngine handles both ends — payment-intent creation against Stripe or PayPal, and order creation on the server — so the front end never holds a payment secret. The base-app checkout under src/app/checkout/ is the reference implementation.
The flow
The end-to-end path:
- 1
Compute final totals
Call
POST /storefront/discounts/calculatewith the current cart, coupon, and shipping address. It returns subtotal, discount, shipping, tax, and total. This is the number you charge. - 2
Pick a payment gateway
Call
GET /storefront/payment-gatewaysto list what the org has configured (Stripe, PayPal, Helcim, etc.). Render the matching button or form. - 3
Create a payment intent
For Stripe one-time:
POST /storefront/stripe/intent. For Stripe subscriptions:POST /storefront/stripe/subscription-session. For Stripe checkout-session:POST /storefront/stripe/checkout-session. PayPal config comes fromGET /storefront/paypal/config. - 4
Confirm payment client-side
Stripe.js or PayPal SDK confirms with the user's card. The vendor returns a payment reference.
- 5
Submit the order
POST /storefront/checkout-cartwith cart contents, addresses, and the payment reference. The server creates the order innewstate and returns it. Fire-and-forget side effects (email, automation triggers) run async.
Get configured payment gateways
/storefront/payment-gatewaysNo auth// base-app/src/app/checkout/page.tsx
const paymentGateways = await client.processRequest(
'get',
API_ENDPOINTS.STORE_CHECKOUT_PAYMENT_GATEWAYS,
);
The response lists each gateway with enabled, displayName, and a redacted config (publishable keys only — secret keys never leave the server).
Stripe — one-time payment
/storefront/stripe/intentNo authBody:
| Field | Type | Description |
|---|---|---|
| amount* | number | Total in the smallest currency unit (cents for USD). |
| currency* | string | ISO 4217 code ( |
| customerId | string | Customer SK if authenticated. Stripe customer is created or matched on the server. |
| metadata | object | Free-form. Use it to attach |
Response:
{
"clientSecret": "pi_3Nx...secret_a1b2",
"paymentIntentId": "pi_3Nx..."
}
Hand clientSecret to Stripe.js and let it confirm with the card details. Never log it.
// client side, after intent creation
import { loadStripe } from '@stripe/stripe-js';
const stripe = await loadStripe(publishableKey);
const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {
payment_method: { card: cardElement, billing_details: { email, name } },
});
if (error) throw error;
if (paymentIntent.status !== 'succeeded') throw new Error('Payment not completed');
// paymentIntent.id → pass to /storefront/checkout-cart as paymentRef
Stripe — subscription
/storefront/stripe/subscription-sessionNo auth/storefront/stripe/checkout-sessionNo authFor recurring billing, AppEngine creates a Stripe Checkout session on your behalf. Body includes priceId (the Stripe price), customerId, successUrl, and cancelUrl. The response is { sessionId, url } — redirect the browser to url and Stripe handles the rest. Webhooks update the AppEngine subscription record.
const { url } = await fetch('/api/storefront/stripe/subscription-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json', orgid: ORG_ID },
body: JSON.stringify({
priceId: 'price_1Nx...',
successUrl: `${origin}/account/subscriptions?ok=1`,
cancelUrl: `${origin}/checkout`,
}),
}).then(r => r.json());
window.location.href = url;
PayPal
/storefront/paypal/configNo authReturns the PayPal clientId and merchant settings. Drop them into the PayPal SDK on the front end. After PayPal returns an orderID, post it as paymentRef to /storefront/checkout-cart with paymentGateway: "paypal". Server-side capture happens during order processing.
Submit the order
/storefront/checkout-cartNo authThis is the call that creates the order. It verifies stock, computes final totals server-side, captures the payment if needed, and persists everything atomically.
| Field | Type | Description |
|---|---|---|
| items* | LineItem[] | Cart contents ( |
| customerInfo* | object |
|
| shippingAddress* | Address |
|
| billingAddress | Address | Defaults to |
| paymentRef* | string | The Stripe |
| paymentGateway* | string |
|
| subtotal* | number | |
| tax | number | |
| shipping | number | |
| discount | number | |
| discountCode | string | |
| total* | number | |
| affiliateCode | string | Auto-attached from the affiliate cookie if set; see |
// base-app/src/lib/storefront-api.ts (excerpt)
async submitCheckoutOrder(orderData: any): Promise<any> {
if (typeof window !== 'undefined') {
const { getAffiliateCode } = await import('@/lib/affiliate-tracking');
const affiliateCode = getAffiliateCode();
if (affiliateCode) orderData.affiliateCode = affiliateCode;
}
const response = await this.client.processRequest(
'post',
API_ENDPOINTS.STORE_CHECKOUT_CART,
orderData,
);
return response?.data;
}
The response is a BaseModel<Order> with data.orderNumber — bookmark this and redirect to /checkout/success?order=<orderNumber>.
Mixed carts
/storefront/checkout-mixedNo authSame body shape, plus per-item itemType: "product" | "rental" and the rental fields (startDate, endDate, rentalPeriod). The server splits products into a standard order and rentals into rental bookings, captures payment once, and returns both records.
Buy-now flow
/storefront/checkout-buy-nowNo authA shortcut for skipping the cart: post a single line item plus the same payment fields, get an order back. Useful for "buy now" buttons on a landing page.
Order confirmation page
The success page lives at /order/:author/:orderNumber in base-app. It calls:
/storefront/order/get/:author/:orderNumberNo authauthor here is the customer SK (or guest email for guest checkouts). The endpoint is @PublicRoute() for the success page to work without forcing sign-in, but it still requires a valid orderNumber + author pair — you can't enumerate orders.
Webhooks
Stripe and PayPal webhooks are handled inside the Connect/Upstream module. You don't wire them yourself: configure the credentials via the admin UI and AppEngine subscribes the right URLs, dispatches order updates (subscription renewal, refund, dispute) onto the order record, and emits events the Automation module can act on.
Use Stripe test keys in non-production orgs. The payment-gateways endpoint reflects whatever the org has configured — if you see a pk_live_... in dev, your config is wrong.
Verifying a payment manually
/storefront/verify-payment/:provider/:configId/:paymentIdJWTRoles(User) only — for staff dashboards that need to confirm a vendor-side payment after the fact (e.g. resolving a stuck order). Don't call from a public page.