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

# Wallets

> Apple Pay, Google Pay and the wallets that follow them, with one integration on iOS, Android, React Native and the web.

A wallet order is a headless order paid on a wallet's own sheet, such as Apple Pay or Google Pay. Your backend quotes and creates it, the [Meld SDK](https://docs.meld.io/docs/stablecoins/headless-integration/sdks.md) presents the wallet's sheet, and settlement arrives by webhook. The calls are the same for every wallet and for every provider behind them: one create call, one `mount`, the same callbacks and webhooks. Your app hands each quote row and each order to the SDK, and the SDK decides how to present it, so your code has no provider or platform branches. The provider only changes what you set up once and what the customer needs before the order.

> [!NOTE]
>
> iOS SDK 0.10.0, Android SDK 0.8.0, React Native SDK 0.10.0 and Web SDK 0.7.0 are the releases that bring the wallet contract on this page: the readiness checks and the shared error codes. Where older releases behave differently, the section says so.

***

## What runs where

Apple Pay orders use `paymentMethodType: APPLE_PAY`, and Google Pay orders `GOOGLE_PAY`.

| Wallet | Provider | [iOS SDK](https://docs.meld.io/docs/stablecoins/headless-integration/sdks/ios.md) | [Android SDK](https://docs.meld.io/docs/stablecoins/headless-integration/sdks/android.md) | [React Native SDK](https://docs.meld.io/docs/stablecoins/headless-integration/sdks/react-native.md) | [Web SDK](https://docs.meld.io/docs/stablecoins/headless-integration/sdks/web.md) |
| - | - | - | - | - | - |
| Apple Pay | Mercuryo | Yes | No | On iOS | No |
| Apple Pay | Banxa | Yes | No | On iOS | Yes, in a browser with Apple Pay |
| Apple Pay | Stripe | Yes | No | On iOS | Not yet |
| Apple Pay | Coinbase | On iOS 16 or later | No | On iOS 16 or later | Not yet |
| Google Pay | Coinbase | No | 0.7.0 or later | On Android, from 0.9.0 | No |

The quote returns only the providers that have a headless route for that customer's country, currency and method. A row isn't a promise that the order will be accepted: it can still be refused when you create it. A Mercuryo Apple Pay order, for example, is refused with `400 INVALID_PAYMENT_METHOD` when Mercuryo doesn't confirm that its native sheet can open for that customer, as [Errors on order creation](#errors-on-order-creation) explains. Who can pay through each provider, and what they need before the order, is in [Providers](#providers).

Apple Pay never runs on Android, and Google Pay never runs on iOS or the web. Every SDK reports a combination it doesn't present as unavailable, so the [readiness checks](#1-check-the-device-then-quote) hide it without a platform check of yours.

***

## Providers

| Provider | Wallets | Customers | Before the order | Your setup | The customer sees |
| - | - | - | - | - | - |
| Mercuryo, Banxa | Apple Pay | Countries in the quote | [Unified KYC](https://docs.meld.io/docs/stablecoins/unified-kyc.md) | Merchant identifier, certificate from Meld's CSR | The Apple Pay sheet |
| Stripe | Apple Pay | US except New York, paying USD | An email on the customer. Stripe verifies the customer itself, so Unified KYC is optional: Meld fills Stripe's forms from it | Merchant identifier, certificate from Stripe's CSR | Stripe's Link sign-in and identity checks, then the Apple Pay sheet |
| Coinbase | Apple Pay, Google Pay | US, paying USD | A verified email and US mobile number, and Coinbase's agreements. At Coinbase's lifetime cap, [step-up KYC](#coinbase-guest-checkout-and-step-up-kyc) | Google Pay: your app's approval | The wallet's sheet |

[Eligibility](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/eligibility.md) tells you what a provider still needs for a given customer, so you don't have to hardcode the column above.

### Stripe

* **Credentials.** In production, Stripe orders run on your own Stripe account. Stripe onboards it for its embedded crypto onramp and registers your app as a trusted application. Then add your Stripe keys to your Meld account as in [provider setup](https://docs.meld.io/docs/stablecoins/for-all-products/service-provider-setup.md), together with the Link OAuth client ID, client secret and headless API version Stripe gives you, and point a Stripe webhook for `crypto.onramp_session.updated` at the webhook URL the Meld dashboard shows for the integration. In sandbox, Meld provides the Stripe integration: see [Headless testing](https://docs.meld.io/docs/stablecoins/sandbox-guide/headless-testing.md#apple-pay-with-stripe).
* **Link.** Stripe signs the customer in to Link with the email on the Meld customer, and sends the one-time code to the phone on that Link account.
* **Identity.** Stripe runs its own KYC in the SDK, after you create the order, and takes no Unified KYC share, so Meld doesn't refuse a Stripe order without Unified KYC. The SDK asks only for the details Stripe lists as missing: name and address first. Date of birth and SSN, and then an ID document and selfie in Stripe's own screens, come only when the order is over the customer's current Stripe limits or Stripe's risk checks call for them. When the customer's Unified KYC is approved, Meld fills those forms with their name, date of birth, US address and SSN, so they type less.
* **Cards.** `CREDIT_DEBIT_CARD` runs the same flow, with Stripe collecting the card. It needs no Apple setup.

### Coinbase

* **Credentials.** In production, Coinbase orders run on your own Coinbase Developer Platform project. See [provider credentials](https://docs.meld.io/docs/stablecoins/headless-integration.md#provider-credentials).
* **Merchant of record.** Coinbase takes the payment on its own payment page, and the SDK opens the wallet's sheet from it. You need no Apple setup.
* **Before the order.** Verify the email and phone with `serviceProvider: "COINBASEPAY"`, and record the customer's acceptance of Coinbase's agreements. Both are in [Eligibility](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/eligibility.md).
* **Limits.** Coinbase caps each customer weekly and over their lifetime. At the lifetime cap, the order is refused with `CUSTOMER_NOT_PAYMENT_READY` until the customer steps up with their date of birth and SSN. See [Coinbase guest checkout and step-up KYC](#coinbase-guest-checkout-and-step-up-kyc).
* **Quote.** Send `walletAddress` on the [headless quote](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/quotes.md), so Coinbase prices the same checkout the order uses.
* **Settlement.** Meld creates the transaction once Coinbase reports the payment, usually a minute or more after `onPaymentSubmitted`. An order the customer never pays sends no webhook.
* **Sandbox.** For Apple Pay, sandbox covers everything up to creating the order. The current SDKs can't present a sandbox Coinbase Apple Pay order (it ends in `PRESENTATION_FAILED`), so test the sheet in production with a small order. Google Pay runs in sandbox: see [Google Pay in sandbox](#google-pay-in-sandbox).

#### Google Pay with Coinbase

The SDK loads Coinbase's payment page out of sight and presses its Google Pay button once, so the customer sees only Google Pay's sheet, with Coinbase as the merchant.

* **Availability.** Google Pay rows appear in your quotes when Google Pay is live for the providers on your account. For Coinbase, Google Pay must also be enabled on your Coinbase project.
* **Order.** `serviceProvider: "COINBASEPAY"` and `paymentMethodType: "GOOGLE_PAY"`, as in [step 3](#3-check-eligibility-then-create-the-order). The order comes back with `headlessPresentation` `PROVIDER_HOSTED` / `COINBASE_PAYMENT_LINK` / `1`, which the SDK reads.
* **Clients.** Android SDK 0.7.0 or later, or React Native SDK 0.9.0 or later on Android. Prefer Android SDK 0.8.0 or React Native SDK 0.10.0, for the readiness checks and the Activity mount.
* **After `onPaymentSubmitted`.** You can unmount straight away. The SDK keeps Coinbase's page alive for up to 60 seconds, so Coinbase can finish confirming the payment.

The SDK reports the events of Coinbase's page (`onramp_api.*`) like this. The provider's event and code travel in `detail`.

| Coinbase reports | You get |
| - | - |
| `load_success` | `onReady` |
| `commit_success` or `polling_start` | `onStatusChange(pending)`, then `onPaymentSubmitted` |
| `cancel` | `onCancel` |
| `ERROR_CODE_GUEST_GOOGLE_PAY_NOT_SUPPORTED` | `onError(PAYMENT_METHOD_UNAVAILABLE)` |
| `ERROR_CODE_INIT` | `onError(ORDER_STATE_CHANGED)` |
| `commit_error` | `onError(PAYMENT_REJECTED)` |
| `polling_error` | `onError(PAYMENT_OUTCOME_UNKNOWN)` |
| Any other error, or no sheet within 8 seconds of the press (`detail` `presentation_deadline`) | `onError(PRESENTATION_FAILED)` |
| The session ends once the sheet may be up | `onError(PAYMENT_OUTCOME_UNKNOWN)` |

***

## Before you go live

[Create the customer](https://docs.meld.io/docs/stablecoins/unified-kyc/meld-kyces-the-user.md#step-1-create-a-meld-customer) and give them what the provider needs before the order (see [Providers](#providers)), and [configure webhooks](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/configure-webhooks.md). Then set up each provider and wallet you offer, once.

### Your own provider accounts

Every production wallet order runs on your own account at the provider, with credentials registered to your Meld account. The one exception today is the one-time-code check of a Coinbase customer's email and phone, which Meld signs with a Meld key. See [provider credentials](https://docs.meld.io/docs/stablecoins/headless-integration.md#provider-credentials).

* **Coinbase:** your own Coinbase Developer Platform project, with a Secret API key created with the ECDSA algorithm, and guest checkout and Google Pay enabled on the project.
* **Stripe:** your own Stripe account, onboarded for Stripe's embedded crypto onramp. See [Stripe](#stripe).
* **Mercuryo and Banxa:** your own account and keys, added as in [provider setup](https://docs.meld.io/docs/stablecoins/for-all-products/service-provider-setup.md).

### Apple Developer setup

Mercuryo, Banxa and Stripe present Apple Pay under your own merchant identifier, with a payment processing certificate made from that processor's CSR. Coinbase presents Apple Pay under its own merchant identifier, so it needs none of this.

**You will need:**

* An Apple Developer account with admin rights on your team
* The payment processing CSR, which your Meld representative provides. For Stripe, see [Stripe's certificate](#stripes-certificate)
* Xcode, to add the capability and regenerate provisioning profiles

**1. Create a merchant identifier**

A merchant identifier names your app to Apple Pay. It lives under your Apple Developer account, not Meld's.

1. Go to [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/identifiers/list)
2. Click **Identifiers** in the sidebar, then **+**
3. Select **Merchant IDs**, then **Continue**
4. Enter a description and an identifier — it must start with `merchant.` (e.g. `merchant.com.yourcompany.yourapp`)
5. Click **Continue**, review, then **Register**

If your app already has a merchant identifier, reuse it, unless another processor's certificate is on it. See [Supporting more than one provider](#supporting-more-than-one-provider).

Give your Meld representative the identifier for each environment you use. Meld configures it on your account, and your orders carry it to the SDK. Send it well before your first order in that environment.

**2. Create a payment processing certificate**

This certificate is what the Apple Pay token is encrypted to. It must be created from a CSR **Meld provides** — never generate your own for this step, because the processor holds the matching private key.

Once you have the `.certSigningRequest` file from your Meld representative:

1. Go to [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/identifiers/list)
2. Click **Identifiers**, filter by **Merchant IDs**, and select the one from Step 1
3. Under **Apple Pay Payment Processing Certificate**, click **Create Certificate**
4. Upload the `.certSigningRequest` file Meld provided
5. Click **Continue**, then **Download** to save the `.cer` file
6. Send the `.cer` file back to your Meld representative

Meld passes it to the processor. Wait for confirmation before going further — payments fail until the processor holds it.

**3. Configure Xcode**

1. Open your project in Xcode and select your app target
2. Go to the **Signing & Capabilities** tab
3. Click **+ Capability** and select **Apple Pay**
4. Under the Apple Pay capability, click **+** and add the merchant identifier from Step 1
5. Regenerate provisioning profiles

On React Native, the [Expo config plugin](https://docs.meld.io/docs/stablecoins/headless-integration/sdks/react-native.md#expo-config-plugin) does this for you — pass the same merchant identifier.

> [!NOTE]
>
> **Certificate expiry.** Apple payment processing certificates expire every 25 months. When yours approaches expiry, repeat Step 2 — Meld will provide a fresh CSR. An expired certificate fails every payment.

#### Supporting more than one provider

Use one merchant identifier per processor. Apple encrypts a merchant identifier's tokens to a single payment processing certificate, so two processors cannot share one. If you offer Apple Pay through two providers, run the setup once for each, with its own identifier.

Your app's Apple Pay entitlement must hold every identifier: add each in Step 3, or list each in the Expo plugin's `merchantIds`. The sheet does not open for an identifier the entitlement lacks.

#### Stripe's certificate

Stripe decrypts the token, so the certificate comes from a CSR issued by the Stripe account the order runs on:

* **Production:** your Stripe account. Download the CSR from the Stripe Dashboard's [iOS certificate settings](https://dashboard.stripe.com/settings/ios_certificates), and upload the `.cer` file Apple returns to Stripe, not to Meld.
* **Sandbox:** Meld's Stripe sandbox. Your Meld representative gives you the CSR, and you send them the `.cer` file.

Use a separate merchant identifier for each environment. Until Meld has configured it, Stripe Apple Pay orders in that environment fail with `400 PAYMENT_METHOD_NOT_SUPPORTED`.

#### Registering your domain — web only

On the web there is no native sheet: the button renders on **your** origin, so that origin has to be registered with the provider before Apple will let it present one.

Registration is not self-serve. **Contact your Meld representative with the domains you need registered** — Meld brokers each one with the provider, so allow for a round trip per domain, and do it before you plan to go live. Every domain you serve the button from needs its own registration, staging included.

Native-only integrations skip this entirely.

### Google Pay: your app's approval

Google approves each Android app that presents Google Pay in its production environment. The approval belongs to your app, so you apply for it yourself. Meld can't apply on your behalf.

**1. Publish your app on Google Play**

The Google Pay & Wallet Console lists only apps published on Google Play under the same Google account. Google Pay inside a WebView, which is how the SDK presents it, also needs a published app.

**2. Request production access**

In the [Google Pay & Wallet Console](https://pay.google.com/business/console), complete your business profile, open **Google Pay API**, and find your app under **Integrations**. Upload screenshots of your Google Pay flow and submit it for approval, as Google's [publishing guide](https://developers.google.com/pay/api/android/guides/test-and-deploy/request-prod-access) describes. Take the screenshots from a [sandbox order](#google-pay-in-sandbox), which needs no approval.

**3. Ship a release build**

Production Google Pay works only in a build signed with your release key. A debug-signed build can't run it, so test those with sandbox orders. A build with another application ID, such as a QA flavour, is a different app to Google and needs its own approval.

Until your app is approved, every production Google Pay order fails on Google's sheet with `OR_BIBED_11`. See [Troubleshooting](#troubleshooting).

### Devices

| Wallet | The customer needs |
| - | - |
| Apple Pay | In an app, an iPhone with a card in Wallet. Coinbase needs iOS 16 or later. On the web, a browser with Apple Pay, such as Safari, on a device with a card in Wallet |
| Google Pay | A physical Android phone with Android System WebView 137 or later, Google Play services 25.18.30 or later, and a card in Google Wallet that is set up for contactless payment on that phone |

Coinbase's Google Pay accepts only cards tokenized on the device (`CRYPTOGRAM_3DS`). An emulator can't hold one, and the cards in Google's test card suite aren't tokenized, so Google Pay never works on an emulator, even in sandbox. The iOS Simulator presents the Apple Pay sheet but can't produce a usable payment token.

***

## The integration loop

<video controls playsInline muted preload="metadata">
  <source src="https://mintcdn.com/meld-e276f676/EoS-uX7Yj1AgYhY_/images/headless/banxa-apple-pay.mp4?fit=max&auto=format&n=EoS-uX7Yj1AgYhY_&q=85&s=f03f34aa0566b878706a1d8852501619" type="video/mp4" />
</video>

An Apple Pay purchase, with the system sheet presented over the app

Your servers handle the order and never see payment credentials. The SDK presents whatever the order carries: the wallet's sheet itself, the provider's own SDK, or the provider's payment page.

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant App as Your app
    participant SDK as Meld SDK
    participant Api as Your backend
    participant Meld

    App->>SDK: paymentMethodReadiness, per wallet
    SDK-->>App: ready, unknown or unavailable
    App->>App: show each wallet that is not unavailable
    App->>Api: customer enters an amount
    Api->>Meld: POST /payments/crypto/quote?integrationMode=HEADLESS
    Meld-->>Api: rows, each with paymentMethodType and headlessPresentation
    Api-->>App: rows
    App->>SDK: presentationReadiness, per row
    SDK-->>App: ready, unknown or unavailable
    App->>App: show a button for each row that is not unavailable
    App->>Api: customer taps a wallet button
    Api->>Meld: GET /crypto/onramp/{provider}/requirements
    Api->>Meld: POST /crypto/order/headless/onramp
    Meld-->>Api: order
    Api-->>App: order, verbatim
    App->>SDK: capabilities, then mount
    SDK-->>App: the wallet's sheet
    SDK-->>App: onPaymentSubmitted
    Meld->>Api: webhooks, through TRANSACTION_CRYPTO_COMPLETE
    Note right of Api: credit the user here
```

### 1. Check the device, then quote

Before you quote, ask the SDK which wallets this device can pay with at all, so you show an entry point and quote only for those. Ask about every wallet you offer, on every platform. No provider or quote row is needed:

```kotlin Android theme={null}
listOf("APPLE_PAY", "GOOGLE_PAY").forEach { method ->
    Meld.paymentMethodReadiness(context, method) { readiness ->
        if (readiness !is MeldReadiness.Unavailable) showWallet(method)
    }
}
```

```swift iOS theme={null}
for method in ["APPLE_PAY", "GOOGLE_PAY"] {
    if case .unavailable = await Meld.paymentMethodReadiness(method) { continue }
    showWallet(method)
}
```

```tsx React Native theme={null}
for (const method of ['APPLE_PAY', 'GOOGLE_PAY']) {
  const { status } = await Meld.paymentMethodReadiness(method);
  if (status !== 'unavailable') showWallet(method);
}
```

```js Web theme={null}
for (const method of ['APPLE_PAY', 'GOOGLE_PAY']) {
  const { status } = await Meld.paymentMethodReadiness(method);
  if (status !== 'unavailable') showWallet(method);
}
```

It needs iOS SDK 0.10.0, Android SDK 0.8.0, React Native SDK 0.10.0 or Web SDK 0.7.0 or later. Each SDK answers for the wallets of its own platform, so the loop above needs no platform check:

| `paymentMethodType` | iOS SDK | Android SDK | Web SDK |
| - | - | - | - |
| `APPLE_PAY` | `ready` when Apple Pay isn't restricted and Wallet holds a card on a network one of the SDK's Apple Pay providers takes: Visa, Mastercard, American Express, Discover or Maestro, plus any you add to `StripeAPI.additionalEnabledApplePayNetworks`. Otherwise `unavailable` | `unavailable` | `ready` when the browser has Apple Pay (`ApplePaySession.canMakePayments()`), otherwise `unavailable`. It can't see the cards in Wallet |
| `GOOGLE_PAY` | `unavailable` | Google Pay's `isReadyToPay` for any Visa, Mastercard, American Express or Discover card: `ready` or `unavailable`. `unknown` when Google Play services is missing, out of date or slow. With the SDK configured for sandbox, no card answers `unknown`, not `unavailable` | `unavailable` |
| Any other, such as `CREDIT_DEBIT_CARD` | `unknown` | `unknown` | `unknown` |

The React Native SDK answers as the native SDK of the platform it runs on. `ready` here doesn't promise that every provider takes the customer's card, so check each row too, in step 2.

Then [get a quote](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/quotes.md) with `integrationMode=HEADLESS` and `Meld-Version: 2026-05-01`, for every order, not just the first one. Each headless row carries `paymentMethodType` and `headlessPresentation`, which says how that row's order would be presented. Pass the rows to your app as they are. Don't work out a row's presentation from its provider name. Name the chain with `destinationNetworkCode` on the quote request, and send the same value on the order.

### 2. Check each row on the device

Before you show a wallet button for a quote row, ask the SDK whether this device can pay with that row, through that row's provider:

```kotlin Android theme={null}
val p = row.headlessPresentation
val presentation = MeldHeadlessPresentation(p.surface, p.protocol, p.version)

Meld.presentationReadiness(context, presentation, row.paymentMethodType) { readiness ->
    when (readiness) {
        MeldReadiness.Ready, is MeldReadiness.Unknown -> offer(row)
        is MeldReadiness.Unavailable -> hide(row)
    }
}
```

```swift iOS theme={null}
guard let presentation = MeldHeadlessPresentation(json: row.headlessPresentation) else {
    return hide(row)
}

switch await Meld.presentationReadiness(presentation, paymentMethodType: row.paymentMethodType) {
case .ready, .unknown: offer(row)
case .unavailable: hide(row)
}
```

```tsx React Native theme={null}
const { status } = await Meld.presentationReadiness(row); // { paymentMethodType, headlessPresentation }
if (status === 'unavailable') hide(row);
else offer(row);
```

```js Web theme={null}
const { status } = await Meld.presentationReadiness(row); // { paymentMethodType, headlessPresentation }
if (status === 'unavailable') hide(row);
else offer(row);
```

It needs the same releases as step 1, and covers every row, cards included.

| Answer | Means | Do |
| - | - | - |
| `ready` | This SDK presents the row on this device, and the device can pay with it | Offer it |
| `unknown` | This SDK presents the row, but can't tell whether the device can pay. Every card row an SDK presents answers `unknown`, because nothing on the device decides it | Offer it. The payment has the final word |
| `unavailable` | This SDK doesn't present the row on this device, or the device can't pay with it. Its `code` is `PAYMENT_METHOD_UNAVAILABLE` | Hide it. No order exists, so there is nothing to track |

`unknown` and `unavailable` also carry a `reason`, worded the same on every SDK. Log it, and branch on the answer, not on the `reason`. For example:

| Case | `reason` |
| - | - |
| A method with no device check, such as a card | `This payment method has no device check; offer it and let the payment decide.` |
| Another platform's wallet | `Apple Pay is not available on Android.`, `Google Pay is not available on iOS.` or `Google Pay is not available on the web.` |
| No card the wallet can use | `Apple Pay has no usable card on this device.` or `Google Pay has no usable card on this device.` |

What each wallet row answers:

| Row | Answer |
| - | - |
| A row this SDK doesn't present: every combination [What runs where](#what-runs-where) marks No or Not yet, and Coinbase Apple Pay below iOS 16 | `unavailable` |
| Apple Pay through Mercuryo or Stripe, on iOS | `ready` when Apple Pay isn't restricted and Wallet holds a card on a network that provider's sheet asks for: Visa or Mastercard for Mercuryo; Visa, Mastercard, American Express, Discover or Maestro for Stripe. Otherwise `unavailable` |
| Apple Pay through Banxa or Coinbase, on iOS | `unknown` once Wallet holds a Visa, Mastercard, American Express, Discover or Maestro card, because the provider's merchant account decides which cards it takes. Otherwise `unavailable` |
| Apple Pay through Banxa, on the web | `unknown`: Banxa's component decides |
| Google Pay through Coinbase, on Android | Google Pay's `isReadyToPay` for the cards Coinbase takes: `ready` or `unavailable`. `unknown` when Google Play services is missing, out of date or slow. With the SDK configured for sandbox, no card answers `unknown`, not `unavailable` |

Readiness is advisory and moves no money. In an app, if you create the order anyway, the mounted order reports `PAYMENT_METHOD_UNAVAILABLE` when the device can't pay, and that is the final word. On the web, Banxa's component decides whether it shows Apple Pay.

Both checks answer once. On Android the callback runs on the main thread within 5 seconds. On iOS, the `async` form resumes on the caller's executor, so on the main actor when you await it from there, and the completion form runs on the main queue after the call returns. React Native and the web return a Promise.

On older releases, use `Meld.presentationCapabilities` (on iOS, `Meld.capabilities(for:paymentMethodType:)`) where the release has it: iOS SDK 0.8.0, Android SDK 0.7.0, and React Native SDK 0.8.0 on iOS or 0.9.0 on Android, or later. It answers only whether this build can present the row: hide the row when its `surface` is `unsupported`. A React Native binary built before 0.9.0 on Android, or before 0.8.0 on iOS, reports `unsupported` for every row, including the cards it can mount, so don't filter quote rows there. On those binaries, and on iOS SDK before 0.8.0, Android SDK before 0.7.0 or Web SDK before 0.7.0, which have no row check, offer the rows and check the order with `Meld.capabilities` after you create it.

### 3. Check eligibility, then create the order

[Check eligibility](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/eligibility.md) for the row's provider and collect whatever is outstanding. Then create the order with the row's values:

**Endpoint:** [`POST /crypto/order/headless/onramp`](https://docs.meld.io/api-reference/crypto/headless/crypto-order-headless-onramp-create)

**Headers:** `Meld-Version: 2026-05-01`, `X-Idempotency-Key: <uuid>`

```json theme={null}
{
  "customerId": "WmYYgvN8ukpV62N3m4u3ee",
  "externalOrderId": "order-5c2e7a",
  "serviceProvider": "COINBASEPAY",
  "paymentMethodType": "GOOGLE_PAY",
  "countryCode": "US",
  "sourceAmount": "50",
  "sourceCurrencyCode": "USD",
  "destinationCurrencyCode": "USDC_BASE",
  "destinationWalletAddress": "0x51FB80013111111111111112121111111",
  "destinationNetworkCode": "BASE",
  "clientIpAddress": "203.0.113.24"
}
```

Carry the quote's values through unchanged, and send the customer's public IP as `clientIpAddress`. Creating the order charges nothing.

The response has the same envelope for every wallet. This one is for a Mercuryo Apple Pay order, and `...` marks values elided here:

```json theme={null}
{
  "id": "WoRdErabcexample0000002",
  "customerId": "WmYYgvN8ukpV62N3m4u3ee",
  "externalOrderId": "order-3ba9f1",
  "paymentMethodType": "APPLE_PAY",
  "responseKind": "SESSION",
  "paymentMethodResponseDetails": {
    "presentation": "NATIVE_TOKEN",
    "sessionToken": "...",
    "merchantTransactionId": "...",
    "merchantIdentifier": "merchant.com.yourcompany.app"
  },
  "payload": { "...": "echo of your request" },
  "paymentActions": { "...": "read by the SDK" },
  "headlessPresentation": {
    "surface": "NATIVE_TOKEN",
    "protocol": "MELD_WALLET_TOKEN",
    "version": 1
  }
}
```

`paymentMethodResponseDetails` and `headlessPresentation` depend on the provider: a Stripe order carries a bootstrap for Stripe's SDK, and a Coinbase order a single-use link to Coinbase's payment page. You don't read them. Return the whole order to your app verbatim, and don't log it: it carries short-lived credentials for this order. The refusals are in [Errors on order creation](#errors-on-order-creation).

### 4. Mount from the customer's tap

```kotlin Android theme={null}
val order = MeldOrder.fromJson(responseJson) // your backend's response, unchanged
val capabilities = Meld.capabilities(order)
if (capabilities.surface == "unsupported") return offerAnotherMethod()

val handle = if (capabilities.embeddable) {
    Meld.mount(order, containerView, handlers)
} else {
    Meld.mount(order, activity, handlers) // Android SDK 0.8.0 or later
}
```

```swift iOS theme={null}
let order = try MeldOrder.from(jsonData: responseData) // your backend's response, unchanged
guard Meld.capabilities(for: order).surface != "unsupported" else { return offerAnotherMethod() }

let handle = try Meld.mount(order, applePay: MeldApplePayRequest(
    amount: 50.00,
    currencyCode: "USD",                  // must match the order
    email: customerEmail,                 // used only when the sheet returns none
    summaryItemLabel: "Acme — Buy USDC"
), handlers: handlers)
```

```tsx React Native theme={null}
const { surface } = await Meld.capabilities(order);
if (surface === 'unsupported') return offerAnotherMethod();

<MeldWidget
  order={order}
  applePay={{ amount: '50.00', currencyCode: 'USD', summaryItemLabel: 'Acme — Buy USDC' }}
  onPaymentSubmitted={() => showProcessing()}
  onCancel={() => backToCheckout()}
  onError={(e) => handleError(e)}
/>
```

```js Web theme={null}
import Meld from '@meldcrypto/sdk';

// The provider's component renders its own Apple Pay button and opens the sheet from
// that tap — Apple requires the gesture inside the frame holding the merchant session.
// Do not render a button of your own in front of it.
Meld.mount(order, document.getElementById('checkout'), {
  onReady:            ()  => hideSpinner(),
  onPaymentSubmitted: ()  => showProcessing(),
  onCancel:           ()  => backToAmount(),
  onError:            (e) => showError(e.message),
});
```

In an app, wallet orders report `embeddable: false`, because the SDK presents them itself and draws nothing into your views, and `requiresUserGesture: true`, so mount them from the customer's tap on your wallet button. On the web, the provider's component draws its own Apple Pay button into the element you pass, so the order reports `embeddable: true`: give the element room, and let the customer tap that button. Branch only on `surface == "unsupported"` and on `embeddable`: see [Surface is informational](#surface-is-informational).

In an app, pass the Apple Pay inputs for every Apple Pay order, whichever provider it routed to. They carry what the sheet shows that the order does not, they must match the order's amount and currency, and providers that don't need them ignore them. Google Pay takes nothing extra. On the web there is nothing to pass: the provider's component draws the button.

On iOS, the SDK checks the device again before it starts a new Apple Pay payment, whichever provider the order routed to. Where Apple Pay can't pay, `mount` still returns the handle, and `onError` follows with `PAYMENT_METHOD_UNAVAILABLE`, with no attempt behind it. On a Stripe order it checks once more just before Stripe collects the payment, after Link sign-in and Stripe's identity checks. An order that resumes an existing payment isn't checked.

Keep the handle, or on React Native the component, until a terminal callback. Unmounting before one tears the payment down with no callback.

On Android, [`Meld.mount(order, activity, handlers)`](https://docs.meld.io/docs/stablecoins/headless-integration/sdks/android#mount-the-order) keeps the payment for the life of the Activity, so it survives your views leaving their window. It needs Android SDK 0.8.0 or later. Before that, mount with a view, and keep that view in its window until a terminal callback: the payment ends when it leaves. React Native SDK 0.10.0 and later mount Android wallet orders on the Activity for you. On older releases, keep `<MeldWidget>` on screen until a terminal callback.

Where an SDK doesn't present a wallet row, as [What runs where](#what-runs-where) shows, the readiness checks answer `unavailable` and `Meld.capabilities` reports `unsupported`, so you never offer the button there.

### 5. Handle the callbacks

Wallet orders use the same [five events](https://docs.meld.io/docs/stablecoins/headless-integration/sdks.md#the-five-events) as every order, and each mount ends with exactly one terminal callback.

| Event | On a wallet order |
| - | - |
| `onReady` | In an app, the sheet was requested. Some providers report it before anything is on screen, so keep your busy state until a terminal callback. On the web, Banxa's component is ready and shows its Apple Pay button, and the sheet opens only when the customer taps it. Hide your spinner, and don't cover the component |
| `onPaymentSubmitted` | The customer authorised the payment. Unmount and show a processing state |
| `onCancel` | The customer backed out or dismissed the sheet. Return them to the amount screen |
| `onError` | Check its `code` against [Error codes](#error-codes) before you offer a retry |
| `onStatusChange` | Informational. Don't end your flow on it |

The transaction may not exist yet when `onPaymentSubmitted` fires. Meld creates it when the provider reports the payment, which for Coinbase is usually a minute or more later, and then sends its webhooks. Show a processing state, not a failure, until they arrive.

### 6. Settle on the webhook

`onPaymentSubmitted` means the customer authorised the payment. Authorisation is not settlement. Credit the customer when Meld's [`TRANSACTION_CRYPTO_COMPLETE` webhook](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/configure-webhooks) reaches your backend. No SDK event tells you the money moved, and an order the customer never pays sends no webhook.

The webhook payload includes `paymentTransactionId`, `paymentTransactionStatus`, `customerId`, `externalCustomerId` and `transactionType`. It doesn't include the order's `id`, so match it to the order as [Configure webhooks](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/configure-webhooks.md#correlating-an-event-to-your-order) describes.

***

## Coinbase guest checkout and step-up KYC

Coinbase's guest checkout lets a new customer buy with Apple Pay or Google Pay without Unified KYC: no date of birth, SSN, ID document or selfie. Coinbase caps how many guest purchases a customer can make. A customer who reaches that cap steps up once, with their date of birth and SSN. You collect them through [Unified KYC](https://docs.meld.io/docs/stablecoins/unified-kyc.md), and Meld passes Coinbase what it needs.

Start every customer as a guest, and run KYC only for those who reach the cap.

| When | The customer gives | You call |
| - | - | - |
| Before the first purchase | Their email and US mobile number, each confirmed with a one-time code, and their acceptance of Coinbase's agreements | [Eligibility](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/eligibility.md), with `serviceProvider: "COINBASEPAY"` on each verification. Then [the integration loop](#the-integration-loop) |
| Every later purchase | Nothing, until a verification or their acceptance goes stale, which eligibility reports | Eligibility, then the integration loop |
| At Coinbase's lifetime cap, once | Their date of birth and US SSN | The steps below: Unified KYC, then Coinbase's `limitUpgrade` process |

Coinbase's quote rows carry `kycMode: PROVIDER_MANAGED`, because Coinbase verifies the customer itself. So eligibility for Coinbase doesn't list `MELD_KYC_APPROVED` or `PROVIDER_KYC_SHARE`, and a guest order needs no Unified KYC.

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant App as Your app
    participant Api as Your backend
    participant Meld
    participant KYC as Sumsub page

    Api->>Meld: GET /crypto/onramp/COINBASEPAY/requirements
    Meld-->>Api: PROVIDER_EXTRA_KYC REQUIRED, process limitUpgrade
    Api->>Meld: POST /crypto/onramp/COINBASEPAY/processes/limitUpgrade, READ
    Meld-->>Api: REQUIRED, inputSources
    App->>Api: customer taps Verify identity
    Api->>Meld: START with CUSTOMER_KYC
    Meld-->>Api: 403 KYC_NOT_COMPLETED

    Note over App,KYC: Only while Meld has no date of birth and SSN for the customer
    Api->>Meld: POST /accounts/customers/{customerId}/kyc/initiate
    Meld-->>Api: url
    Api-->>App: url
    App->>KYC: customer completes KYC
    Meld->>Api: webhook CUSTOMER_KYC_STATUS_CHANGE, APPROVED
    App->>Api: customer taps Verify identity again
    Api->>Meld: START with CUSTOMER_KYC
    Meld-->>Api: PENDING

    loop every pollIntervalMs, up to pollTimeoutMs
        Api->>Meld: READ
    end
    Meld-->>Api: SATISFIED
    Api->>Meld: GET /crypto/onramp/COINBASEPAY/requirements
    Api->>Meld: POST /crypto/order/headless/onramp, the same request
```

### Coinbase's limits

Coinbase puts two limits on each guest, keyed on their verified phone number. It sets the numbers itself, so read them from the process's [`limits`](#2-read-the-process) rather than hardcoding them.

| Limit | When the customer reaches it | What you do |
| - | - | - |
| Lifetime purchases | Eligibility reports `PROVIDER_EXTRA_KYC` as `REQUIRED`, and Meld refuses the order with `422 CUSTOMER_NOT_PAYMENT_READY` before it reaches Coinbase | Step the customer up, as below |
| Weekly spending | Coinbase refuses the order itself, as `422 HEADLESS_PROVIDER_REJECTED`, and that order is spent | Nothing raises it. Offer a smaller amount, another provider, or a later try. Don't send the customer to KYC |

An approved upgrade lifts the lifetime cap only, not the weekly one. If Meld can't read Coinbase's limits, it lets the order through and Coinbase enforces both limits itself, so a capped order then also ends in `422 HEADLESS_PROVIDER_REJECTED`.

### 1. Spot the cap

Check eligibility before every Coinbase order, as step 3 of the loop already does. Send the whole order: `customerId`, `paymentMethodType`, `sourceCurrencyCode`, `sourceAmount`, `destinationCurrencyCode` and `countryCode`, and the `destinationNetworkCode` you quoted with. If any of the first six is missing, the response says nothing about the cap.

```
GET /crypto/onramp/COINBASEPAY/requirements
  ?customerId=WmYYgvN8ukpV62N3m4u3ee
  &paymentMethodType=APPLE_PAY
  &sourceCurrencyCode=USD
  &sourceAmount=50
  &destinationCurrencyCode=USDC_BASE
  &destinationNetworkCode=BASE
  &countryCode=US
```

For a customer at the cap, `kycRequirements` holds:

```json theme={null}
{
  "code": "PROVIDER_EXTRA_KYC",
  "status": "REQUIRED",
  "processes": ["limitUpgrade"],
  "reason": "Coinbase's lifetime transaction limit has been reached; identity verification is required to request an upgrade",
  "processActions": [
    {
      "code": "limitUpgrade",
      "type": "LIMIT_UPGRADE",
      "protocol": "MELD_ONRAMP_PROCESS_V1",
      "endpoint": "/crypto/onramp/COINBASEPAY/processes/limitUpgrade",
      "method": "POST",
      "pollIntervalMs": 2000,
      "pollTimeoutMs": 30000,
      "automaticSubmissionRetryAllowed": false
    }
  ]
}
```

`processActions` describes the process: where to call it, and how often to poll. Read those values from the response. The top-level `availableProcesses` lists the same `limitUpgrade` on every Coinbase response, whether the customer needs it or not, so don't treat its presence as a requirement.

| `PROVIDER_EXTRA_KYC` | Means | Do |
| - | - | - |
| `SATISFIED` | The customer is under the lifetime cap, or their upgrade is active | Create the order |
| `REQUIRED` | The customer is at the cap, and Coinbase will take an upgrade | Steps 2 to 5 |
| `PENDING` | Coinbase is reviewing the customer's upgrade. `processStates` shows `limitUpgrade` as `PENDING` | Tell the customer their verification is under review. Check again later, and don't start the process again |
| `BLOCKED` | The customer is at the cap, and Coinbase offers no upgrade | Offer another provider |
| No entry | Meld couldn't ask Coinbase, for example because the customer's phone isn't verified yet | Carry on. Meld checks the cap again when you create the order |

If you create the order anyway, Meld refuses it before anything is reserved:

```json theme={null}
{
  "code": "CUSTOMER_NOT_PAYMENT_READY",
  "message": "Coinbase's guest-checkout lifetime transaction limit blocks this order. No order was created: read GET /crypto/onramp/COINBASEPAY/requirements, complete the limitUpgrade process it declares, then resend the same request.",
  "errors": ["limitUpgrade: REQUIRED"]
}
```

`errors` holds `limitUpgrade: REQUIRED`, `limitUpgrade: PENDING` or `limitUpgrade: BLOCKED`, which you handle as in the table. Nothing was created or charged, so after the upgrade you can resend the same request, with the same `externalOrderId`.

### 2. Read the process

Run the process from your backend, with the customer and the same order you checked. `READ` changes nothing:

**Endpoint:** `POST /crypto/onramp/COINBASEPAY/processes/limitUpgrade`

```json theme={null}
{
  "customerId": "WmYYgvN8ukpV62N3m4u3ee",
  "order": {
    "paymentMethodType": "APPLE_PAY",
    "sourceCurrencyCode": "USD",
    "sourceAmount": 50,
    "destinationCurrencyCode": "USDC_BASE",
    "destinationNetworkCode": "BASE",
    "countryCode": "US"
  },
  "operation": "READ"
}
```

Response:

```json theme={null}
{
  "process": { "code": "limitUpgrade", "...": "the descriptor from processActions" },
  "status": "REQUIRED",
  "inputSources": ["HOSTED_FORM", "CUSTOMER_KYC"],
  "pollable": true,
  "limits": [
    { "type": "SPENDING", "period": "WEEK", "currency": "USD", "limit": "...", "remaining": "...", "limitUnlimited": false, "remainingUnlimited": false },
    { "type": "TRANSACTION_COUNT", "period": "LIFETIME", "limit": "15", "remaining": "0", "limitUnlimited": false, "remainingUnlimited": false }
  ]
}
```

`limits` is what the customer has left: weekly spending in `currency`, and lifetime purchases as a count. When `limitUnlimited` or `remainingUnlimited` is `true`, Coinbase sets no number for it, so ignore the matching value.

| `status` | Means | Do |
| - | - | - |
| `REQUIRED` | Coinbase will take the customer's identity | Start the process with one of `inputSources`, in step 3 |
| `PENDING` | Coinbase is reviewing it | Poll, as in step 5 |
| `SATISFIED` | The upgrade is active | Check eligibility, then create the order |
| `BLOCKED` | Coinbase won't raise this customer's limit | Offer another provider |
| `UNAVAILABLE`, with `reason: NOT_OFFERED` | Coinbase offers this customer no upgrade, for example before their first guest purchase | Nothing to start |
| `UNKNOWN`, with `reason: STATE_UNAVAILABLE` | Meld couldn't read Coinbase's answer | Read again later. Don't start |

`status` says whether Coinbase would take an upgrade, which it can offer before the customer reaches the cap. Decide whether the customer must step up from eligibility, in step 1, not from `READ`.

`inputSources` lists how the customer can give Coinbase their identity:

* **`CUSTOMER_KYC`:** Meld reads the date of birth and SSN from the customer's Unified KYC, and sends Coinbase the date of birth and the SSN's last four digits. The customer types nothing more. Meld offers it only when Coinbase asks for exactly those two fields.
* **`HOSTED_FORM`:** Coinbase's own page, where the customer types them. Always offered while the status is `REQUIRED`.

Prefer `CUSTOMER_KYC` when it is offered. With `reason: CORRECT_IDENTITY`, Coinbase wants the customer to correct the details it was sent, so only `HOSTED_FORM` is offered.

### 3. Start the upgrade

Start the process once, when the customer taps your button to verify their identity. Never start it automatically, and never retry a `START` on its own: Coinbase allows each customer only five upgrade submissions in five days, and the descriptor's `automaticSubmissionRetryAllowed` is `false`.

Send the same body with `operation: START` and the input source you chose:

```json theme={null}
{
  "customerId": "WmYYgvN8ukpV62N3m4u3ee",
  "order": { "...": "the same order as the READ" },
  "operation": "START",
  "inputSource": "CUSTOMER_KYC"
}
```

* **`CUSTOMER_KYC`** answers `status: PENDING` with `pollable: true` once Meld has sent the details. Meld doesn't store them. Go to step 5. If Meld has no usable date of birth and SSN for the customer, it answers `403 KYC_NOT_COMPLETED` and sends Coinbase nothing: go to step 4.
* **`HOSTED_FORM`** answers `hostedForm.url` and `hostedForm.expiresAt`, and the status stays `REQUIRED` until the customer submits the form. Open the URL for the customer before it expires, then poll as in step 5. While the form is open, `READ` still answers `REQUIRED`: keep polling, and don't start again. The link is short-lived and works as a credential: never log or store it. Poll Meld for the result rather than relying on events from the page.

If the process is already `PENDING`, `SATISFIED`, `BLOCKED` or `UNAVAILABLE`, `START` sends nothing and returns its state.

Errors from `READ` and `START` use the [standard envelope](https://docs.meld.io/docs/meld-api/error-responses.md):

| HTTP | `code` | Cause | What to do |
| - | - | - | - |
| `400` | `TRANSACTION_REQUEST_VALIDATION_ERROR` | `READ` with an `inputSource`, or `START` without one | Fix the body |
| `400` | `ONRAMP_PROCESS_NOT_SUPPORTED` | The provider, process or `order` has no such process, for example an order that isn't a Coinbase wallet order | Use the descriptor eligibility returned, with the order you checked |
| `403` | `KYC_NOT_COMPLETED` | `CUSTOMER_KYC` only: Meld has no usable date of birth and SSN for the customer. Nothing was sent to Coinbase | Step 4 |
| `403` | `WHITELABEL_NOT_ENABLED` | Headless onramps aren't enabled on your account in this environment | Contact your Meld representative |
| `403` | `SERVICE_PROVIDER_NOT_ENABLED` | Your account has no Coinbase credentials | See [provider credentials](https://docs.meld.io/docs/stablecoins/headless-integration.md#provider-credentials) |
| `409` | `ONRAMP_PROCESS_NOT_READY` | Meld couldn't read the process state (`UNKNOWN`), or the input source isn't one `READ` offered | Read again, and start with an offered input source |
| `422` | `CUSTOMER_NOT_PAYMENT_READY` | The customer's phone isn't verified with Meld | Verify it with `serviceProvider: "COINBASEPAY"`, as in [Eligibility](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/eligibility.md#contact-verification) |
| `500` | `CUSTOMER_VERIFICATION_UNAVAILABLE` | Meld couldn't read the customer's verification or KYC. Nothing was sent to Coinbase | Try again later, from the customer's tap |

### 4. Run Unified KYC when Meld has no SSN

A `403 KYC_NOT_COMPLETED` means the customer's Unified KYC holds no date of birth and SSN yet. [Check the customer's KYC](https://docs.meld.io/docs/stablecoins/unified-kyc/meld-kyces-the-user.md#step-2-check-if-the-user-is-already-kyced):

* **Not `APPROVED`, or no KYC at all.** Start Unified KYC: [`POST /accounts/customers/{customerId}/kyc/initiate`](https://docs.meld.io/api-reference/customer/customers/accounts-customers-kyc-initiate)

  ```json theme={null}
  {
    "serviceProvider": "SUMSUB",
    "mode": "HOSTED_URL"
  }
  ```

  Open the `url` it returns in your app. Coinbase takes no KYC share, so leave `kycShareProviders` out unless another provider needs it. If the `POST` answers `409`, the customer already has a session: send the same body with `PATCH` for a fresh `url`. This is the standard Unified KYC that other providers use, so Sumsub also asks the customer for an ID document and a selfie.

  Wait for the [`CUSTOMER_KYC_STATUS_CHANGE`](https://docs.meld.io/docs/stablecoins/unified-kyc/meld-kyces-the-user#step-3-have-meld-kyc-the-user) webhook with `payload.status: APPROVED`. Then, when the customer is back in your flow and taps to continue, read the process and start it with `CUSTOMER_KYC` again.

* **Already `APPROVED`.** The customer's KYC has no date of birth or SSN on file, and running it again won't add them. Start the process with `HOSTED_FORM` instead, so the customer types them on Coinbase's page.

If you KYC your customers yourself, as in [You KYC the user](https://docs.meld.io/docs/stablecoins/unified-kyc/you-kyc-the-user.md), the applicant you import needs a date of birth and a US SSN. With `RAW_DATA_IMPORT`, send `dob` and the SSN as `tin`.

### 5. Wait for Coinbase, then resend the order

After `START`, read the process every `process.pollIntervalMs` while `pollable` is `true`, for up to `process.pollTimeoutMs`.

| You read | Do |
| - | - |
| `SATISFIED` | Check eligibility again, then create the order. A refused order was never created, so you can resend the same request |
| `PENDING` after `pollTimeoutMs` | Coinbase is still reviewing, which isn't a failure. Tell the customer, stop polling, and check eligibility again when they come back. Don't start the process again |
| `REQUIRED` with `reason: CORRECT_IDENTITY` | Coinbase wants the details corrected. Let the customer start `HOSTED_FORM`, to correct them on Coinbase's page |
| `REQUIRED` after `HOSTED_FORM` | The customer hasn't submitted Coinbase's page yet. Let them reopen the same `hostedForm.url` until `hostedForm.expiresAt`, and start `HOSTED_FORM` again only after it expires |
| `BLOCKED` | Offer another provider |

***

## Surface is informational

`Meld.capabilities` returns `surface`, `embeddable` and `requiresUserGesture`. Branch on two things only:

* **`surface == "unsupported"`:** this build can't present the order on this device. Don't mount it. Offer another method.
* **`embeddable`:** `true` means the surface renders into a view you give it, so give it room. `false` means the SDK presents it and draws into none of your views.

Every other `surface` value is informational. It names what the provider exposes, such as `embedded`, `native-applepay` or `native-sdk` on iOS and `native-wallet` on Android, so the same wallet reports different values on different providers and platforms. New values can appear in any release. Log `surface`, and never switch on it.

***

## Error codes

Every Meld SDK names these codes, and agrees on `attemptMayExist` for each. They come with iOS SDK 0.10.0, Android SDK 0.8.0, React Native SDK 0.10.0 and Web SDK 0.7.0. On the web, the surfaces Web SDK 0.7.0 presents report only `PROVIDER_LOAD_FAILED` from this table, and codes of their own otherwise: see [Web SDK → Error codes](https://docs.meld.io/docs/stablecoins/headless-integration/sdks/web.md#error-codes).

| `code` | `attemptMayExist` | Means | Do |
| - | - | - | - |
| `PAYMENT_METHOD_UNAVAILABLE` | `false` | This device or customer can't pay with the order's method, for example because the wallet holds no card the provider takes | Offer another method |
| `PRESENTATION_FAILED` | `false` | The sheet or form never came up, or the wallet failed before anything was sent | Return to your button. The next attempt needs a new order |
| `PAYMENT_REJECTED` | `false` | Declined, and nothing will settle | Offer another option |
| `ORDER_STATE_CHANGED` | `false` | The provider no longer accepts this order | Create a new order |
| `VERIFICATION_PENDING` | `false` | The provider is still reviewing the customer | Tell the customer. No payment was made |
| `PAYMENT_OUTCOME_UNKNOWN` | `true` | A payment may exist | Track the order through your backend, and never pay it again |
| `PROVIDER_LOAD_FAILED` | `true` | The provider's page, SDK or script didn't load | With `recoverable: true`, the customer can carry on, or mounting the same order again may work. Otherwise track the order before you offer a new one |

Treat any other code as a payment that may exist, like `PAYMENT_OUTCOME_UNKNOWN`. That includes the codes only one SDK reports, which each SDK's page lists, and any code your build doesn't know. The exception is the React Native SDK's own codes, raised before anything is mounted, with no attempt behind them: `INVALID_ORDER`, `INVALID_APPLE_PAY_REQUEST`, `MOUNT_FAILED` and `UNSUPPORTED_NATIVE_PROTOCOL`.

Don't keep a list of your own. Each SDK answers for you: `error.attemptMayExist` on iOS and Android, `attemptMayExist(e.code)` in React Native, and `Meld.attemptMayExist(e.code)` on the web. They also name the codes, as `MeldErrorCode` on iOS, Android and React Native, and as `Meld.ErrorCode` on the web.

Every error carries the mounted order's `paymentMethodType`, such as `APPLE_PAY` or `GOOGLE_PAY`, so you can word a message like "Apple Pay isn't available" from it without branching on the code. When the order has none, it is `nil` on iOS, `null` on Android, and absent on React Native. The Web SDK doesn't mount an order without one.

`detail` carries the provider's own event or code, such as `onramp_api.load_error:ERROR_CODE_GUEST_GOOGLE_PAY_NOT_SUPPORTED`, or the SDK's reason, such as `EMPTY_APPLE_PAY_TOKEN` on a `PRESENTATION_FAILED`. Log it, and don't branch on it.

A card form that fails before it shows reports one of these codes, with the form's own code in `detail`. In the Android SDK, the Banxa and Uphold card forms report `sdk_unavailable` as `PROVIDER_LOAD_FAILED`, and `mount_failed` or Banxa's `checkout_init_failed` as `PRESENTATION_FAILED`. For example, `detail` is `banxa_card_failed:checkout_init_failed`. In the iOS SDK, the Uphold card form reports `sdk_unavailable` and `mount_failed` the same way. Banxa's iOS card form is Primer's native modal instead, and reports `PRESENTATION_FAILED` with `detail` `banxa_card_failed:` and Primer's error id, or `presentation_deadline`: see [iOS → Card forms that fail before they show](https://docs.meld.io/docs/stablecoins/headless-integration/sdks/ios.md#card-forms-that-fail-before-they-show).

The Android SDK defines `VERIFICATION_PENDING` from 0.8.0 but doesn't report it yet. It is listed so code shared across platforms reads it the same way.

***

## Errors on order creation

Errors use the [standard envelope](https://docs.meld.io/docs/meld-api/error-responses.md). Unless the table says otherwise, nothing was charged.

| HTTP | `code` | Cause | What to do |
| - | - | - | - |
| `400` | `PAYMENT_METHOD_NOT_SUPPORTED` | No headless route for this method, provider, country, currency pair and network. For Stripe Apple Pay, also: the integration's keys don't match its environment, or Meld has no Stripe merchant identifier for you in this environment | Re-quote with `integrationMode=HEADLESS` and send its values unchanged, or offer another method. For Stripe, see [Stripe's certificate](#stripes-certificate) |
| `400` | `HEADLESS_NOT_SUPPORTED` | The order doesn't match a headless route for the provider, or headless isn't available for it on your account | Re-quote and send its values unchanged. If it persists, contact your Meld representative |
| `400` | `INVALID_CRYPTO_CURRENCY` | Stripe only: `destinationNetworkCode` is missing. Any provider: `destinationNetworkCode` doesn't match the chain in `destinationCurrencyCode`, for example `USDC_BASE` with `ETH`. The quote returns the same error | Send the `destinationNetworkCode` you quoted with, with a new `X-Idempotency-Key` |
| `400` | `INVALID_PAYMENT_METHOD` | Mercuryo Apple Pay: when you create the order, Meld asks Mercuryo for a fresh quote, and Mercuryo doesn't confirm that its native Apple Pay sheet can open for the customer's IP and corridor. A Mercuryo Apple Pay row in the quote doesn't rule this out. Any provider: Meld has conflicting routes for the method | For Mercuryo Apple Pay, offer a card or another row from the quote, as a new order with a new `X-Idempotency-Key` and `externalOrderId`, and don't retry Apple Pay through Mercuryo for this quote. If you left out `clientIpAddress`, Mercuryo judged your server's IP: send the customer's on the next order. Otherwise, contact your Meld representative |
| `400` | `ACCOUNT_BAD_CONFIGURATION` | The provider's integration on your account is incomplete, for example a Stripe integration without its Link OAuth client or headless API version | Complete it in the Meld dashboard |
| `400` | `TRANSACTION_REQUEST_VALIDATION_ERROR` | A field is invalid, for example an `agreementAcceptedAt` that can't be parsed or is in the future | Fix the field |
| `403` | `WHITELABEL_NOT_ENABLED` | Headless onramps aren't enabled on your account in this environment | Contact your Meld representative |
| `403` | `SERVICE_PROVIDER_NOT_ENABLED` | Your account has no credentials for the provider | See [provider credentials](https://docs.meld.io/docs/stablecoins/headless-integration.md#provider-credentials) |
| `403` | `VERIFICATION_REQUIRED` | `errors` lists what the customer still needs: `email`, `phone` or `agreement`, each `MISSING` or `STALE` | Complete what is listed, as in [Eligibility](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/eligibility.md), then resend |
| `403` | `LEGAL_ACCEPTANCE_REQUIRED` | Only the provider's agreements are unmet | [Record the acceptance](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/eligibility.md#legal-agreements), then resend |
| `403` | `KYC_NOT_COMPLETED` | The provider hasn't approved the customer | Re-check eligibility before retrying |
| `409` | `HEADLESS_PAYMENT_OUTCOME_UNKNOWN` | Meld couldn't confirm with the provider whether the order started | Create a new order rather than retrying in a loop |
| `422` | `CUSTOMER_NOT_PAYMENT_READY` | The customer reached a provider limit, and no order was created. `errors` names the [process](https://docs.meld.io/docs/stablecoins/headless-integration/shared-flows/eligibility.md#provider-processes) that raises it and its state, for example `limitUpgrade: REQUIRED` | Run the process, then resend the same request. For Coinbase, see [step-up KYC](#coinbase-guest-checkout-and-step-up-kyc) |
| `422` | `HEADLESS_PROVIDER_REJECTED` | The provider refused the order, and the order is spent. Stripe reports this as `STRIPE_HEADLESS_PROVIDER_REJECTED` | Create a new order with a new `X-Idempotency-Key` and `externalOrderId`. If `errors` lists `email: REJECTED` or `phone: REJECTED`, verify that channel again first. If every order fails, check the provider account's setup. A Coinbase customer over the weekly spending cap ends here too: don't send them to KYC, as [Coinbase's limits](#coinbases-limits) explains |
| `425` | `HEADLESS_PAYMENT_IN_PROGRESS` | The same request is still being processed | Retry shortly with the same `X-Idempotency-Key` |
| `500` | `CUSTOMER_VERIFICATION_UNAVAILABLE` | Meld couldn't check the customer's verification | Retry the same request later |

***

## Testing

### Google Pay in sandbox

When your Coinbase integration runs in Coinbase's sandbox, Meld sends Coinbase a customer reference that starts with `sandbox-`, and Coinbase runs Google Pay in Google's test environment:

* The sheet shows a test card and says you won't be charged. No money moves.
* It needs no Google approval, so any build of your app can run it, debug builds included.
* It still needs a physical phone that meets [Devices](#devices). An emulator can't run it.

To run one:

1. Quote for a US customer in USD, with `integrationMode=HEADLESS` and `paymentMethodType: GOOGLE_PAY`, and pick the `COINBASEPAY` row.
2. Check the row's readiness, then create the order with `paymentMethodType: "GOOGLE_PAY"`.
3. Mount it from a tap, and pay on Google Pay's sheet.
4. Expect `onPaymentSubmitted`, then the transaction webhooks for the order.

Ask your Meld representative to confirm that your sandbox Coinbase integration runs in Coinbase's sandbox. One that runs in Coinbase's production moves real money, even from Meld's sandbox, and its Google Pay orders then need your app's approval.

With the Android SDK configured for sandbox, a readiness check that finds no card answers `unknown` rather than `unavailable`. The check asks Google Pay's production environment, and a sandbox order runs Google's test environment instead.

### Apple Pay in sandbox

See [Headless testing → Test Apple Pay](https://docs.meld.io/docs/stablecoins/sandbox-guide/headless-testing.md#test-apple-pay). Coinbase Apple Pay can't be presented in sandbox, so test its sheet in production with a small order, as [Coinbase](#coinbase) describes.

### Coinbase step-up KYC in sandbox

Sandbox has no test customer at Coinbase's lifetime cap, so you can't run the [step-up](#coinbase-guest-checkout-and-step-up-kyc) end to end there. Test its Unified KYC half as in [KYC testing](https://docs.meld.io/docs/stablecoins/sandbox-guide/kyc-testing.md), and handle each status in your code. A sandbox Coinbase integration that runs in Coinbase's production reads and changes the customer's real Coinbase limits.

### Your first production orders

Run one small production order per wallet and provider, on a physical device. For Apple Pay, finish the [Apple Developer setup](#apple-developer-setup) first, unless the provider is Coinbase. For Google Pay, run it from a release build of the app Google approved, signed with your release key.

***

## Troubleshooting

| You see | Cause | Fix |
| - | - | - |
| Google Pay's sheet says "This merchant is having trouble accepting your payment right now", with `OR_BIBED_11` | Your app isn't approved for Google Pay's production environment, and the order runs there because your Coinbase integration is in production | Get the app approved, as in [Google Pay: your app's approval](#google-pay-your-apps-approval). Once it is, run a release build signed with your release key, under the approved application ID. Until then, test with [sandbox orders](#google-pay-in-sandbox) |
| `onError` with `PAYMENT_METHOD_UNAVAILABLE` and `detail` `onramp_api.load_error:ERROR_CODE_GUEST_GOOGLE_PAY_NOT_SUPPORTED` | No card in Google Wallet that is tokenized on this phone and on a network Coinbase takes | Offer another method. Check readiness before you create the next order |
| `Meld.capabilities(order).surface` is `unsupported` for a Google Pay order | The phone's WebView is older than 137 and lacks Payment Request, or the SDK doesn't know the order's protocol or version | Update Android System WebView, or the SDK |
| `onError` with `PRESENTATION_FAILED` and `detail` `presentation_deadline` | The sheet didn't open within 8 seconds of the press | Create a new order. If it repeats, check the phone meets [Devices](#devices) |
| Google Pay never works on an emulator | Emulators can't hold a device-tokenized card | Use a physical phone |
| No Google Pay rows in the quote | Google Pay isn't live for any provider on your account, it isn't enabled on your Coinbase project, or the customer isn't a US customer paying USD | Check the customer and your Coinbase project, then ask your Meld representative |
| `onError` with `PAYMENT_METHOD_UNAVAILABLE` on an Apple Pay order | Apple Pay is restricted on the device, or Wallet holds no card the provider takes | Offer another method. Check readiness before you create the next order |
| The Apple Pay sheet doesn't appear | Your app's Apple Pay entitlement is missing or lacks the merchant identifier the order carries | Add the identifier, as in [Apple Developer setup](#apple-developer-setup) |
| The Apple Pay sheet appears, and every payment declines | The processing certificate never got back to the processor | Send the `.cer` file to your Meld representative, or for Stripe upload it to Stripe |
| An Apple Pay payment fails from the iOS Simulator | The Simulator can't produce a usable Apple Pay token | Test on a physical iPhone |
| `404` naming a service-provider customer | Usually a missing `email` on the Meld customer, occasionally missing KYC | Add the email, then check eligibility |
| `400` or `409` on `externalOrderId` | You reused a value | Generate a fresh one per attempt |
| `INVALID_REDIRECT_URL` | You sent `redirectUrl`. Headless surfaces render in place, so the field is rejected | Leave it out |
| The surface never becomes ready on the web | Your Content Security Policy blocks the provider, or a provider host isn't reachable from the device | Add the hosts from [`Meld.requiredCsp`](https://docs.meld.io/docs/stablecoins/headless-integration/sdks/web#content-security-policy) |
| The customer paid, and no crypto has arrived yet | Normal while settlement runs | Wait for the webhook before you show a failure |


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