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.
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 usepaymentMethodType: APPLE_PAY, and Google Pay orders GOOGLE_PAY.
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 explains. Who can pay through each provider, and what they need before the order, is in 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 hide it without a platform check of yours.
Providers
Eligibility 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, 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.updatedat the webhook URL the Meld dashboard shows for the integration. In sandbox, Meld provides the Stripe integration: see Headless testing. - 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_CARDruns 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.
- 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. - Limits. Coinbase caps each customer weekly and over their lifetime. At the lifetime cap, the order is refused with
CUSTOMER_NOT_PAYMENT_READYuntil the customer steps up with their date of birth and SSN. See Coinbase guest checkout and step-up KYC. - Quote. Send
walletAddresson the headless quote, 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 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"andpaymentMethodType: "GOOGLE_PAY", as in step 3. The order comes back withheadlessPresentationPROVIDER_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.
onramp_api.*) like this. The provider’s event and code travel in detail.
Before you go live
Create the customer and give them what the provider needs before the order (see Providers), and configure webhooks. 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.- 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.
- Mercuryo and Banxa: your own account and keys, added as in provider setup.
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
- 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.
- Go to Certificates, Identifiers & Profiles
- Click Identifiers in the sidebar, then +
- Select Merchant IDs, then Continue
- Enter a description and an identifier — it must start with
merchant.(e.g.merchant.com.yourcompany.yourapp) - Click Continue, review, then Register
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:- Go to Certificates, Identifiers & Profiles
- Click Identifiers, filter by Merchant IDs, and select the one from Step 1
- Under Apple Pay Payment Processing Certificate, click Create Certificate
- Upload the
.certSigningRequestfile Meld provided - Click Continue, then Download to save the
.cerfile - Send the
.cerfile back to your Meld representative
3
Configure Xcode
- Open your project in Xcode and select your app target
- Go to the Signing & Capabilities tab
- Click + Capability and select Apple Pay
- Under the Apple Pay capability, click + and add the merchant identifier from Step 1
- Regenerate provisioning profiles
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’smerchantIds. 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, and upload the
.cerfile Apple returns to Stripe, not to Meld. - Sandbox: Meld’s Stripe sandbox. Your Meld representative gives you the CSR, and you send them the
.cerfile.
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, 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 describes. Take the screenshots from a sandbox order, 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.
OR_BIBED_11. See Troubleshooting.
Devices
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
An Apple Pay purchase, with the system sheet presented over the app
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:
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 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: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:
What each wallet row answers:
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 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
Headers: Meld-Version: 2026-05-01, X-Idempotency-Key: <uuid>
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:
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.
4. Mount from the customer’s tap
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.
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) 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 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 as every order, and each mount ends with exactly one terminal callback.
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 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 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, and Meld passes Coinbase what it needs. Start every customer as a guest, and run KYC only for those who reach the cap.
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.
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’slimits rather than hardcoding them.
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.
kycRequirements holds:
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.
If you create the order anyway, Meld refuses it before anything is reserved:
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
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 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 isREQUIRED.
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 aSTART 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:
CUSTOMER_KYCanswersstatus: PENDINGwithpollable: trueonce 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 answers403 KYC_NOT_COMPLETEDand sends Coinbase nothing: go to step 4.HOSTED_FORManswershostedForm.urlandhostedForm.expiresAt, and the status staysREQUIREDuntil 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,READstill answersREQUIRED: 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.
PENDING, SATISFIED, BLOCKED or UNAVAILABLE, START sends nothing and returns its state.
Errors from READ and START use the standard envelope:
4. Run Unified KYC when Meld has no SSN
A403 KYC_NOT_COMPLETED means the customer’s Unified KYC holds no date of birth and SSN yet. Check the customer’s KYC:
-
Not
APPROVED, or no KYC at all. Start Unified KYC:POST /accounts/customers/{customerId}/kyc/initiateOpen theurlit returns in your app. Coinbase takes no KYC share, so leavekycShareProvidersout unless another provider needs it. If thePOSTanswers409, the customer already has a session: send the same body withPATCHfor a freshurl. 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 theCUSTOMER_KYC_STATUS_CHANGEwebhook withpayload.status: APPROVED. Then, when the customer is back in your flow and taps to continue, read the process and start it withCUSTOMER_KYCagain. -
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 withHOSTED_FORMinstead, so the customer types them on Coinbase’s page.
RAW_DATA_IMPORT, send dob and the SSN as tin.
5. Wait for Coinbase, then resend the order
AfterSTART, read the process every process.pollIntervalMs while pollable is true, for up to process.pollTimeoutMs.
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:truemeans the surface renders into a view you give it, so give it room.falsemeans the SDK presents it and draws into none of your views.
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 onattemptMayExist 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.
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.
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. Unless the table says otherwise, nothing was charged.Testing
Google Pay in sandbox
When your Coinbase integration runs in Coinbase’s sandbox, Meld sends Coinbase a customer reference that starts withsandbox-, 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. An emulator can’t run it.
- Quote for a US customer in USD, with
integrationMode=HEADLESSandpaymentMethodType: GOOGLE_PAY, and pick theCOINBASEPAYrow. - Check the row’s readiness, then create the order with
paymentMethodType: "GOOGLE_PAY". - Mount it from a tap, and pay on Google Pay’s sheet.
- Expect
onPaymentSubmitted, then the transaction webhooks for the order.
unknown rather than unavailable. The check asks Google Pay’s production environment, and a sandbox order runs Google’s test environment instead.