Skip to main content

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.

Different from Payment Element checkout

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 paymentResult and 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:

  1. Configure the Stripe Payment Intents plugin in Centra.
  2. Implement a backend endpoint (e.g. /api/payment-configuration) that calls expressCheckoutWidgets and returns Stripe config to the frontend.
  3. 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:

FieldRole
publishableKeyStripe publishable key (pk_…) for Stripe.js
paymentMethodCentra payment method identifier (plugin URI) to use in API calls
captureMethodWhether the intent uses manual or automatic capture
countryShopper / session country (from checkout context), not the same as the country field inside stripeParameters on payment instructions for Payment Element.
languageCodeShopper language ISO code
shippingMethodsShipping options for express-style flows where applicable
phoneNumberRequiredWhether 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.

Frontend (TBD)

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-Secret header
  • 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:

Keep amount and line items aligned

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>
Deferred PaymentIntent creation

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:

EventStorefront API call
onShippingAddressChangeensureSelectionReady (may call mutation addItem) + mutation paymentInstructions with paymentInitiateOnly: true (partial address)
onShippingRateChangemutation setShippingMethod + query selection
onConfirmensureSelectionReady + 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 missing
  • mutation paymentInstructions with paymentMethod and paymentInitiateOnly: true — creates the PaymentIntent and returns clientSecret from formFields

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.

  1. onConfirm calls paymentInstructions to create a Payment Intent and return a clientSecret needed to complete the payment.
  2. stripe.confirmPayment() is called with return_url pointing to your confirmation page.
  3. If Stripe requires 3DS, it redirects the shopper to the authentication page automatically.
  4. After authentication, Stripe redirects back to your return_url with result query params.
  5. Your confirmation page calls paymentResult with 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 paymentResult correctly
  • Your failure page is reachable and offers a clear retry path
  • 3DS flow completes successfully and lands on the confirmation page