Stripe integration
This guide covers Google Pay and Apple Pay through Stripe’s ExpressCheckoutElement, using query expressCheckoutWidgets and Storefront API payment instructions with a deferred PaymentIntent at wallet confirmation.
For a full checkout step built with Stripe Payment Element (cards, dynamic methods, your own form), use Stripe Payment Intents with Stripe Elements — Centra contract and client flow differ from the wallet sheet below.
What this integration provides
The reference implementation (StripeExpressCheckoutStandalone in the storefront accelerator) mounts both Google Pay and Apple Pay through Stripe's ExpressCheckoutElement and runs the payment flow through Storefront API payment instructions.
The key difference from a traditional Stripe integration is the deferred payment-intent pattern: no PaymentIntent is created until the shopper confirms payment in the wallet sheet. This avoids orphaned intents when shoppers cancel before completing the flow.
In your own project, create an express checkout component that follows the same flow described in this guide (configuration fetch, wallet rendering, shipping callbacks, and deferred payment intent initialization).
Page responsibilities
Your storefront should define pages for the final payment states:
- Success page: payment is complete, no further action is needed, and the page should load and render the order data.
- Confirmation page: Stripe redirects here after payment (including 3DS); you call
paymentResultand resolve the final payment outcome. - Failure page: payment was not processed correctly, and the page should display error handling and a clear way for the shopper to retry the payment flow.
Prerequisites
Before integrating:
- Configure the Stripe Payment Intents plugin in Centra.
- Implement a backend endpoint (e.g.
/api/payment-configuration) that callsexpressCheckoutWidgetsand returns Stripe config to the frontend. - Implement success, confirmation, and failure pages in your storefront.
Install packages:
npm install @stripe/stripe-js @stripe/react-stripe-js
Stripe Payment Intents widgets JSON
The widgets list on checkout / selection can include a Stripe Payment Intents entry whose contents parse to JSON (not HTML — no Centra-hosted payment UI). Express checkout uses this (with expressCheckoutWidgets and the steps below) for wallet metadata and related fields. A standard Payment Element checkout does not read widgets for the card step — it uses paymentInstructions only.
Typical widget contents fields include:
| Field | Role |
|---|---|
publishableKey | Stripe publishable key (pk_…) for Stripe.js |
paymentMethod | Centra payment method identifier (plugin URI) to use in API calls |
captureMethod | Whether the intent uses manual or automatic capture |
country | Shopper / session country (from checkout context), not the same as the country field inside stripeParameters on payment instructions for Payment Element. |
languageCode | Shopper language ISO code |
shippingMethods | Shipping options for express-style flows where applicable |
phoneNumberRequired | Whether billing phone is required for the current context |
Exact shape is available on the Selection / Checkout widgets list in the Storefront API (see Checkout → widgets). Match the widget entry for Stripe Payment Intents and parse contents as JSON.
Add a GraphQL fragment example for widgets { … on StripePaymentIntentsWidget { … } } (or the concrete type name in your schema) once finalized.
Integration steps
1) Fetch express checkout widget configuration
Request Stripe configuration from a backend endpoint before rendering the wallet button.
Backend requirements:
- authenticated shopper context
X-Shared-Secretheader- plugin type
express_checkout_stripe_payment_intents - request payload with amount, line items, and
returnUrl(your confirmation page URL)
The response provides:
type StripePaymentConfig = {
publishableKey: string;
currency: string;
country?: string;
captureMethod?: 'automatic' | 'automatic_async' | 'manual';
paymentAmount?: { amount: number; currency: string };
shippingMethods?: Record<
string,
{ id: string; displayName: string; amount: number }
>;
stripeParameters?: string | Record<string, unknown>;
};
Relevant API references:
The amount and line items sent to expressCheckoutWidgets are used for the initial Stripe Elements setup. They should reflect your current selection totals.
2) Initialize Stripe Elements with a placeholder amount
Wrap your express checkout component in a <Elements> provider. Use the current cart total as the initial amount — this is a placeholder that Stripe uses to display the wallet sheet before the actual PaymentIntent is created.
const stripePromise = loadStripe(publishableKey);
<Elements
stripe={stripePromise}
options={{
amount: cartTotalInMinorUnits,
captureMethod: captureMethod ?? 'manual',
currency: currency.toLowerCase(),
mode: 'payment',
}}
>
<YourExpressCheckoutElement />
</Elements>
No real PaymentIntent is created at this stage. Stripe defers the intent creation until the shopper confirms payment. Use elements.update({ amount }) in shipping callbacks to keep the displayed total in sync.
3) Render the ExpressCheckoutElement
Mount the element with the required options and wire up all event callbacks:
<ExpressCheckoutElement
options={{
allowedShippingCountries,
billingAddressRequired: true,
emailRequired: true,
lineItems: initialLineItems,
paymentMethods: { googlePay: 'always' },
phoneNumberRequired: true,
shippingAddressRequired: true,
shippingRates,
}}
onCancel={handleCancel}
onConfirm={handleConfirm}
onShippingAddressChange={onShippingAddressChange}
onShippingRateChange={onShippingRateChange}
/>
shippingRates passed here come from the initial config response and give the wallet sheet something to display before the shopper provides an address.
4) Handle wallet callbacks
onShippingAddressChange
onShippingAddressChange fires as the shopper selects a shipping destination in the wallet sheet. This is the first point at which you can ensure the selection is ready for purchase — call ensureSelectionReady at the top of this callback. In a PDP context it adds the product to the cart if it is missing; in a cart context it verifies the cart is non-empty.
// Adapt to your PDP / cart context
async function ensureSelectionReady(): Promise<CheckoutData> {
const checkoutData = await fetchCheckout();
if (itemId) {
// PDP context: add the product if it is not already in the selection
const alreadyInCart = checkoutData.lines.some(
line => line.item.id === itemId
);
if (!alreadyInCart) {
await addItem({ item: itemId });
}
return checkoutData;
}
// Cart context: selection must be non-empty
if (checkoutData.lines.length === 0) {
throw new Error('Selection is empty');
}
return checkoutData;
}
After the selection is ready, update the location and fetch updated totals and shipping methods:
const onShippingAddressChange = async ({ resolve, reject, address }) => {
try {
if (!allowedCountries.includes(address.country)) {
reject();
return;
}
await ensureSelectionReady();
const data = await paymentInstructions({
shippingAddress: {
address1: '',
city: address.city,
country: address.country,
zipCode: address.postal_code,
state: address.state,
},
paymentReturnPage: confirmationPageUrl,
paymentFailedPage: failurePageUrl,
paymentInitiateOnly: true,
});
const checkoutData = data.selection;
const amount = getGrandTotalInMinorUnits(checkoutData.checkout.totals);
elements.update({ amount });
resolve({
lineItems: mapToStripeLineItems(checkoutData.checkout.totals),
shippingRates: mapToStripeShippingRates(
checkoutData.checkout.shippingMethods
),
});
} catch {
reject();
}
};
onShippingRateChange
Triggered when the shopper selects a shipping method inside the wallet sheet:
const onShippingRateChange = async ({ resolve, reject, shippingRate }) => {
try {
await requestSetShippingMethod({ shippingMethod: shippingRate.id });
const checkoutData = await fetchCheckout();
const amount = getGrandTotalInMinorUnits(checkoutData.checkout.totals);
elements.update({ amount });
resolve({
lineItems: mapToStripeLineItems(checkoutData.checkout.totals),
shippingRates: mapToStripeShippingRates(
checkoutData.checkout.shippingMethods
),
});
} catch {
reject();
}
};
Event to API mapping:
| Event | Storefront API call |
|---|---|
onShippingAddressChange | ensureSelectionReady (may call mutation addItem) + mutation paymentInstructions with paymentInitiateOnly: true (partial address) |
onShippingRateChange | mutation setShippingMethod + query selection |
onConfirm | ensureSelectionReady + mutation paymentInstructions with paymentMethod and paymentInitiateOnly: true |
5) Deferred Payment Intent creation and confirm
onConfirm fires after the shopper authorizes the payment in the wallet sheet. This is where you call Centra backend, to create a Payment Intent and return a clientSecret needed to complete the payment:
const handleConfirm = async (
event: StripeExpressCheckoutElementConfirmEvent
) => {
const billingAddress = mapStripeBillingDetails(event.billingDetails);
const shippingAddress = mapStripeShippingAddress(event.shippingAddress);
// 1. Ensure selection is still ready (adds PDP item if missing)
const checkoutData = await ensureSelectionReady();
// 2. Resolve the Stripe payment method from checkout data and initiate payment
const stripePaymentMethod = checkoutData.checkout.paymentMethods.find(
m => m.kind === 'stripe_payment_intents'
);
const response = await paymentInstructions({
paymentMethod: stripePaymentMethod?.id,
shippingAddress,
separateBillingAddress: billingAddress,
paymentInitiateOnly: true,
paymentReturnPage: confirmationPageUrl,
paymentFailedPage: failurePageUrl,
});
const clientSecret = extractClientSecret(response); // from response.formFields.clientSecret
if (!clientSecret) {
event.paymentFailed({ reason: 'fail' });
return;
}
// 3. Confirm payment — Stripe handles 3DS and redirects to returnUrl
const { error } = await stripe.confirmPayment({
clientSecret,
confirmParams: { return_url: confirmationPageUrl },
elements,
});
if (error) {
event.paymentFailed({ reason: 'fail' });
}
};
Storefront API calls in this step:
ensureSelectionReady— fetches current selection; in a PDP context, adds the product if it is missingmutation paymentInstructionswithpaymentMethodandpaymentInitiateOnly: true— creates the PaymentIntent and returnsclientSecretfromformFields
The paymentMethod is resolved from the checkout data returned by ensureSelectionReady, so no separate setPaymentMethod call is needed. The response contains a FormPaymentAction with formType: 'stripe-payment-intents' and formFields including clientSecret and publishableKey.
6) Confirmation page
After stripe.confirmPayment() succeeds (or after 3DS), Stripe redirects the shopper to your return_url with Stripe query parameters appended (payment_intent, payment_intent_client_secret, redirect_status).
On the confirmation page, pass all query parameters directly to paymentResult:
const paymentMethodFields = req.query; // from URL query params set by Stripe
const result = await paymentResult({ paymentMethodFields });
if (result.type === 'PaymentResultSuccessPayload') {
// render order confirmation
} else {
redirect('/failure');
}
Example paymentResult mutation used on the confirmation page:
mutation paymentResult($paymentMethodFields: Map!) {
paymentResult(paymentMethodFields: $paymentMethodFields) {
type
... on PaymentResultSuccessPayload {
order {
number
orderDate
status
totals {
type
price {
value
formattedValue
currency {
code
}
}
}
}
}
userErrors {
message
path
}
}
}
Handling 3D Secure flow
Unlike Adyen, Stripe handles 3DS automatically inside stripe.confirmPayment() — no onAdditionalDetails callback is needed.
onConfirmcallspaymentInstructionsto create a Payment Intent and return a clientSecret needed to complete the payment.stripe.confirmPayment()is called withreturn_urlpointing to your confirmation page.- If Stripe requires 3DS, it redirects the shopper to the authentication page automatically.
- After authentication, Stripe redirects back to your
return_urlwith result query params. - Your confirmation page calls
paymentResultwith those params to resolve the final order state.
Operational hints
Element key reset
When the shopper cancels or payment fails, reset the ExpressCheckoutElement by incrementing its key prop. This ensures the wallet sheet re-initializes cleanly on the next attempt:
const handleCancel = () => {
setElementKey(k => k + 1);
};
Apply the same reset in the onConfirm error path before returning.
Keeping totals in sync
Call elements.update({ amount }) after every shipping address or method change. Stripe uses this value to display the correct total in the wallet sheet. If this call is skipped, the shopper sees a stale amount until confirmation.
Deferred initialization trade-offs
Because no PaymentIntent exists until onConfirm, cancelled flows leave no orphaned intents in Stripe. The downside is that onConfirm involves a Storefront API round-trip before Stripe can complete the payment. Keep your paymentInstructions endpoint fast to avoid shopper-visible latency at this step.
Wallet availability
If neither Google Pay nor Apple Pay appears, confirm:
- The publishable key is correct for the environment (test vs. live)
- The Stripe account has the payment methods enabled for the market
- The browser/device supports the wallet (Apple Pay requires Safari on Apple hardware or a configured test device)
Validation checklist
Before going live, verify:
- Google Pay can authorize and complete an order end-to-end
- Apple Pay can authorize and complete an order end-to-end
- Changing shipping address inside the sheet updates totals and available methods
- Changing shipping method inside the sheet updates the grand total
- Cancellation does not leave the selection in a broken state
- Your confirmation page is reachable, receives Stripe query params, and calls
paymentResultcorrectly - Your failure page is reachable and offers a clear retry path
- 3DS flow completes successfully and lands on the confirmation page