Want a working reference instead of building from scratch? onramp-v2-mobile-demo is a simple, working implementation of this exact flow.
iOS only. App2App supports iOS with Apple App Attest today. Android isn’t supported yet, so use Coinbase-hosted Onramp for Android.
One-time setup
Install dependencies, configure Xcode, stand up your backend, and register each device. You do this once, not per purchase.
Every purchase
Detect the Coinbase app, start the purchase, handle the return, and confirm settlement. This runs every time a user buys.
Prerequisites
- Get allowlisted. Contact the Coinbase team to enable your CDP project for App2App. Then register your iOS App Attest identifier (
teamID.bundleID) yourself in CDP Portal, under Payments → Onramp & Offramp → App2App iOS. - Add your redirect host to the domain allowlist. In CDP Portal, under Payments → Onramp & Offramp, add the host of the
redirectUrlyou’ll use. See Security Requirements for supported formats. Requests with aredirectUrlhost that isn’t allowlisted are rejected. - Stand up a backend that holds a CDP API key. Minting the attestation challenge and the purchase challenge both require a CDP API-key JWT; the device only ever sees the challenge that comes back, never the key. See Build your backend.
- Use a physical iOS device. App Attest requires the Secure Enclave, which the iOS Simulator doesn’t have. This applies to sandbox testing too, not just production.
- Use a custom development build, not Expo Go.
@coinbase/cdp-app-attestis a native module; Expo Go can’t load it. Build withexpo run:ios, a custom dev client, or EAS Build. - Enable the App Attest capability in Xcode (requires an Apple Developer Program account) with a deployment target of iOS 14+. See Apple’s DeviceCheck documentation and the App Attest setup guide for background on what this capability does.
The four API routes
Everything in this guide comes down to four HTTP calls. The two challenge-mint calls need a CDP API key and run on your backend; the other two are unauthenticated and can be called directly from the device, or proxied through your backend for a single base URL (see Build your backend).1. Mint attestation challenge
1. Mint attestation challenge
- Auth: CDP API key JWT, from your backend
- Called: once per install, before registering the device
Response
2. Register the device key
2. Register the device key
- Auth: none
- Called: once per install, right after the device attests the challenge above
Request
Response
3. Mint purchase challenge
3. Mint purchase challenge
- Auth: CDP API key JWT, from your backend
- Called: once per purchase, before starting the purchase
Request
Response
4. Create the session
4. Create the session
- Auth: none
- Called: once per purchase, right after the device signs the challenge above
Request
Response
One-time setup
Do these four things once, when you build the integration. None of them repeat per purchase.1. Install dependencies
@coinbase/cdp-app-attestis required. It’s the native module that drivesDCAppAttestServicedirectly and is what this guide uses for the App Attest ceremony in Register each device and Start the purchase.@coinbase/cdp-react-nativeis optional, used only for itscanOpenCoinbaseOnramp()andhandleOnrampReturn()helpers (Detect the Coinbase app and Handle the return). This guide does not use itsopenCoinbaseOnramp()function.- If you’re building a native iOS app without React Native, call
DCAppAttestServicedirectly wherever this guide shows a@coinbase/cdp-app-attestcall, andcanOpenURLdirectly for detection — the request/response shapes are identical.
2. Configure your iOS project
1
Enable the App Attest entitlement
In Xcode, add the App Attest capability, or set it directly in your Use
Info.plist/entitlements:development for local debug builds if you want Apple’s sandbox App Attest environment; see Sandbox testing for how this interacts with partnerUserRef.2
Declare both Coinbase onramp URL schemes
Add the following to This isn’t a Coinbase-side setting: iOS requires your app to declare any scheme it intends to query with
Info.plist so canOpenCoinbaseOnramp() can detect the installed Coinbase app:canOpenURL, or the call always returns false. Declare both schemes:com.coinbase.cdp.onramp.v2is registered by newer Coinbase app builds that support the current App2App input contract (purchaseAmount,paymentMethod).com.coinbase.cdp.onrampis the legacy scheme, still registered by older Coinbase app builds.canOpenCoinbaseOnramp()treats either as “supported” so users on an older Coinbase app build aren’t incorrectly told App2App is unavailable.
3. Build your backend
Add two routes, one per authenticated call. Each signs a CDP API key JWT and forwards to CDP:4. Register each device
Do this once per install, before the device’s first purchase. Persist the registration locally, scoped to your CDP project ID.attest() and createAssertion() (used here and in Start the purchase) both expect standard base64 input, but CDP mints challenges as base64url (unpadded). Convert before signing:
Where does this call actually go?
Where does this call actually go?
Straight to CDP (
https://api.cdp.coinbase.com/platform/v2/onramp/mobile/attestation/registrations), or to your backend’s pass-through proxy if you built one. Either way, it’s unauthenticated: no Authorization header, no projectId or keyId path params. keyId travels only in the body — App Attest key IDs are standard base64 and often contain /, which breaks path routing at the edge. See The four API routes above for the exact request and response.Why clear the registration on any failure, even a network error?
Why clear the registration on any failure, even a network error?
Apple’s
attestKey is a one-time operation per key: if registration fails after attest() succeeds, the key’s attestation slot is already consumed. Always clear the local registration on failure (as shown above) so the next attempt provisions a fresh key, rather than retrying attest() on the same one.attest() calls DCAppAttestService.attestKey under the hood with clientDataHash = SHA-256(base64url_decode(challenge)). If you’re calling DCAppAttestService directly instead, apply that same decode-then-hash order; hashing the base64url string directly fails verification.
Every purchase
This flow repeats for every purchase a signed-in user makes. It assumes one-time setup is already done.1. Detect the Coinbase app
Before showing or enabling your “Pay with Coinbase” button, check whether the installed Coinbase app supports App2App:canOpenCoinbaseOnramp() probes both schemes declared in Configure your iOS project and resolves false on Android (App2App isn’t available there yet) or if either probe throws. Re-run this check whenever your app returns to the foreground, in case the user installed or removed the Coinbase app while your app was backgrounded.
Not using React Native? Probe both schemes with canOpenURL directly and treat either true as supported:
2. Start the purchase
Call this when the user taps your “Pay with Coinbase” button:order is the body your backend forwards to /v2/onramp/mobile/sessions/challenges. There’s no projectId field: the challenge is minted under your backend’s CDP API key JWT, and that JWT is what identifies the project.
Order parameters & pricing reference
Order parameters & pricing reference
The purchase challenge doesn’t return a quote or eligibility details. To surface accurate information to the user before handoff, pull from:
- Buy Config: countries and payment methods
- Buy Options: assets, networks, and limits
- Buy Quote: an actual fee breakdown
challengeexpires 5 minutes after it’s minted. If the session call returnsstale_attestation, mint a fresh purchase challenge and retry with a new assertion; keep the same registered device key.- The session call is idempotent on the challenge: if you don’t receive a response (for example, due to a dropped connection), retry it. Retrying an already-attested challenge returns the same URL rather than an error.
- The returned
onrampUrl’s session token expires 15 minutes after it’s issued.
What if the attestation key becomes invalid?
What if the attestation key becomes invalid?
Registered keys can go stale (for example, a key left over from a deleted TestFlight install). If Match
/v2/onramp/mobile/sessions rejects the assertion with an invalid-key error, self-heal by clearing the registration and re-running Register each device before retrying the purchase once:isAttestationKeyError against the error message (phrases like “invalid key,” “key not found,” or “assertion could not be verified”). This mirrors the retry pattern in the reference demo app.3. Handle the return
The Coinbase app redirects back to yourredirectUrl as a universal link with one of three outcomes. There’s no reason code on any of them; the query parameters below are everything you get:
If you’re using
@coinbase/cdp-react-native, call handleOnrampReturn in your deep-link handler every time your app receives this redirect:
handshakeNonce-based security handshake that handleOnrampReturn will eventually perform. Adding the call now means your app automatically participates once the backend starts requiring it, with no further code changes on your end.
handshakeNonceis only included onsuccess.errorandcancelledcarry no detail about what went wrong or how far the user got. If you need to distinguish “not eligible” from “transaction failed,” you can’t do it from the redirect alone.- If the user isn’t already signed in to Coinbase, they’ll sign in or create an account (and complete KYC if they’re new) before the purchase continues, all inside the Coinbase app, with no separate step needed from you.
4. Confirm the transaction
- Follow the Onramp & Offramp Webhooks guide to create a webhook subscription for
onramp.transaction.success(and.created/.updated/.failedif you want the intermediate states). - Match incoming events to your user with
partnerUserRef; it’s included in every payload. - Treat a transaction as settled only when the event is
onramp.transaction.success(or, if you’re inspecting the payload directly,statusisONRAMP_TRANSACTION_STATUS_SUCCESS). - App2App transactions include
isAppToApp: true, so you can distinguish them from other purchase flows if needed.
partnerUserRef you sent when starting the purchase:
- Match transactions using
partnerUserRefplustxHash, purchase currency, and amount. The redirect’stransactionIddoesn’t match thetransactionIdreturned by this endpoint: they’re generated by different systems for the same purchase, so don’t use them to join records, even though the redirect’s value now looks like a real ID on a live purchase rather than a placeholder. - Sandbox (dry-run) sessions are the exception to both of the above; see Sandbox testing.
Sandbox testing
You can exercise the full flow (registration, challenge, session, handoff, and redirect) without moving real funds. There’s no way to skip App Attest just to preview the UI: every purchase, sandbox or live, runs the real attestation ceremony on a physical device — the iOS Simulator can’t attest at all, since it has no Secure Enclave. Which path applies depends on the build you’re testing with:
Either way, a
partnerUserRef like sandbox-user-1234 is what puts the session into sandbox mode; there’s no separate sandbox flag, endpoint, or allowlist.
What actually happens in sandbox mode?
What actually happens in sandbox mode?
The user experience is identical to a real purchase: sign-in, confirmation, and redirect all run normally, and the redirect still includes a
transactionId generated for that sandbox purchase only. But no webhook fires and no row appears in the transactions API — the redirect is the only confirmation signal you get in sandbox. See Confirm the transaction.- Keep the
sandbox-prefix until you’re ready to move real funds; it’s independent of which App Attest environment you’re using.
Errors
What to read next
- FAQ: Common questions from partners integrating App2App
- Onramp & Offramp Webhooks: Full webhook setup and payload reference
- Security Requirements: Domain allowlist requirements for your redirect URL
- onramp-v2-mobile-demo: Full reference implementation of the client and backend pieces in this guide