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

# Setup

> Set up App2App from your native iOS app: register your app, create a purchase session, and confirm settlement.

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](/onramp/app2app/overview).

<Info>
  **iOS only.** App2App supports iOS with Apple App Attest today. Android isn't supported yet, so use [Coinbase-hosted Onramp](/onramp/coinbase-hosted-onramp/overview) for Android.
</Info>

## Prerequisites

* **Get allowlisted.** [Contact the Coinbase team](https://support.cdp.coinbase.com/onramp-onboarding) to enable your CDP project for App2App and register your iOS App Attest identifier (`teamID.bundleID`).
* **Add your redirect host to the domain allowlist.** In [CDP Portal](https://portal.cdp.coinbase.com), under **Payments → Onramp & Offramp**, add the host of the `redirectUrl` you'll use. See [Security Requirements](/onramp/security-requirements) for supported formats. Requests with a `redirectUrl` host that isn't allowlisted are rejected.
* **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.
* **Set up Apple's DeviceCheck framework.** App2App calls `DCAppAttestService` directly: you're working with Apple's APIs, not a Coinbase SDK. You'll need the **App Attest** capability enabled in Xcode (requires an Apple Developer Program account) and a deployment target of iOS 14+. See Apple's [DeviceCheck documentation](https://developer.apple.com/documentation/devicecheck), the [App Attest setup guide](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity), and the [`DCAppAttestService` reference](https://developer.apple.com/documentation/devicecheck/dcappattestservice).
* **Add the Coinbase capability scheme** so your app can detect whether the installed Coinbase app supports App2App:

```xml theme={null}
<key>LSApplicationQueriesSchemes</key>
<array>
  <string>com.coinbase.cdp.onramp</string>
</array>
```

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`. Only Coinbase app versions that support App2App register this particular scheme, which is what makes the check in [Step 5](#step-5-detect-the-coinbase-app-and-open-the-purchase) a valid capability check rather than a plain "is Coinbase installed" check.

None of the attestation, challenge, or session requests below need a CDP API key or `Authorization` header; your app's attestation is the credential. Confirming settlement is the exception: [webhooks](#step-7-confirm-the-transaction) require a webhook subscription on your CDP project, and querying the transactions API directly requires a CDP API key JWT.

<Info>
  **What is App Attest?** App Attest is Apple's on-device mechanism for proving that a request comes from your genuine, unmodified app running on real hardware. It uses a hardware-backed key stored in the device's Secure Enclave. You call Apple's `DeviceCheck` framework directly; Coinbase verifies the result server-side.
</Info>

## Setup steps

### Step 1: Register your app's attestation key

Do this once per install. Persist the registration locally, scoped to your CDP project ID. You don't need to repeat it for every purchase. If a later purchase assertion fails because the key is invalid, clear your local registration, register a new key, and retry once.

<Steps>
  <Step title="Create a registration challenge">
    ```
    POST https://api.cdp.coinbase.com/platform/v2/onramp/mobile/projects/{projectId}/attestation/challenges
    ```

    Send an empty request body. The response is `{ challenge, expiresAt }`. `challenge` is base64url-encoded (unpadded).
  </Step>

  <Step title="Attest your key with Apple">
    Call `DCAppAttestService.attestKey` with:

    ```
    clientDataHash = SHA-256(base64url_decode(challenge))
    ```

    Decode the challenge before hashing it. Hashing the base64url string directly fails verification. Apple returns `{ keyId, attestation }`.
  </Step>

  <Step title="Register the public key">
    ```
    POST https://api.cdp.coinbase.com/platform/v2/onramp/mobile/projects/{projectId}/attestation/registrations
    ```

    ```json theme={null}
    {
      "challenge": "<from step 1>",
      "ios": {
        "keyId": "<Apple keyId, standard base64>",
        "attestation": "<CBOR attestation object, base64>",
        "bundleId": "<e.g. com.your.app>"
      }
    }
    ```

    The response includes `appId` (the matched `teamID.bundleID` identifier), `keyId`, `platform: "ios"`, and `attestedAt`.
  </Step>
</Steps>

### Step 2: Create a purchase challenge

```
POST https://api.cdp.coinbase.com/platform/v2/onramp/mobile/challenges
```

```json theme={null}
{
  "projectId": "<CDP project ID>",
  "purchaseCurrency": "USDC",
  "destinationNetwork": "solana",
  "destinationAddress": "<destination wallet address>",
  "redirectUrl": "https://your-allowlisted-host/onramp-return",
  "paymentAmount": "25.00",
  "paymentCurrency": "USD",
  "paymentMethod": "ACH",
  "partnerUserRef": "<your user ID, max 50 characters>",
  "country": "US",
  "subdivision": "NY"
}
```

| Field                     | Required                                          | Notes                                                                                                                                                                                                                          |
| ------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `projectId`               | Yes                                               | Your CDP project ID                                                                                                                                                                                                            |
| `purchaseCurrency`        | Yes                                               | A Coinbase ticker (`USDC`, not `usdc`) or asset ID                                                                                                                                                                             |
| `destinationNetwork`      | Yes                                               | A blockchain name, e.g. `solana` (not `solana-mainnet`)                                                                                                                                                                        |
| `destinationAddress`      | Yes                                               | The wallet that receives the crypto. Must be valid for `destinationNetwork`                                                                                                                                                    |
| `redirectUrl`             | Yes                                               | An absolute `https` URL. The host must be on your domain allowlist                                                                                                                                                             |
| `partnerUserRef`          | Required to confirm settlement                    | Your own user identifier, max 50 characters (see the note below)                                                                                                                                                               |
| `paymentAmount`           | Exactly one of `paymentAmount` / `purchaseAmount` | How much **fiat the user spends, fee-inclusive**. E.g. `"25.00"` means the user pays exactly \$25 total; fees come out of the crypto side, so they may receive slightly less crypto than a naive rate conversion would suggest |
| `purchaseAmount`          | Exactly one of `paymentAmount` / `purchaseAmount` | How much **crypto the user receives, fee-exclusive**. This is a literal crypto-denominated amount, not a fiat value. E.g. `"0.01"` means the user receives exactly 0.01 ETH; Coinbase charges extra fiat on top to cover fees  |
| `paymentCurrency`         | No                                                | A prefill hint for the fiat currency to start from. If omitted, Coinbase lets the signed-in user pick from their available funding sources                                                                                     |
| `paymentMethod`           | No                                                | One of `CARD`, `ACH`, `APPLE_PAY`, `FIAT_WALLET`, `CRYPTO_WALLET`                                                                                                                                                              |
| `country` / `subdivision` | No                                                | Optional ISO 3166-1 / 3166-2 codes. Not currently used to restrict anything; Coinbase determines eligibility from the signed-in account after handoff                                                                          |

<Tip>
  **`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:

  * In the [redirect](#step-6-handle-the-return-to-your-app) query parameters
  * In the [webhook](#step-7-confirm-the-transaction) payload
  * In the [transactions API](#step-7-confirm-the-transaction) response
</Tip>

The response is `{ challenge, expiresAt }`. The challenge expires after 5 minutes.

This endpoint doesn't return a quote or eligibility details. To surface accurate information to the user before handoff, pull from:

* **[Buy Config](/api-reference/rest-api/onramp-offramp/get-buy-config)**: supported countries and the payment methods available in each (plus US state-level subdivisions).
* **[Buy Options](/api-reference/rest-api/onramp-offramp/get-buy-options)**: supported assets, networks, and fiat currencies, including the min/max limit for each payment method.
* **[Buy Quote](/api-reference/rest-api/onramp-offramp/create-buy-quote)**: an actual fee breakdown and total for a specific amount, asset, and payment method.

All three return estimates; Coinbase applies the signed-in user's actual account-specific limits and eligibility after they land in the Coinbase app.

<Tip>
  Want a quick visual reference instead of calling the APIs directly? The [Onramp Asset Availability tool](https://onramp-asset-availability.vercel.app/) lets you look up supported assets, networks, and payment methods by region.
</Tip>

### Step 3: Generate a purchase assertion

Call `DCAppAttestService.generateAssertion` with the key you registered in Step 1 and:

```
clientDataHash = SHA-256(base64url_decode(challenge))
```

Use the challenge from Step 2, and apply the same decode-then-hash order as registration. Apple returns `{ keyId, assertion }`.

### Step 4: Create the session

```
POST https://api.cdp.coinbase.com/platform/v2/onramp/mobile/sessions
```

```json theme={null}
{
  "challenge": "<from step 2>",
  "ios": {
    "keyId": "<registered keyId, standard base64>",
    "assertion": "<assertion, base64>"
  }
}
```

The response is:

```json theme={null}
{
  "session": {
    "onrampUrl": "https://coinbase.com/onramp?sessionToken=..."
  }
}
```

* `onrampUrl` contains a session token that controls the destination, asset, network, and redirect for this purchase. Don't append or modify query parameters on it.
* This 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 session token expires **15 minutes** after it's issued.

### Step 5: Detect the Coinbase app and open the purchase

Before showing or enabling your "Pay with Coinbase" button, check whether the installed Coinbase app supports App2App:

```
canOpenURL("com.coinbase.cdp.onramp://")
```

* If this returns `true`, open the `onrampUrl` from Step 4.
* If it returns `false`, either the Coinbase app isn't installed or the installed version doesn't support App2App yet, so fall back to [Coinbase-hosted Onramp](/onramp/coinbase-hosted-onramp/overview) instead.
* Open `onrampUrl` exactly as returned. Don't open the `com.coinbase.cdp.onramp://` scheme yourself; it's only used for the capability check above.

<Warning>
  **The 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, create a new challenge and session rather than reopening the same URL.
</Warning>

### Step 6: Handle the return to your app

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:

| `status`    | When you'll see it                                                                                                                                                                                       | Query parameters                                                                             |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `success`   | The purchase (and send) completed                                                                                                                                                                        | `transactionId`, `sessionToken`, `partnerUserRef` if provided, `handshakeNonce` if available |
| `error`     | The session couldn't be validated, the user isn't eligible (unsupported country or asset, region restrictions), the user needs to update their Coinbase app, or the transaction was attempted and failed | `sessionToken`, `partnerUserRef` if provided                                                 |
| `cancelled` | The user left before attempting a transaction: closed the flow, backed out of the preview screen, or exited the passcode/biometric prompt                                                                | `sessionToken`, `partnerUserRef` if provided                                                 |

* `handshakeNonce` is reserved for a future verification step. You don't need to do anything with it today; it's safe to ignore. Both `handshakeNonce` and `transactionId` are only included on `success` today; Coinbase plans to also include them on `error`/`cancelled` when available.
* `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.

<Warning>
  **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](#step-7-confirm-the-transaction) before crediting anything.
</Warning>

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.

### Step 7: Confirm the transaction

<Tip>
  **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.
</Tip>

1. Follow the [Onramp & Offramp Webhooks](/webhooks/onramp) 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](/api-reference/rest-api/onramp-offramp/get-onramp-transactions-by-id) endpoint with a [CDP API key JWT](/api-reference/v2/authentication#generate-bearer-token-jwt-and-export), using the same `partnerUserRef` you sent in Step 2:

```
GET https://api.developer.coinbase.com/onramp/v1/buy/user/{partnerUserRef}/transactions
```

* 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).

## Sandbox testing

You can exercise the entire flow (registration, challenge, session, handoff, and redirect) without moving real funds, using an Xcode debug build or TestFlight's sandbox App Attest environment. No production App Attest entitlement is required to test.

<Steps>
  <Step title="Use a physical device">
    The iOS Simulator can't attest: there's no Secure Enclave. You need a physical device for every step that involves App Attest, in sandbox and production alike.
  </Step>

  <Step title="Prefix partnerUserRef with sandbox-">
    Send a `partnerUserRef` like `sandbox-user-1234` on the purchase challenge in [Step 2](#step-2-create-a-purchase-challenge). This is what puts the session into sandbox mode; there's no separate sandbox flag, endpoint, or allowlist. Once your app is allowlisted for App2App at all, the `sandbox-` prefix is all you need.
  </Step>

  <Step title="Run the flow with a development or sandbox App Attest key">
    Xcode debug builds and TestFlight's sandbox App Attest environment are both accepted directly: register and attest exactly as you would in production. The one restriction: a development or sandbox App Attest key can **only** create a session when `partnerUserRef` has the `sandbox-` prefix. Using one of these keys without the prefix is rejected.
  </Step>

  <Step title="Confirm the sandbox purchase completes">
    The user experience is identical to a real purchase: sign-in, confirmation, and redirect all run normally. No funds move. The redirect still includes a `transactionId`, generated for that sandbox purchase only, but unlike a live purchase, **no webhook fires and no row appears in the transactions API** for a sandbox session; the redirect is the only confirmation signal you'll get in sandbox.
  </Step>
</Steps>

* Keep the `sandbox-` prefix until you're ready to move real funds; it's independent of which App Attest environment (development, sandbox, or production) you're using.
* Buy Options, Buy Quote, and Buy Config responses are estimates in both sandbox and production. After sign-in, Coinbase applies the user's actual limits and eligibility.
* Apple Pay sandbox testing isn't available yet, so sandbox purchases can't currently exercise the Apple Pay payment sheet.

## Errors

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

| Situation                                                                            | HTTP status               | Action                                                                                    |
| ------------------------------------------------------------------------------------ | ------------------------- | ----------------------------------------------------------------------------------------- |
| Invalid request, invalid redirect host, or an address that doesn't match the network | 400 (`invalid_request`)   | Fix the request and retry                                                                 |
| Unknown purchase currency                                                            | 400 (`invalid_request`)   | Use a supported ticker or asset ID                                                        |
| Unknown challenge                                                                    | 400 (`invalid_request`)   | Create a new challenge                                                                    |
| Registration attestation failed                                                      | 400 (`invalid_request`)   | Create a new registration challenge; generate a new key if it fails again                 |
| Purchase assertion failed                                                            | 400 (`invalid_request`)   | Create a new purchase challenge; only re-register your device key after repeated failures |
| Development or sandbox App Attest key used without a `sandbox-` `partnerUserRef`     | 400 (`invalid_request`)   | Prefix `partnerUserRef` with `sandbox-`, or use a production App Attest key               |
| Challenge older than 5 minutes                                                       | 422 (`stale_attestation`) | Create a new challenge and re-attest                                                      |
| App not enabled for App2App                                                          | 403                       | [Contact the Coinbase team](https://support.cdp.coinbase.com/onramp-onboarding)           |
| Service unavailable                                                                  | 503                       | Retry later                                                                               |
| Rate limited                                                                         | 429                       | Back off and retry                                                                        |

## What to read next

* **[FAQ](/onramp/app2app/faq):** Common questions from partners integrating App2App
* **[Onramp & Offramp Webhooks](/webhooks/onramp):** Full webhook setup and payload reference
* **[Security Requirements](/onramp/security-requirements):** Domain allowlist requirements for your redirect URL
