> ## Documentation Index
> Fetch the complete documentation index at: https://primer.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.

> [!NOTE]
>
> **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](https://primer.io/docs/checkout/primer-checkout/installation.md)), Google Pay on the **Android** SDK ([Checkout Components](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/installation.md)), and Apple Pay on the **iOS** SDK ([Checkout Components](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/installation.md)).
>
> Because it is new, **test your integration end to end in the [sandbox](https://primer.io/docs/testing/overview.md) 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.

## When to use it

Use it when shipping cost or availability depends on the destination and cannot be known when the client session is created. For example:

* Physical goods priced by weight, zone, or carrier rate.
* Free-shipping thresholds that vary by region.
* Shipping choices that depend on state held in your app or page, such as a selected store or a delivery window.

## How it works

1. **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.
2. **Your code returns the options.** This is display only. Primer does not store the list and does not price anything from it.
3. **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.
4. **Your backend confirms it** by PATCHing `order.shipping` onto the client session, then your code tells the SDK it is done.
5. **Primer re-reads the session**, checks that the confirmation matches the selection, recomputes `order.amount`, and refreshes the sheet total.
6. **The shopper authorizes.** Authorization waits for the confirmation, so a payment can never be created against a shipping option your backend has not priced.

> [!NOTE]
>
> **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.

## Prerequisites

On every platform:

* An [API key](https://primer.io/docs/api-reference/get-started/authentication.md) 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.

> [tab] Web
>
> * Primer Checkout already integrated. See [First payment](https://primer.io/docs/checkout/primer-checkout/first-payment.md).
> * `@primer-io/primer-js` **1.9.2** or later. Earlier versions do not dispatch these events at all.
> * Apple Pay and/or Google Pay configured in the [Dashboard](https://primer.io/docs/connections/payment-methods/apple-pay/overview.md).

> [tab] Android
>
> * `io.primer:checkout` **3.0.0-beta.8** or later. Earlier versions do not have these handlers.
> * Google Pay configured through [`PrimerGooglePayOptions`](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/common/primer-settings#primergooglepayoptions).
> * 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-wallet` **20.0.0** and exposes it to your app. If your app declares an older version, Gradle resolves to 20.0.0.

> [tab] iOS
>
> * Apple Pay already integrated. See [Apple Pay integration](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/guides/apple-pay-integration.md).
> * 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.

## Step 1. Enable shipping in the payment sheet

Shipping selection is off by default. Turn it on in the SDK options.

> [tab] Web
>
> ```typescript theme={"dark"}
> 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,
>   },
> };
> ```
>
> > [!WARNING]
> >
> > **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.
>
> | Option | Wallet | Effect |
> | - | - | - |
> | `applePay.shippingOptions.requireShippingMethod` | Apple Pay | Shows the shipping method selector in the sheet. |
> | `applePay.shippingOptions.requiredShippingContactFields` | Apple Pay | 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. |

> [tab] Android
>
> 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.
>
> ```kotlin theme={"dark"}
> 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.
>
> ### Address updates only
>
> 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.

> [tab] iOS
>
> 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.
>
> ```swift theme={"dark"}
> 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`.
>
> ### Address updates only
>
> 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.

## Step 2. Supply options for the address

Respond to the address callback with the options you want the sheet to show.

> [tab] Web
>
> Listen for `primer:shipping-address-change` and call `setShippingOptions()`.
>
> > [!WARNING]
> >
> > **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.
>
> ```typescript theme={"dark"}
> checkout.addEventListener('primer:shipping-address-change', (event) => {
>   // Tell the SDK to wait. Required whenever you respond asynchronously.
>   event.preventDefault();
>
>   const { shippingAddress, setShippingOptions } = event.detail;
>
>   (async () => {
>     const rates = await fetch('/my-backend/shipping-rates', {
>       method: 'POST',
>       headers: { 'Content-Type': 'application/json' },
>       body: JSON.stringify({ shippingAddress }),
>     }).then((r) => r.json());
>
>     setShippingOptions(rates);
>   })();
> });
> ```
>
> ### Event payload
>
> `primer:shipping-address-change` is a [CustomEvent](https://developer.mozilla.org/en-US/docs/Web/API/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. |
>
> ### The ShippingOption shape
>
> ```typescript theme={"dark"}
> 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
> }
> ```

> [tab] Android
>
> Register `onShippingAddressChange` on the [`PrimerCheckoutController`](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/primer-checkout-controller) before the sheet opens and return the options. Registering right after you create the controller is the simplest way to guarantee the ordering.
>
> ```kotlin theme={"dark"}
> LaunchedEffect(checkout) {
>     checkout.onShippingAddressChange { change ->
>         myBackend.shippingRates(change.shippingAddress).map { rate ->
>             PrimerShippingOption(
>                 id = rate.id,
>                 name = rate.name,
>                 description = rate.eta,
>                 amount = rate.amountInMinorUnits,
>             )
>         }
>     }
> }
> ```
>
> 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.
>
> ### Payload
>
> ```kotlin theme={"dark"}
> 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`. |
>
> ### The option
>
> ```kotlin theme={"dark"}
> 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.

> [tab] iOS
>
> Set `onShippingAddressChange` on the [`PrimerCheckoutSession`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-session) and return the options.
>
> ```swift theme={"dark"}
> session.onShippingAddressChange = { change in
>   let rates = try await myBackend.shippingRates(for: change.shippingAddress)
>   return rates.map {
>     PrimerShippingOption(
>       id: $0.id,
>       name: $0.name,
>       description: $0.eta,
>       amount: $0.amountInMinorUnits
>     )
>   }
> }
> ```
>
> 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.
>
> ### Payload
>
> ```swift theme={"dark"}
> 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`. |
>
> ### The option
>
> ```swift theme={"dark"}
> 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.

## Step 3. Confirm the selection from your backend

When an option is selected, send it to your backend, which PATCHes it onto the client session, then tell the SDK you are done.

> [tab] Web
>
> Listen for `primer:shipping-option-change` and call `continue()` once the PATCH has landed.
>
> ```typescript theme={"dark"}
> 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.
>
> ### Event payload
>
> | Property | Type | Description |
> | - | - | - |
> | `paymentMethodType` | `'APPLE_PAY' \| 'GOOGLE_PAY'` | Which wallet the shopper is using. |
> | `selectedShippingOption` | `ShippingOption` | 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. |

> [tab] Android
>
> 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.
>
> ```kotlin theme={"dark"}
> 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.
>
> ### Payload
>
> ```kotlin theme={"dark"}
> data class PrimerShippingOptionChange(
>     val paymentMethodType: String,
>     val selectedShippingOption: PrimerShippingOption,
> )
> ```

> [tab] iOS
>
> Set `onShippingOptionChange` and return once your backend has confirmed. Returning is the acknowledgement. Throw if the PATCH did not land.
>
> ```swift theme={"dark"}
> 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.
>
> ### Payload
>
> ```swift theme={"dark"}
> public struct PrimerShippingOptionChange: Sendable {
>   public let paymentMethodType: String
>   public let selectedShippingOption: PrimerShippingOption
> }
> ```
>
> ### Other entry points
>
> The handlers are available on all three entry points. On [`PrimerCheckout`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout) and [`PrimerCheckoutPresenter`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-presenter), pass them at the call site as the last parameters:
>
> ```swift theme={"dark"}
> PrimerCheckout(
>   clientToken: clientToken,
>   primerSettings: settings,
>   onShippingAddressChange: { change in try await myBackend.rates(for: change.shippingAddress) },
>   onShippingOptionChange: { change in
>     try await myBackend.commitShipping(optionId: change.selectedShippingOption.id)
>   }
> )
> ```

### The PATCH

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:

```bash theme={"dark"}
curl -X PATCH https://api.sandbox.primer.io/client-session \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "X-Api-Version: 2.4" \
  -H "Content-Type: application/json" \
  -d '{
    "clientToken": "YOUR_CLIENT_TOKEN",
    "orderId": "order-123",
    "order": {
      "shipping": {
        "methodId": "standard",
        "methodName": "Standard",
        "methodDescription": "5-7 business days",
        "amount": 500
      },
      "lineItems": [
        { "itemId": "sku-1", "description": "T-shirt", "amount": 2000, "quantity": 1 }
      ]
    }
  }'
```

> [!WARNING]
>
> **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.

### Tax

Primer recomputes the order total as:

```
sum over line items of (quantity x amount + taxAmount - discountAmount)
+ shipping.amount
+ any fees
```

If the destination changes the tax due, set `taxAmount` on each line item and re-send the full `lineItems` array in the same PATCH.

Two things to watch:

* `taxAmount` is added once per line. It is not multiplied by `quantity`.
* A top-level `order.taxAmount` is **not** included in the recalculated total. Use per line item tax, or a fee.

## Step 4. Primer verifies and refreshes

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`](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/primer-checkout-state), 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](#when-something-goes-wrong).

On Android, a confirmation that does not match shows a recoverable error in the sheet. See [When something goes wrong](#when-something-goes-wrong).

## Lifecycle and edge cases

### Timeouts

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.

> [tab] Web
>
> | 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. |

> [tab] Android
>
> 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. |

> [tab] iOS
>
> | 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. |

### When something goes wrong

> [tab] Web
>
> Failures in this flow surface on `primer:payment-failure`, like any other payment failure:
>
> ```typescript theme={"dark"}
> checkout.addEventListener('primer:payment-failure', (event) => {
>   const { error } = event.detail;
>   console.error(error.origin, error.code, error.message, error.suggestion);
> });
> ```
>
> * `error.origin` is `integration` when the fix is on your side, and `primer` when it is on ours.
> * `error.code` is `REQUEST_TIMEOUT` when a callback was not answered in time, and `REQUEST_ERROR` when the confirmation did not match the selection.
> * `error.suggestion` carries a short, actionable hint. Read it first.
>
> See [Log errors and debugging](https://primer.io/docs/checkout/primer-checkout/guides-and-recipes/log-errors-debugging.md) for the wider error model.

> [tab] Android
>
> 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`](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/primer-checkout-state), like any other payment failure.

> [tab] iOS
>
> 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.
>
> Failures after the sheet closes surface as [`PrimerCheckoutState.failure`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-state), like any other payment failure.

### No listener or handler responded

> [tab] Web
>
> 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.

> [tab] Android
>
> 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.

> [tab] iOS
>
> 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.

### Retries

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.

### Changing the address twice

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.

## Troubleshooting

> [tab] Web
>
> | Symptom | Likely cause |
> | - | - |
> | No shipping selector in the sheet | `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. |

> [tab] Android
>
> | 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](#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. |

> [tab] iOS
>
> | Symptom | Likely cause |
> | - | - |
> | No shipping options in the sheet | `onShippingOptionChange` is not set, or one of the conditions in [No listener or handler responded](#no-listener-or-handler-responded) is false. |
> | The handlers never run | `onShippingAddressChange` is not set, or the client session carries a legacy `SHIPPING` checkout module. Check the log for a warning. |
> | The sheet goes back to the previous option after the shopper picks one | 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. |
> | The shopper is charged the pre-shipping total | The PATCH included a top-level `amount`, which overrides Primer's recalculation. |

## See also

> [card] [Events reference](https://primer.io/docs/sdk/primer-checkout-web/events-reference.md)
>
> Full reference for the shipping events and every other checkout event

> [card] [PrimerCheckoutController](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/primer-checkout-controller.md)
>
> Where the two Android handlers live

> [card] [Apple Pay](https://primer.io/docs/connections/payment-methods/apple-pay/overview.md)
>
> Configure Apple Pay, including merchant identity and domain verification

> [card] [Google Pay](https://primer.io/docs/connections/payment-methods/google-pay/overview.md)
>
> Configure Google Pay

> [card] [Update client session](https://primer.io/docs/api-reference/v2.4/api-reference/client-session-api/update-client-session.md)
>
> API reference for the PATCH used to commit shipping

> [card] [PrimerSettings](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/common/primer-settings.md)
>
> Optional Android Google Pay settings, such as asking for the phone number

> [card] [PrimerCheckoutSession](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-session.md)
>
> Where the two iOS handlers live

> [card] [iOS Apple Pay integration](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/guides/apple-pay-integration.md)
>
> Where the iOS Apple Pay shipping options live


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.