Skip to main content
This guide walks through a complete App2App setup. For a conceptual overview of how App2App works and why it uses device attestation, see the overview.
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.
App2App has two moving pieces: a client integration that runs the on-device App Attest ceremony and hands the user off to the Coinbase app, and a backend that holds your CDP API key and mints two of the four network calls. A CDP API key can never ship inside a mobile app, so those two calls have to come from somewhere that isn’t the device. This guide is two flows, not one:

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 redirectUrl you’ll use. See Security Requirements for supported formats. Requests with a redirectUrl host 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-attest is a native module; Expo Go can’t load it. Build with expo 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).
No request body.
Response
  • Auth: none
  • Called: once per install, right after the device attests the challenge above
Request
Response
Request
Response
  • 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-attest is required. It’s the native module that drives DCAppAttestService directly and is what this guide uses for the App Attest ceremony in Register each device and Start the purchase.
  • @coinbase/cdp-react-native is optional, used only for its canOpenCoinbaseOnramp() and handleOnrampReturn() helpers (Detect the Coinbase app and Handle the return). This guide does not use its openCoinbaseOnramp() function.
  • If you’re building a native iOS app without React Native, call DCAppAttestService directly wherever this guide shows a @coinbase/cdp-app-attest call, and canOpenURL directly 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 Info.plist/entitlements:
Use 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 Info.plist so canOpenCoinbaseOnramp() can detect the installed Coinbase app:
This isn’t a Coinbase-side setting: iOS requires your app to declare any scheme it intends to query with canOpenURL, or the call always returns false. Declare both schemes:
  • com.coinbase.cdp.onramp.v2 is registered by newer Coinbase app builds that support the current App2App input contract (purchaseAmount, paymentMethod).
  • com.coinbase.cdp.onramp is 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.
Only Coinbase app versions that support App2App register these schemes at all, which is what makes detection a real capability check rather than a plain “is Coinbase installed” check.

3. Build your backend

Add two routes, one per authenticated call. Each signs a CDP API key JWT and forwards to CDP:
The other two calls, registration and session creation, stay unauthenticated. You can call them directly from the device (see Register each device and Start the purchase), or proxy them through this same backend for a single base URL and centralized logging, the way the reference demo app does. Proxying them is a convenience, not a requirement, since they don’t need your CDP API key.

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.
Run Detect the Coinbase app first and only register if it returns true. There’s no reason to spend an App Attest ceremony and a network round trip registering a device that can’t complete a purchase anyway.
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:
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.
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:
This only checks whether the installed Coinbase app supports App2App — it says nothing about the user themselves. Purchase eligibility (KYC, region, limits) is only known after handoff, once the user is signed in. If the check returns false, fall back to Coinbase-hosted Onramp so the user can still complete a purchase.

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.
The purchase challenge doesn’t return a quote or eligibility details. To surface accurate information to the user before handoff, pull from:All three return estimates; Coinbase applies the signed-in user’s real limits and eligibility after they land in the Coinbase app. The Onramp Asset Availability tool does the same lookup visually, by region.
partnerUserRef is your user identifier. You generate it; Coinbase never creates or looks it up on its own. Send the same value every time for a given user (max 50 characters), and Coinbase echoes it back to you in three places so you can tie everything together:
  • challenge expires 5 minutes after it’s minted. If the session call returns stale_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.
Each session is single-use. Once the Coinbase app opens the URL, the session is consumed. If the user closes or force-quits the Coinbase app before finishing, start a new purchase rather than reopening the same URL.
Registered keys can go stale (for example, a key left over from a deleted TestFlight install). If /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:
Match 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 your redirectUrl 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:
This is currently a no-op: CDP doesn’t yet issue the 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.
  • handshakeNonce is only included on success.
  • error and cancelled carry 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.
Never credit a balance from the redirect. The redirect is for showing the right screen to your user, not for confirming settlement; it isn’t signed and shouldn’t be trusted on its own. Always confirm the purchase with a webhook or the transactions API before crediting anything.
Backgrounding the Coinbase app doesn’t lose the redirect: it fires normally once the user returns and completes or backs out of the flow, no matter how long they were away. The redirect is only permanently lost if the Coinbase app is force-quit or killed by the OS before the user comes back. Don’t block your UI waiting for a redirect indefinitely.

4. Confirm the transaction

Subscribe to a webhook. Don’t build your primary confirmation path around polling. Coinbase sends a webhook to your backend when a transaction is created, updated, or settles. Set up a subscription once and get pushed a notification the moment a purchase completes, instead of pulling for it.
  1. Follow the Onramp & Offramp Webhooks guide to create a webhook subscription for onramp.transaction.success (and .created / .updated / .failed if you want the intermediate states).
  2. Match incoming events to your user with partnerUserRef; it’s included in every payload.
  3. Treat a transaction as settled only when the event is onramp.transaction.success (or, if you’re inspecting the payload directly, status is ONRAMP_TRANSACTION_STATUS_SUCCESS).
  4. App2App transactions include isAppToApp: true, so you can distinguish them from other purchase flows if needed.
If you’d rather pull instead of (or in addition to) receiving webhooks (for reconciliation, backfilling, or debugging a specific user), poll the Get onramp transactions by ID endpoint with a CDP API key JWT, using the same partnerUserRef you sent when starting the purchase:
  • Match transactions using partnerUserRef plus txHash, purchase currency, and amount. The redirect’s transactionId doesn’t match the transactionId returned 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.
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

Error responses may change. Coinbase will communicate breaking changes before they take effect whenever possible.