Express Checkout: shipping in the Apple Pay and Google Pay sheets
Supply shipping options and confirm the shopper’s choice from inside the Apple Pay and Google Pay sheets.
New feature. Express Checkout shipping is available to all merchants. No allowlisting or account setup is required.What it covers today: Apple Pay and Google Pay on the Web SDK (Primer Checkout), Google Pay on the Android SDK (Checkout Components), and Apple Pay on the iOS SDK (Checkout Components).Because it is new, test your integration end to end in the sandbox before going live in production.
Apple Pay and Google Pay collect the shopper’s shipping address inside the native payment sheet, after checkout has already started. If your shipping rates depend on that address, you need to price them while the sheet is open.Express Checkout shipping does this with two callbacks. Your app or page supplies the shipping options for the address the shopper entered, and your backend confirms the option they pick. Primer then recalculates the order total from the confirmed shipping, so the amount stays authoritative on our side.
The shopper enters an address, or the sheet opens. The SDK asks your code for the shipping options for that address. The sheet stays in a loading state.
Your code returns the options. This is display only. Primer does not store the list and does not price anything from it.
An option is selected. The SDK hands you that option. This fires when the shopper picks one, and also for the option the sheet pre-selects, so expect it more than once per sheet session.
Your backend confirms it by PATCHing order.shipping onto the client session, then your code tells the SDK it is done.
Primer re-reads the session, checks that the confirmation matches the selection, recomputes order.amount, and refreshes the sheet total.
The shopper authorizes. Authorization waits for the confirmation, so a payment can never be created against a shipping option your backend has not priced.
The two callbacks are a pair. The address callback is display only and never changes the amount. The option callback is the one that moves money, and it goes through your backend.
An API key with client_tokens:write for the client session PATCH.
On iOS, a client session with no legacy SHIPPING checkout module. There a legacy module takes priority and the handlers are ignored. Web and Android do not read it.
Web
Android
iOS
Primer Checkout already integrated. See First payment.
@primer-io/primer-js1.9.2 or later. Earlier versions do not dispatch these events at all.
Apple Pay and/or Google Pay configured in the Dashboard.
io.primer:checkout3.0.0-beta.8 or later. Earlier versions do not have these handlers.
A physical device with Google Play services and a card in Google Wallet. None of this flow can be exercised in the emulator.
The SDK declares the Google Pay callback service in its own manifest and ships the matching keep rules, so there is nothing to add to your app’s manifest or ProGuard configuration.The SDK depends on com.google.android.gms:play-services-wallet20.0.0 and exposes it to your app. If your app declares an older version, Gradle resolves to 20.0.0.
A physical device with a card in Wallet to test the payment. The iOS Simulator shows the sheet and runs both handlers, but its payment token is not a real one, so the payment itself fails.
Shipping selection is off by default. Turn it on in the SDK options.
Web
Android
iOS
const checkout = document.querySelector('primer-checkout');checkout.options = { applePay: { // Apple Pay nests these under `shippingOptions`. shippingOptions: { requireShippingMethod: true, requiredShippingContactFields: ['postalAddress', 'name', 'email'], }, }, googlePay: { // Google Pay takes them at the top level. The nesting is not the same // as Apple Pay, so do not copy the block above. captureShippingAddress: true, requireShippingMethod: true, },};
The two wallets nest these options differently. Apple Pay reads applePay.shippingOptions.requireShippingMethod. Google Pay reads googlePay.requireShippingMethod, with no shippingOptions object. An option placed at the wrong level is ignored silently, and the sheet opens without a shipping selector.
Which shipping contact fields the shopper must provide.
googlePay.captureShippingAddress
Google Pay
Collects a shipping address in the sheet.
googlePay.requireShippingMethod
Google Pay
Shows the shipping option selector.
On Android the handlers turn shipping on. You do not set any shipping option in PrimerSettings: when you register onShippingAddressChange, the SDK asks the shopper for a shipping address, and when you also register onShippingOptionChange, the sheet shows the shipping options.
val settings = PrimerSettings( paymentMethodOptions = PrimerPaymentMethodOptions( googlePayOptions = PrimerGooglePayOptions( merchantName = "Example Store", // Optional: ask for the phone number too. The SDK asks for the address itself. shippingAddressParameters = PrimerGoogleShippingAddressParameters( phoneNumberRequired = true, ), ), ),)val checkout = rememberPrimerCheckoutController(clientToken, settings)
Handlers you register
Effect
onShippingAddressChange and onShippingOptionChange
The sheet asks for an address, shows your options and confirms the selection, as described here.
onShippingAddressChange only
The sheet asks for an address and shows no options. See below.
shippingAddressParameters still adds the phone number. requireShippingMethod is not needed.
If you register only onShippingAddressChange, the sheet shows no shipping options. The SDK still calls onShippingAddressChange for each address and ignores the options it returns. Use it when the total depends on the address, for example for tax. Patch the client session from your backend before you return, and the SDK re-reads the total for the sheet. onShippingOptionChange is not called, and authorization does not wait for a confirmation.
On iOS the handlers turn shipping on. You do not set any shipping option in PrimerSettings: when you set onShippingAddressChange, the SDK asks the shopper for a shipping address, and when you also set onShippingOptionChange, the sheet shows the shipping options.
let settings = PrimerSettings( paymentMethodOptions: PrimerPaymentMethodOptions( applePayOptions: PrimerApplePayOptions( merchantIdentifier: "merchant.com.example", merchantName: "Example Store", // Optional: other shipping contact fields. The SDK adds the postal address itself. shippingOptions: PrimerApplePayOptions.ShippingOptions( shippingContactFields: [.name, .emailAddress], // Required by the initializer. The handlers decide whether the sheet shows options. requireShippingMethod: false ) ) ))let session = PrimerCheckoutSession(clientToken: clientToken, settings: settings)
Handlers you set
Effect
onShippingAddressChange and onShippingOptionChange
The sheet asks for an address, shows your options and confirms the selection, as described here.
onShippingAddressChange only
The sheet asks for an address and shows no options. See below.
shippingOptions.shippingContactFields still adds other fields, for example name or email. The ShippingOptions initializer requires requireShippingMethod, but Checkout Components does not read it. Pass false.
If you set only onShippingAddressChange, the sheet shows no shipping options. The SDK still calls onShippingAddressChange for each address and ignores the options it returns. Use it when the total depends on the address, for example for tax. Patch the client session from your backend before you return, and the SDK re-reads the total for the sheet. onShippingOptionChange is not called, and authorization does not wait for a confirmation.
Respond to the address callback with the options you want the sheet to show.
Web
Android
iOS
Listen for primer:shipping-address-change and call setShippingOptions().
Call preventDefault() before any async work. The SDK treats the event as handled only if your listener calls preventDefault(), or calls setShippingOptions() synchronously. If you fetch rates from your backend, setShippingOptions() runs later, so preventDefault() is the only thing telling the SDK to wait. Without it the SDK moves on with no options and no shipping confirmation, no error is raised, and the shopper is charged the total without shipping. The same rule applies to continue() in Step 3.
primer:shipping-address-change is a CustomEvent. Its data is on event.detail.
Property
Type
Description
paymentMethodType
'APPLE_PAY' | 'GOOGLE_PAY'
Which wallet the shopper is using.
shippingAddress
NullableAddress
The address from the sheet, redacted by the wallet at this stage. Expect city, state, postal code and country. Street address, name, phone and email are not available until after authorization. Any field can be null.
setShippingOptions
(options: ShippingOption[]) => void
Call with the options to display. Call it once. Later calls are ignored.
timestamp
number
Unix timestamp in seconds, when the event was dispatched.
interface ShippingOption { id: string; // Stable identifier. Echoed back on selection, and // must match order.shipping.methodId when you confirm. name: string; // Display label, e.g. "Standard" description: string; // Display detail, e.g. "5-7 business days" amount: number; // Shipping cost in MINOR UNITS, e.g. 500 = £5.00}
Register onShippingAddressChange on the PrimerCheckoutController before the sheet opens and return the options. Registering right after you create the controller is the simplest way to guarantee the ordering.
The handler is a suspend function. The SDK calls it on the main thread, so suspend for your backend call and do not block. The two handlers never run at the same time, and a handler still running when the 20 seconds expire is cancelled.The SDK pre-selects one option and confirms it immediately: the one matching the client session’s current order.shipping.methodId if there is one, otherwise the first in your list. Your list order is kept.
data class PrimerShippingAddressChange( val paymentMethodType: String, val shippingAddress: PrimerAddress,)
Property
Description
paymentMethodType
Which wallet the shopper is using. "GOOGLE_PAY" today.
shippingAddress
The address from the sheet, redacted by the wallet at this stage. Expect countryCode, postalCode, city and state. Name and street lines are not available until after authorization. Any field can be null.
data class PrimerShippingOption( val id: String, // Echoed back on selection. Must match order.shipping.methodId. val name: String, // Display label, for example "Standard" val description: String, // Display detail, for example "5-7 business days" val amount: Int, // Shipping cost in MINOR UNITS, for example 500 = £5.00)
The sheet shows name and description. It does not show amount next to the option, so include the price in name or description if you want the shopper to see it before the total updates.
The SDK pre-selects one option and confirms it immediately: the one matching the client session’s current order.shipping.methodId if there is one, otherwise the first in your list. Apple Pay shows the pre-selected option first, so the SDK moves it to the top of the list.
public struct PrimerShippingAddressChange: Sendable { public let paymentMethodType: String public let shippingAddress: PrimerAddress}
Property
Description
paymentMethodType
Which wallet the shopper is using. "APPLE_PAY" today.
shippingAddress
The address from the sheet, redacted by Apple at this stage. Expect city, state, postal code and country. Street address, name, phone and email are not available until after authorization. Any field can be nil.
public struct PrimerShippingOption: Equatable, Sendable { public let id: String // Echoed back on selection. Must match order.shipping.methodId. public let name: String // Display label, for example "Standard" public let description: String // Display detail, for example "5-7 business days" public let amount: Int // Shipping cost in MINOR UNITS, for example 500 = £5.00}
The wallet gives you a partial address on purpose. If you price below postal code granularity, you cannot do it at this stage.Return an empty list to tell the shopper you do not ship to that address. The sheet shows an “address not serviceable” error and they can enter a different one. On Android this needs both handlers: with onShippingAddressChange alone the returned list is ignored.
When an option is selected, send it to your backend, which PATCHes it onto the client session, then tell the SDK you are done.
Web
Android
iOS
Listen for primer:shipping-option-change and call continue() once the PATCH has landed.
checkout.addEventListener('primer:shipping-option-change', (event) => { event.preventDefault(); // `continue` is a reserved word in JavaScript. Alias it when destructuring. const { selectedShippingOption, continue: confirmShipping } = event.detail; (async () => { const response = await fetch('/my-backend/commit-shipping', { method: 'POST', headers: { 'Content-Type': 'application/json' }, // Send the id only. Your backend resolves the price. body: JSON.stringify({ shippingOptionId: selectedShippingOption.id }), }); if (!response.ok) { // Do not call continue() here. The PATCH did not land, so the session // still holds the old shipping. Acknowledging anyway would let the // shopper reach authorization and fail there, which is harder to // understand. Log it and let the callback time out instead. console.error('Shipping PATCH failed, not confirming the selection'); return; } confirmShipping(); })();});
Your confirm endpoint can be called more than once during a single sheet session, including for the option the sheet pre-selects before the shopper touches anything. Make it idempotent.
The option that was selected, exactly as you supplied it.
continue
() => void
Acknowledge the confirmation. Reserved word — give it an alias, as in the example above.
timestamp
number
Unix timestamp in seconds, when the event was dispatched.
Register onShippingOptionChange and return once your backend has confirmed. Returning is the acknowledgement. Throw, or let the call fail, if the PATCH did not land.
LaunchedEffect(checkout) { checkout.onShippingOptionChange { change -> // Send the id only. Your backend resolves the price. myBackend.updateShipping(orderId, change.selectedShippingOption.id) }}
The handler runs for the pre-selected option as soon as options are supplied, and again each time the shopper picks a different one. Make your confirm endpoint idempotent.
data class PrimerShippingOptionChange( val paymentMethodType: String, val selectedShippingOption: PrimerShippingOption,)
Set onShippingOptionChange and return once your backend has confirmed. Returning is the acknowledgement. Throw if the PATCH did not land.
session.onShippingOptionChange = { change in // Send the id only. Your backend resolves the price. try await myBackend.commitShipping(optionId: change.selectedShippingOption.id)}
The handler runs for the pre-selected option as soon as options are supplied, and again each time the shopper picks a different one. Make your confirm endpoint idempotent.
The handlers are available on all three entry points. On PrimerCheckout and PrimerCheckoutPresenter, pass them at the call site as the last parameters:
Your backend PATCHes order.shipping onto the client session. The SDK never sets the shipping amount itself, so the shopper’s device cannot change the amount that is charged:
Do not send a top-level amount. It pins the order total and overrides Primer’s recalculation. The shopper is charged the total without shipping, and the shipping you just confirmed is dropped silently.
After you acknowledge, the SDK re-reads the client session and checks that order.shipping.methodId and order.shipping.amount match the option that was selected. If they match, the sheet total updates and the shopper can authorize. If they do not, the attempt fails with an integration error.On Android, the refreshed session reaches your app as PrimerCheckoutState.Ready, like any other client session update, so any state you derive from it picks up the new total.On iOS, a confirmation that does not match does not fail the attempt while the sheet is open. The sheet keeps the option your backend last confirmed. See When something goes wrong.On Android, a confirmation that does not match shows a recoverable error in the sheet. See When something goes wrong.
Each callback has 20 seconds to respond. Apple and Google also apply their own limit inside the sheet, which we do not control, so treat 20 seconds as a ceiling rather than a budget. Keep rate calls and confirmations fast.
Web
Android
iOS
Callback
If you do not respond in time
primer:shipping-address-change
The sheet shows a recoverable error and stays open. The shopper can try another address.
primer:shipping-option-change
The sheet shows a recoverable error, and the payment attempt then fails at authorization. The confirmation is not retried.
On Android the 20 seconds cover the whole Google Pay callback. On sheet open that is the address handler, the option handler and the client-session read together, so keep both handlers well inside it.
Handler
If you do not return in time, or throw
onShippingAddressChange
The sheet shows a recoverable error and stays open. The shopper can try another address. This includes confirming the pre-selected option, which runs in the same callback.
onShippingOptionChange
The confirmation is not recorded and the sheet shows a recoverable error. If the shopper still authorizes, the SDK blocks authorization and nothing is charged.
Handler
If you do not return in time, or throw
onShippingAddressChange
The sheet stays open. The shopper can try another address. This includes confirming the pre-selected option, which runs in the same address update.
onShippingOptionChange
The confirmation is not recorded. The sheet stays open and shows the option your backend last confirmed, so the shopper can pay for that option or pick again. Nothing is charged for the failed option.
Failures while the sheet is open are shown inside the sheet, and the shopper can recover without relaunching it:
An empty option list shows “We do not ship to this address”. The shopper can enter another address.
When the shopper picks an option and your handler throws, times out, or the confirmation does not match, the sheet shows “Something went wrong. Please try again.” The shopper can pick again.
When the same happens while the sheet opens or after an address change, the error shows on the address and no options are listed, because the pre-selected option could not be confirmed. The shopper can enter another address.
Authorization with no verified confirmation is blocked and nothing is charged.
While an address error is showing, the sheet’s Pay button stays enabled. If the shopper taps it, Google Pay rejects the payment with its own “Request failed” dialog, and the attempt ends as a failure in your app. Nothing is charged.Failures after the sheet closes surface as PrimerCheckoutState.Failure, like any other payment failure.
Failures while the sheet is open are handled inside the sheet:
An empty option list shows Apple’s “address not serviceable” error. The shopper can enter another address.
An address handler that throws or times out keeps the sheet open. The shopper can try another address.
When the shopper picks an option and its confirmation fails, the sheet stays open on the option your backend last confirmed. The shopper can pay for that option or pick again. A confirmation fails when the option handler throws or times out, or when the confirmation does not match the option.
When the pre-selected option fails to confirm after the sheet opens or the address changes, the sheet treats it like an address handler failure. The shopper can try another address.
Authorization with no verified confirmation fails in the sheet and nothing is charged. The SDK does not confirm the option again at this point.
If nothing responds, either because no listener is registered or because an async listener did not call preventDefault(), the SDK logs a console warning and carries on by itself. No options are shown, no shipping is confirmed, and the shopper is charged the total without shipping.If the amounts look wrong, check the browser console for a warning about primer:shipping-address-change or primer:shipping-option-change having no listener.
The SDK uses the handlers only when you register onShippingAddressChange. Without it, the SDK leaves the sheet alone and PrimerGooglePayOptions alone decides what the sheet asks for, as in drop-in. If you register onShippingOptionChange without onShippingAddressChange, neither runs and the SDK logs a warning. Pass null to either method to remove its handler.Registration timing. The SDK decides whether to use the handlers when it builds the Google Pay request, just before the sheet opens. Register in a LaunchedEffect keyed on the controller, or right after rememberPrimerCheckoutController, so the handlers are in place before the shopper can tap Google Pay.Repeated callbacks. Google Pay may call the SDK more than once for the same address, including on sheet open. The SDK collapses these and reuses the options and the confirmation it already has. The address handler runs only when the address differs from the last one it answered. With both handlers registered, the SDK then confirms the pre-selected option again. Treat both as idempotent anyway.
The SDK uses the handlers only when both of these are true:
You set onShippingAddressChange.
The client session has no legacy SHIPPING checkout module.
If one of them is false, the SDK leaves the sheet alone and uses the shipping options in the client session. The SDK logs a warning when a legacy SHIPPING checkout module overrides your handlers, and when you set onShippingOptionChange without onShippingAddressChange.The SDK checks this when the shopper taps Apple Pay. You can set the handlers on the session at any time before that.
If a payment fails and the shopper reopens the wallet sheet, the shipping flow starts again from the address change. Options supplied in a previous attempt are not reused.
If the shopper edits the address again before the previous confirmation has completed, that earlier confirmation is dropped and the flow restarts from the new address. Your backend can therefore receive a PATCH for an address the shopper has already moved on from. Always price from the address in the request you are handling, never from stored state.
requireShippingMethod is at the wrong nesting level for the wallet, or the client session carries a legacy SHIPPING checkout module.
Options never appear and no error is raised
Your async listener did not call preventDefault(), so the SDK moved on without waiting. Check the console for a “no listener” warning.
Authorization fails after the shopper picks an option
Your backend PATCHed a different methodId or amount than the option the SDK sent, or continue() was called before the PATCH landed.
The shopper is charged the pre-shipping total
The PATCH included a top-level amount, or no listener confirmed the selection.
Symptom
Likely cause
No shipping options in the sheet
onShippingOptionChange is not registered, or onShippingAddressChange is not registered.
The handlers never run
onShippingAddressChange is not registered, or it was registered after the sheet opened. If only onShippingOptionChange is registered, the log shows a warning.
”Something went wrong. Please try again.” in the sheet
Your handler threw, took longer than 20 seconds, or your backend PATCHed a different methodId or amount than the option the SDK sent.
Authorization fails after the shopper picks an option
The confirmation for the selected option was never verified. Check the PATCH response and timing on your backend.
Google Pay shows “Request failed” [OR_BIBED_06] after an error in the sheet
The shopper tapped Pay while an address error was showing. See When something goes wrong. Nothing was charged.
The shopper is charged the pre-shipping total
The PATCH included a top-level amount, which overrides Primer’s recalculation.
Works in debug, callbacks never arrive in release
The SDK ships consumer keep rules for the callback service. Check that your build does not override them with an aggressive R8 configuration.