# Integrate the Embedded Components onramp
Step-by-step integration guide for the Embedded Components onramp.
# React Native
This guide provides step-by-step instructions for you to build your integration. Use this when you need full control over the onramp flow, want to understand each API, or want to customize the flow for your app. Alternatively, see the [quickstart](https://docs.stripe.com/crypto/onramp/embedded-components-quickstart.md) for a minimal example that shows the full flow, or explore the [example app](https://github.com/stripe-samples/crypto-embedded-components-onramp?platform=react-native) for a complete React Native project that demonstrates the full crypto purchase flow.
## Before you begin
- The Embedded Components onramp is only available to users in the US (excluding New York).
- The Embedded Components API is in private preview. No API calls succeed until onboarding is complete, including in a *sandbox* (A sandbox is an isolated test environment that allows you to test Stripe functionality in your account without affecting your live integration. Use sandboxes to safely experiment with new features and changes). To request access:
1. [Submit your application](https://docs.stripe.com/crypto/onramp.md#submit-your-application).
1. [Sign up to join the waitlist](https://docs.stripe.com/crypto/onramp.md#sign-up).
1. Work with your Stripe account executive or solutions architect to complete onboarding before you start your integration. This includes, but isn’t limited to:
- Confirm that your account is enrolled in the required feature gates for the Embedded Components onramp APIs and Link OAuth APIs.
- Enable Link as a payment method in your [Dashboard](https://dashboard.stripe.com/settings/payment_methods).
- Obtain your OAuth client ID and client secret. Stripe provisions these credentials, and you need them for the [authentication flow](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#authentication).
- Confirm that your app is registered as a trusted application. We require this before you can use the SDK, including for simulator testing.
- After onboarding is complete, obtain your secret key and [publishable API key](https://docs.stripe.com/keys.md#obtain-api-keys) from the [API keys page](https://dashboard.stripe.com/apikeys).
## Mobile SDK configuration
### Step 1: Install the Stripe React Native SDK
For Expo projects, run the following to automatically install the version compatible with your Expo SDK:
```shell
npx expo install @stripe/stripe-react-native
```
For bare React Native projects, follow the installation instructions in the [README](https://github.com/stripe/stripe-react-native?tab=readme-ov-file). See the [requirements section](https://github.com/stripe/stripe-react-native?tab=readme-ov-file#requirements) for the minimum compatible Expo SDK, React Native, iOS, and Android versions.
### Step 2: Add the onramp dependency (Client-side)
By default, the onramp dependency isn’t included in the Stripe React Native SDK to reduce bundle size. Include it as follows, depending on your platform.
#### Bare React Native App
```shell
# android/gradle.properties
StripeSdk_includeOnramp=true
# ios/Podfile – add pod
pod 'stripe-react-native/Onramp', path: '../node_modules/@stripe/stripe-react-native'
```
#### Expo
```javascript
// [Expo] Add `"includeOnramp": true` (default false)
{
"expo": {
...
"plugins": [
[
"@stripe/stripe-react-native",
{
"merchantIdentifier": string | string [],
"enableGooglePay": boolean,
"includeOnramp": boolean
}
]
]
}
}
```
```shell
# Add Expo BuildProperties (https://docs.expo.dev/versions/latest/sdk/build-properties/)
npx expo install expo-build-properties
```
If you’re testing on a physical device, install [expo-dev-client](https://docs.expo.dev/versions/latest/sdk/dev-client/) to avoid Metro bundler connection issues:
```shell
npx expo install expo-dev-client
```
### Step 3: Use StripeProvider (Client-side)
Wrap your app with [StripeProvider](https://stripe.dev/stripe-react-native/api-reference/functions/StripeProvider.html) at a high level so Stripe functionality is available throughout your component tree. Key properties:
- `publishableKey`: Your Stripe publishable key.
- `merchantIdentifier`: Your Apple Merchant ID (required for Apple Pay).
- `urlScheme`: Required for return URLs in authentication flows.
You need this component to initialize the Stripe SDK in your React Native application before using payment-related features.
```javascript
import { StripeProvider } from '@stripe/stripe-react-native';
function App() {
return (
{/* Your app components */}
);
}
```
### Step 4: Configure the onramp SDK (Client-side)
Before you can successfully call any onramp APIs, you need to configure the SDK using the `configure` method. It’s provided by the [useOnramp()](https://stripe.dev/stripe-react-native/api-reference/functions/useOnramp.html) hook. The `configure` method takes an instance of [Onramp.Configuration](https://stripe.dev/stripe-react-native/api-reference/types/Onramp.Configuration.html) to customize your business display name and lightly customize elements in Stripe-provided interfaces, such as the user’s wallet, one-time passcode authorization, and identity verification UI.
```javascript
import { useOnramp } from '@stripe/stripe-react-native';
function OnrampComponent() {
const { configure } = useOnramp();
React.useEffect(() => {
const setupOnramp = async () => {
const result = await configure({
merchantDisplayName: 'My Crypto App',
appearance: {
lightColors: {
primary: '#2d22a1',
contentOnPrimary: '#ffffff',
borderSelected: '#07b8b8'
},
darkColors: {
primary: '#800080',
contentOnPrimary: '#ffffff',
borderSelected: '#526f3e'
},
style: 'ALWAYS_DARK',
primaryButton: { cornerRadius: 8, height: 48 }
}
});
if (result.error) {
console.error('Configuration failed:', result.error.message);
}
};
setupOnramp();
}, [configure]);
return null;
}
```
## Authentication
### Step 1: Check for a Link account (Client-side)
The customer must have a [Link](https://link.com) account to use the onramp APIs. Use `hasLinkAccount` to determine if the customer’s email is associated with an existing Link account. See the [HasLinkAccountResult](https://stripe.dev/stripe-react-native/api-reference/types/Onramp.HasLinkAccountResult.html) for the return type and the [OnrampError](https://stripe.dev/stripe-react-native/api-reference/enums/OnrampError.html) for the error type.
- If they have an account, proceed to [Authorize](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#step-authorize).
- If they don’t, use [Register a new Link user](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#step-register-a-new-link-user-if-needed), then proceed to [Authorize](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#step-authorize).
```jsx
const { hasLinkAccount, registerLinkUser, authorize } = useOnramp();
const linkResult = await hasLinkAccount('user@example.com');
if (linkResult.error) return;
if (linkResult.hasLinkAccount) {
// Proceed to authorization.
} else {
// Register the user first (see next step).
}
```
### View example
```javascript
function AuthComponent() {
const { hasLinkAccount, registerLinkUser } = useOnramp();
const handleAuth = async () => {
const linkResult = await hasLinkAccount('user@example.com');
if (linkResult.error) {
// Lookup failed. Show linkResult.error.message and stop.
return;
}
if (linkResult.hasLinkAccount) {
// User has Link account. Proceed to authorization.
} else {
const userInfo = {
email: 'user@example.com',
phone: '+12125551234',
country: 'US',
fullName: 'John Smith',
};
const registerResult = await registerLinkUser(userInfo);
if (registerResult.error) {
// Registration failed. Show registerResult.error.message and let the user fix the data.
} else if (registerResult.customerId) {
// User registered. Proceed to authorization.
}
}
};
return ;
}
```
### Step 2: Register a new Link user (if needed) (Client-side)
If the customer doesn’t have a Link account, use `registerLinkUser` to create one with the customer information collected from your UI. Upon successful account creation, proceed to [Authorize](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#step-authorize). See the [RegisterLinkUserResult](https://stripe.dev/stripe-react-native/api-reference/types/Onramp.RegisterLinkUserResult.html) for the return type and the [OnrampError](https://stripe.dev/stripe-react-native/api-reference/enums/OnrampError.html) for the error type.
```jsx
const userInfo = {
email: 'user@example.com',
phone: '+12125551234',
country: 'US',
fullName: 'John Smith',
};
const registerResult = await registerLinkUser(userInfo);
if (registerResult.error) return;
if (registerResult.customerId) {
// Proceed to authorization.
}
```
### Step 3: Authorize (Client-side) (Server-side)
The primary method of authentication is through two-factor authorization.
#### Create a LinkAuthIntent
A `LinkAuthIntent` tracks scopes of the OAuth requests and the status of user consent. Your back end calls the [Create a LinkAuthIntent](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#create-a-linkauthintent) API with your `OAUTH_CLIENT_ID` and the onramp OAuth scopes. LinkAuthIntent will return a `authIntentId`, which your back end can share with your client application.
#### Client-side
```jsx
// createAuthIntent is a client-side function you must implement.
// Call your back end to create a LinkAuthIntent using the API.
const authIntentResponse = await createAuthIntent(
email,
// This is your OAUTH_CLIENT_ID, which identifies your application in the Link OAuth flow.
authToken,
'kyc.status:read,crypto:ramp'
);
const authIntentId = authIntentResponse.data.authIntentId;
```
#### Server-side
```shell
curl -X POST https://login.link.com/v1/link_auth_intent \
-H "Authorization: Bearer $STRIPE_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "oauth_client_id": "$OAUTH_CLIENT_ID", "oauth_scopes": "kyc.status:read,crypto:ramp"}'
# Response
{
"id": "lai_xxxx",
"expires_at": 1756238966
}
```
#### User consents
The client calls `authorize` with the `authIntentId` to complete consent. The `authorize` SDK looks up and verifies the user’s Link session, shows them what your app is requesting (the OAuth scopes) on a consent screen or inline on the OTP screen, and collects their approval.
The SDK then sends that consent to Stripe so your backend can exchange the intent for an access token and finish the flow. The result includes a `customerId` that must be used for all subsequent onramp API calls. See the [AuthorizeResult](https://stripe.dev/stripe-react-native/api-reference/types/Onramp.AuthorizeResult.html) for the return type and the [OnrampError](https://stripe.dev/stripe-react-native/api-reference/enums/OnrampError.html) for the error type.
```jsx
const result = await authorize(authIntentId);
if (result?.error) {
// Error occurred. Show result.error.message and stop.
} else if (result?.status === 'Consented' && result.customerId) {
// User consented, store the authIntentId and re-use for future `authorize` calls
// Call your backend to exchange for access token, then proceed to identity flow.
} else if (result?.status === 'Denied') {
// User denied. Explain they need to consent to continue, or let them try again.
} else {
// User canceled. Dismiss and let them try again.
}
```
#### Request access tokens
If the result is `Consented`, your backend calls the [Retrieve Access Tokens](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#retrieve-access-tokens) API to request access tokens. Store the access token and use it on all subsequent onramp API requests (for example, in the `Stripe-OAuth-Token` header).
```shell
# Request
curl -X POST https://login.link.com/v1/link_auth_intent/{authIntentId}/tokens \
-H "Authorization: Bearer $STRIPE_SECRET_KEY"
# Response
{
"access_token": "liwltoken_xxx",
"token_type": "Bearer",
"expires_in": 3600,
"refresh": {
"refresh_token": "liwlrefresh_xxx",
"expires_in": 7776000
}
}
```
### Use seamless sign-in for a returning customer (Client-side) (Server-side)
To reduce friction for a returning customer, you can skip the OTP dialog by storing the `authIntentId` after a successful `authorize` call and exchanging it for a `linkAuthTokenClientSecret` through your backend in the next session. Pass the `linkAuthTokenClientSecret` to `authenticateUserWithToken` from `useOnramp` to sign in without customer interaction.
> Seamless sign-in requires your Stripe account to be enabled for this feature. Work with your Stripe account executive or solutions architect to enable seamless sign-in for your account.
If authentication fails, for example because the token expired, clear the stored token and fall back to the standard `hasLinkAccount` and `authorize` flow.
```typescript
const { authenticateUserWithToken } = useOnramp();
const result = await authenticateUserWithToken(linkAuthTokenClientSecret);
if (result?.error) {
// Seamless sign-in failed. Clear stored token and fall back to authorize.
} else {
// Customer authenticated. Proceed to the onramp session.
}
```
For a complete implementation guide including how to request the required scope, store tokens, and handle failures, see [Add seamless sign-in to your React Native onramp integration](https://docs.stripe.com/crypto/onramp/seamless-sign-in-integration-guide.md).
## Identity
For details on KYC tiers and their identity requirements, see the [KYC integration guide](https://docs.stripe.com/crypto/onramp/kyc-integration-guide.md).
### Step 1: Check if KYC collection is needed (Server-side)
Your back end calls the [Retrieve a CryptoCustomer](https://docs.stripe.com/api/crypto/customers/retrieve.md) API with the `customerId`. Inspect the response `verifications` array. If it includes an entry with type `kyc_verified` and status `not_started`, proceed to [Collect KYC](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#step-collect-kyc-if-needed).
```shell
curl https://api.stripe.com/v1/crypto/customers/{customerId} \
-H "Authorization: Bearer $STRIPE_SECRET_KEY" \
-H "Stripe-OAuth-Token: $ACCESS_TOKEN"
```
### View example
```javascript
async function getCryptoCustomer(req, res) {
const { id } = req.params;
const oauthToken = req.headers['stripe-oauth-token'];
const response = await fetch(
`https://api.stripe.com/v1/crypto/customers/${id}`,
{
headers: {
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
'Stripe-OAuth-Token': oauthToken ?? '',
},
}
);
const customer = await response.json();
const verifications = customer.verifications ?? [];
const kycVerified = verifications.find((v) => v.name === 'kyc_verified');
const idDocVerified = verifications.find(
(v) => v.name === 'id_document_verified'
);
res.json({
customerId: customer.id,
providedFields: customer.provided_fields ?? [],
kycStatus: kycVerified?.status ?? 'not_started',
idDocStatus: idDocVerified?.status ?? 'not_started',
});
}
```
### Step 2: Collect KYC (if needed) (Client-side)
If the customer needs KYC verification, your client calls `attachKycInfo` to collect and submit user KYC data. Present your own interface to the user to collect this KYC information. See [KycInfo](https://stripe.dev/stripe-react-native/api-reference/types/Onramp.KycInfo.html) for the full parameter type and the [OnrampError](https://stripe.dev/stripe-react-native/api-reference/enums/OnrampError.html) for the error type.
```jsx
import { useOnramp } from '@stripe/stripe-react-native';
function AttachKYCComponent() {
const { attachKycInfo } = useOnramp();
const handleAttachKycInfo = async () => {
const kycInfo = {
firstName: "FirstName",
lastName: "LastName",
idNumber: '000000000', // Full ID number — for US, only SSN is currently supported.
dateOfBirth: { // Object with numeric fields, not a date string.
day: 1, // Day of month (1-31).
month: 1, // Month of year (1-12).
year: 1990, // Full 4-digit year.
},
address: {
line1: '123 Main St',
line2: 'Apt 4B',
city: 'San Francisco',
state: 'CA',
postalCode: '94111',
country: 'US',
},
};
const result = await attachKycInfo(kycInfo);
if (result?.error) {
// KYC failed to attach. Show result.error.message and let the user fix the data or retry.
} else {
// KYC attached. Proceed to identity verification (if needed) or payment flow.
}
};
return ;
}
```
### Step 3: Verify KYC and update address (if needed) (Client-side)
When a user already has KYC information, use `presentKycInfoVerification` to let them review and update it. This method presents a Stripe-hosted screen showing the user’s existing KYC data. See the [VerifyKycResult](https://stripe.dev/stripe-react-native/api-reference/types/Onramp.VerifyKycResult.html) for the return type and the [OnrampError](https://stripe.dev/stripe-react-native/api-reference/enums/OnrampError.html) for the error type.
> Only address updates are currently supported. Other KYC fields can’t be modified.
The typical flow is:
1. Call `presentKycInfoVerification(null)` to show existing KYC data. The SDK returns `Confirmed` if the user accepts, or `UpdateAddress` if they want to edit their address.
1. If the result status is `UpdateAddress`, show your address form to collect a new address.
1. Call `presentKycInfoVerification(updatedAddress)` with the new address to submit and verify it.
1. If the result status is `Confirmed`, the address is updated.
```jsx
import { useOnramp } from '@stripe/stripe-react-native';
function VerifyKYCComponent() {
const { presentKycInfoVerification } = useOnramp();
const handlePresentKycVerification = async () => {
// Step 1: Show existing KYC data for review.
const reviewResult = await presentKycInfoVerification(null);
if (reviewResult?.error) {
// Verification failed or user canceled.
return;
}
if (reviewResult?.status === 'Confirmed') {
// User confirmed existing data. Proceed to identity verification (if needed) or payment flow.
return;
}
if (reviewResult?.status === 'UpdateAddress') {
// Step 2: User wants to edit their address. Show your address form and collect input.
const updatedAddress = await collectAddressFromUser();
// Step 3: Submit the updated address.
const updateResult = await presentKycInfoVerification({
line1: updatedAddress.line1,
line2: updatedAddress.line2,
city: updatedAddress.city,
state: updatedAddress.state,
postalCode: updatedAddress.postalCode,
country: updatedAddress.country,
});
if (updateResult?.error) {
// Update failed. Show updateResult.error.message and let the user retry.
} else if (updateResult?.status === 'Confirmed') {
// Address updated. Proceed to identity verification (if needed) or payment flow.
}
}
};
return ;
}
```
### Step 4: Verify identity (if needed) (Client-side)
Some users must verify their identity before continuing with checkout. When required, use the `verifyIdentity` method. It presents a Stripe-hosted flow where the user uploads an identity document and a selfie.
Verification is asynchronous. After the user completes the flow, your backend can call the [Retrieve a CryptoCustomer](https://docs.stripe.com/api/crypto/customers/retrieve.md) API and inspect the verifications array to see the results.
On Android, the Stripe Identity SDK requires the app’s theme to extend `Theme.MaterialComponents`. For example, Expo defaults to `Theme.AppCompat`, so you need a config plugin to change the theme.
```jsx
import { useOnramp } from '@stripe/stripe-react-native';
function VerifyIdentityComponent() {
const { verifyIdentity } = useOnramp();
const handleVerifyIdentity = async () => {
const result = await verifyIdentity();
if (result?.error?.code === 'Canceled') {
// User canceled. Dismiss and let them try again.
} else if (result?.error) {
// Verification failed. Show result.error.message and let the user retry.
} else {
// Identity verified. Proceed to payment flow (register wallet, collect payment method).
}
};
return ;
}
```
## Payment
### Step 1: Register a crypto wallet (Client-side) (Server-side)
A [ConsumerWallet](https://docs.stripe.com/api/crypto/consumer_wallets/object.md) must be registered before you can create a [PaymentToken](https://docs.stripe.com/api/crypto/payment_tokens/list.md). This validates that the address is valid for the given network. Your back end can call the [List ConsumerWallets](https://docs.stripe.com/api/crypto/consumer_wallets/list.md) API to see whether the user already has wallets on file.
If the list is empty or the user wants to add another address, have the client call `registerWalletAddress` with the user’s chosen address and network. Replace the address and network with user-provided values. You can use a previously registered wallet in future sessions. For all valid network values, see [Network](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#supported-networks-and-currencies).
#### List ConsumerWallets
```shell
curl "https://api.stripe.com/v1/crypto/customers/{customerId}/crypto_consumer_wallets" \
-H "Authorization: Bearer $STRIPE_SECRET_KEY" \
-H "Stripe-OAuth-Token: $ACCESS_TOKEN"
```
#### Register a wallet
```jsx
import { useOnramp } from '@stripe/stripe-react-native';
function RegisterWalletComponent() {
const { registerWalletAddress } = useOnramp();
const handleRegisterWallet = async () => {
const result = await registerWalletAddress("0x000…", Onramp.CryptoNetwork.ethereum);
if (result?.error) {
// Registration failed. Show result.error.message and let the user retry with a different address.
} else {
// Wallet registered. Proceed to collect payment method.
}
};
return ;
}
```
### Step 2: Collect a payment method (Client-side) (Server-side)
You must first collect a payment method before a transaction can occur. Your back end can call the [List PaymentTokens](https://docs.stripe.com/api/crypto/payment_tokens/list.md) API to see which payment methods the user already has. If the list is empty or the user wants to use a different method, have the client call `collectPaymentMethod`.
Card, Bank Account, Apple Pay, and Google Pay are supported. For Card and Bank Account, `collectPaymentMethod` presents Stripe’s wallet user interface, which lists existing stored payment methods, allows the user to add new ones, and select one. Upon successful payment method selection, it returns an instance of [CollectPaymentMethodResult](https://stripe.dev/stripe-react-native/api-reference/types/Onramp.CollectPaymentMethodResult.html), which includes a `displayData` property (icon, label, sublabel) that you can use in your UI to show the selected payment method.
#### List PaymentTokens
```shell
curl https://api.stripe.com/v1/crypto/customers/{customerId}/payment_tokens \
-H "Authorization: Bearer $STRIPE_SECRET_KEY" \
-H "Stripe-OAuth-Token: $ACCESS_TOKEN"
```
#### Collect a payment method
```jsx
import { useOnramp } from '@stripe/stripe-react-native';
function CollectPaymentMethodComponent() {
const { collectPaymentMethod } = useOnramp();
const handleCollectPaymentMethod = async () => {
const result = await collectPaymentMethod('Card');
if (result?.error) {
// Collection failed. Show result.error.message and let the user retry.
} else if (result?.displayData) {
// Payment method selected. Use result.displayData in your UI, then call createCryptoPaymentToken.
} else {
// User canceled. Dismiss and let them try again.
}
};
return ;
}
```
#### Collect Apple Pay
To collect Apple Pay, first check [isPlatformPaySupported](https://stripe.dev/stripe-react-native/api-reference/functions/isPlatformPaySupported.html) in [useStripe()](https://stripe.dev/stripe-react-native/api-reference/functions/useStripe.html). See [Apple Pay on React Native](https://docs.stripe.com/apple-pay.md?platform=react-native#check-if-apple-pay-supported). If the user chooses Apple Pay, pass an instance of [PlatformPay.PaymentMethodParams](https://stripe.dev/stripe-react-native/api-reference/types/PlatformPay.PaymentMethodParams.html) into `collectPaymentMethod`.
#### Collect Google Pay
To collect Google Pay, first check [isPlatformPaySupported](https://stripe.dev/stripe-react-native/api-reference/functions/isPlatformPaySupported.html) in [useStripe()](https://stripe.dev/stripe-react-native/api-reference/functions/useStripe.html). See [Google Pay on React Native](https://docs.stripe.com/google-pay.md?platform=react-native#react-native-create-enable-google-pay). If the user chooses Google Pay, pass an instance of [PlatformPay.PaymentMethodParams](https://stripe.dev/stripe-react-native/api-reference/types/PlatformPay.PaymentMethodParams.html) into `collectPaymentMethod`.
### Step 3: Create a payment token (Client-side)
Create a [PaymentToken](https://docs.stripe.com/api/crypto/payment_tokens/list.md) by calling `createCryptoPaymentToken`. Use the returned token when creating the [CryptoOnrampSession](https://docs.stripe.com/api/crypto/onramp_sessions/object.md).
```jsx
import { useOnramp } from '@stripe/stripe-react-native';
function CreatePaymentTokenComponent() {
const { createCryptoPaymentToken } = useOnramp();
const handleCreateCryptoPaymentToken = async () => {
const result = await createCryptoPaymentToken();
if (result?.error) {
// Token creation failed. Show result.error.message and let the user retry.
} else {
// Token created. Pass result.cryptoPaymentToken to createOnrampSession.
}
};
return ;
}
```
### Step 4: Create a crypto onramp session (Server-side)
From your UI, determine the amount, source currency (for example, `usd`), destination currency (for example, `usdc`), and network. Your backend calls the [Create a CryptoOnrampSession](https://docs.stripe.com/api/crypto/onramp_sessions/create.md) API to create a [CryptoOnrampSession](https://docs.stripe.com/api/crypto/onramp_sessions/object.md). The Stripe React Native SDK doesn’t provide APIs for creating a crypto onramp session. It happens on your backend. The example below shows how a client application might call your backend. Adapt it to your use case.
#### Client-side
```jsx
function CreateOnrampSessionComponent() {
const handleCreateOnrampSession = async () => {
// createOnrampSession is a client-side function you must implement.
// Call your back end to create a CryptoOnrampSession using the API.
const result = await createOnrampSession({
uiMode: "headless",
cryptoCustomerId,
cryptoPaymentToken,
sourceAmount: 100.0, // Pass source_amount OR destination_amount, not both.
sourceCurrency: "usd",
destinationCurrency: "usdc",
destinationNetwork: Onramp.CryptoNetwork.bitcoin, // Singular: pins the transaction to this network.
destinationNetworks: [Onramp.CryptoNetwork.bitcoin], // Array: must be set when walletAddress is set.
walletAddress,
customerIpAddress,
});
if (result.success) {
const sessionId = result.data.id;
// Call performCheckout with sessionId.
} else {
// Creation failed. Show error and let the user retry.
}
};
return ;
}
```
#### Server-side
```shell
curl -X POST https://api.stripe.com/v1/crypto/onramp_sessions \
-H "Authorization: Bearer $STRIPE_SECRET_KEY" \
-H "Stripe-OAuth-Token: $ACCESS_TOKEN" \
-d "ui_mode=headless" \
-d "crypto_customer_id=crc_xxx" \
-d "payment_token=cpt_xxx" \
-d "source_amount=100" \ # Pass source_amount OR destination_amount, not both.
-d "source_currency=usd" \
-d "destination_currency=usdc" \
-d "destination_network=base" \ # Singular: pins the transaction to this network.
-d "destination_networks[]=base" \ # Array: required when wallet_address is set. Must match destination_network.
-d "wallet_address=0x1234567890abcdef1234567890abcdef12345678" \
-d "customer_ip_address=203.0.113.1"
```
### Step 5: Perform checkout (Client-side) (Server-side)
Call `performCheckout` to run the checkout flow for a crypto onramp session. It presents a UI for any required actions such as 3DS.
You must implement the client-side callback `fetchClientSecretFromBackend`, which the SDK invokes to retrieve the checkout client secret. Have it call your back end, which calls the [onramp session checkout endpoint](https://docs.stripe.com/api/crypto/onramp_sessions/checkout.md) with the session ID. The response includes the `client_secret`, which your callback can then return to the SDK. This callback might be called more than once during a single checkout, for example after the SDK handles a required next action such as 3DS.
For ACH, the API may indicate that `mandate_data` is missing. Collect acceptance and send it on a later checkout call if required.
#### Client-side
```jsx
import { useOnramp } from '@stripe/stripe-react-native';
function CheckoutComponent() {
const { performCheckout } = useOnramp();
const handleCheckout = async () => {
const result = await performCheckout(sessionId, async () => {
// fetchClientSecretFromBackend is a client-side function you must implement.
// It calls your back end, which calls the onramp session checkout endpoint with the session ID
// and returns the client_secret from the response.
// Return the client secret on success, or throw an Error on failure.
const clientSecret = await fetchClientSecretFromBackend(sessionId);
return clientSecret;
});
if (result.error?.code === 'Canceled') {
// User canceled. Dismiss and let them try again.
} else if (result.error) {
// Checkout failed. Show result.error.message and let the user retry.
} else {
// Purchase complete. Show success and that crypto was sent to their wallet.
}
};
return ;
}
```
#### Server-side
```shell
curl -X POST https://api.stripe.com/v1/crypto/onramp_sessions/{sessionId}/checkout \
-H "Authorization: Bearer $STRIPE_SECRET_KEY" \
-H "Stripe-OAuth-Token: $ACCESS_TOKEN"
```
When the API returns 200 or 202 but the purchase isn’t done, the response body includes the [CryptoOnrampSession](https://docs.stripe.com/api/crypto/onramp_sessions.md) object with `transaction_details.last_error` set. For SDK integrations, `performCheckout` handles payment next actions such as 3DS internally, so the table below only covers `last_error` values that require explicit action from your integration:
| **last\_error** | **Description** | **How to handle** |
| ------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `missing_kyc` | KYC verification is required. | Have the user complete KYC in the SDK (for example, `attachKycInfo`). Then call checkout again. |
| `missing_document_verification` | Identity document verification is required. | Have the user complete verification in the SDK (for example, `verifyIdentity`). Then call checkout again. |
| `charged_with_expired_quote` | Quote expired. | Call [Refresh a Quote](https://docs.stripe.com/api/crypto/onramp_sessions/quote.md) API, then call checkout again. |
| `transaction_limit_reached` | User’s limit exceeded. | Display an error message. |
| `location_not_supported` | User’s location isn’t supported. | Show that the service isn’t available in their region. |
| `transaction_failed` | Generic failure. | Display a generic error message. |
| `missing_consumer_wallet` | The wallet address doesn’t exist for the current user. | Have the user register the wallet, then call checkout again. |
The following pseudocode shows how to handle these cases in a retry loop:
```jsx
import { useOnramp } from '@stripe/stripe-react-native';
function CheckoutWithRetryComponent() {
const { attachKycInfo, verifyIdentity, registerWalletAddress, performCheckout } = useOnramp();
const handleCheckout = async () => {
// Step 1: Create a new session on your backend before starting checkout.
let sessionId = await createSessionOnBackend();
for (let attempt = 0; attempt < 5; attempt++) {
const result = await performCheckout(sessionId, async () => {
// Your backend calls POST /v1/crypto/onramp_sessions/{sessionId}/checkout.
// Return { clientSecret, lastError } from your backend response.
return await fetchClientSecretFromBackend(sessionId);
});
if (!result.error) {
// Purchase complete.
return;
}
if (result.error.code === 'Canceled') {
// User canceled. Stop retrying.
return;
}
// Step 2: Inspect last_error from the session to decide the next action.
const session = await fetchSessionFromBackend(sessionId);
const lastError = session.transaction_details?.last_error;
if (lastError === 'missing_kyc') {
// Prompt user to provide KYC info, then retry checkout on the same session.
await attachKycInfo({ firstName, lastName, idNumber, dateOfBirth, address });
} else if (lastError === 'missing_document_verification') {
// Prompt user to complete identity verification, then retry on the same session.
await verifyIdentity();
} else if (lastError === 'missing_consumer_wallet') {
// Prompt user to register a wallet, then retry on the same session.
await registerWalletAddress(walletAddress, network);
} else if (lastError === 'charged_with_expired_quote') {
// Quote expired. Refresh the quote on your backend, then retry checkout on the same session.
await refreshQuoteOnBackend(sessionId);
} else {
// Terminal errors: transaction_limit_reached, location_not_supported, transaction_failed.
// Do not retry. Show an appropriate error message.
showError(lastError);
return;
}
}
};
return ;
}
```
## React Native SDK
| SDK method | Presents a UI | What the user sees |
| ------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `authorize(authIntentId)` | Yes | Link consent screen (or consent inline on OTP screen). |
| `attachKycInfo` | Optional | Initial KYC submission only. Collect KYC in your own UI and pass data in. Errors if the user is already verified. |
| `presentKycInfoVerification` | Yes | Review KYC and update addresses for verified users. Pass `null` to review existing data, or an address object to update. |
| `verifyIdentity` | Yes | The Stripe-hosted flow (document + selfie). |
| `collectPaymentMethod` (Card / BankAccount) | Yes | The Stripe wallet UI: list saved methods, add new, choose one. |
| `performCheckout` | Maybe | Only when needed (for example, 3DS). |
| `registerWalletAddress` | No | No UI. You pass the address and network. |
## Troubleshooting
### App attestation is missing or device can’t use native Link
The Embedded Components onramp SDKs require device attestation to verify that API requests come from a legitimate app. To troubleshoot app attestation errors, check the following:
- Confirm your app is registered as a trusted application with Stripe. Contact your Stripe account executive or solutions architect to register your app. Stripe requires registration for both *sandbox* (A sandbox is an isolated test environment that allows you to test Stripe functionality in your account without affecting your live integration. Use sandboxes to safely experiment with new features and changes) and live mode. Make sure the bundle identifier on iOS or package name on Android matches the value registered with Stripe.
- Confirm your app includes the App Attest entitlement on iOS. Your app must include the `com.apple.developer.devicecheck.appattest-environment` entitlement.
- Confirm you’re running on a supported device and using a supported distribution method. We support simulators in *sandboxes* (A sandbox is an isolated test environment that allows you to test Stripe functionality in your account without affecting your live integration. Use sandboxes to safely experiment with new features and changes), but not in live mode. In live mode, use a physical device. On iOS, you can run from Xcode on a physical device, distribute through [TestFlight](https://developer.apple.com/testflight/), or publish to the App Store. When running from Xcode, set `com.apple.developer.devicecheck.appattest-environment` to `production` in your entitlements file, and delete and reinstall the app if you previously used the `development` environment.
- If you’re testing on an Android emulator and see the error `Native Link is not available`, confirm that your emulator uses a system image that includes Google APIs or Google Play. Standard emulator images without Google APIs don’t support the app attestation required by the SDK.
### Unrecognized request URL
If your API calls return a `404` with an `invalid_request_error` and the message `Unrecognized request URL`, your account might not be enrolled in the private preview or might be missing one or more required feature gates.
Contact your Stripe account executive or solutions architect to confirm that your account has access to all required feature gates for the Embedded Components onramp.
## Supported networks and currencies
### Livemode
| Currency | Network | Address |
| ----------------------------- | -------------------------- | ---------------------------------------------------------------------------------- |
| USDC (`usdc`) | Solana (`solana`) | EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v |
| USDC (`usdc`) | Base (`base`) | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
| USDC (`usdc`) | Sui (`sui`) | 0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC |
| USDC.e (`usdc`) | Tempo (`tempo`) | 0x20c000000000000000000000b9537d11c60e8b50 |
| USDC (`usdc`) | Ethereum (`ethereum`) | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 |
| USDB (`usdb`) | Solana (`solana`) | ENL66PGy8d8j5KNqLtCcg4uidDUac5ibt45wbjH9REzB |
| USDsui (`usdsui`) | Sui (`sui`) | 0x44f838219cf67b058f3b37907b655f226153c18e33dfcd0da559a844fea9b1c1::usdsui::USDSUI |
| USDC (`usdc`) | Arbitrum (`arbitrum`) | 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 |
| USDC (`usdc`) | World Chain (`worldchain`) | 0x79A02482A880bCE3F13e09Da970dC34db4CD24d1 |
| USDT (`usdt`) | Ethereum (`ethereum`) | 0xdac17f958d2ee523a2206206994597c13d831ec7 |
| Phantom Cash (`phantom_cash`) | Solana (`solana`) | CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH |
### Sandbox (test mode)
| Currency | Network |
| ------------- | --------------------- |
| USDC (`usdc`) | Solana (`solana`) |
| USDC (`usdc`) | Ethereum (`ethereum`) |
| USDC (`usdc`) | Base (`base`) |
## Testing
> You can test your integration in two ways, in a *sandbox* (A sandbox is an isolated test environment that allows you to test Stripe functionality in your account without affecting your live integration. Use sandboxes to safely experiment with new features and changes) using test API keys, or in *live mode* (Use this mode when you’re ready to launch your app. Card networks or payment providers process payments) using live API keys. Both require your app to be registered as a trusted application with Stripe before any SDK calls succeed, including on a simulator.
### Sandbox testing
Use a *sandbox* (A sandbox is an isolated test environment that allows you to test Stripe functionality in your account without affecting your live integration. Use sandboxes to safely experiment with new features and changes) to build and verify your integration without real charges or real KYC. Use your `sk_test_...` secret key and `pk_test_...` publishable key. We recommend using a sandbox account rather than legacy test mode — legacy test mode is on a deprecation path, and sandboxes are the current, supported way to test.
If you’re testing on an Android emulator, use a system image that includes Google APIs or Google Play to prevent SDK calls from failing because of app attestation errors.
#### Test values
Use the following values when testing each step of the flow in *sandbox* (A sandbox is an isolated test environment that allows you to test Stripe functionality in your account without affecting your live integration. Use sandboxes to safely experiment with new features and changes):
| Step | Field | Test value |
| -------------- | ------------------ | ----------------------------------------------------- |
| Authentication | SMS / OTP code | `000000` |
| KYC | Name | `John Verified` |
| KYC | ID Number (SSN) | `000000000` |
| KYC | Address line 1 | `address_full_match` |
| KYC | State | Two-letter code (for example, `WA`, not `Washington`) |
| Payment | Credit card number | `4242 4242 4242 4242` |
When testing, use a purchase amount of 100 USD or less. The liquidity partner enforces a 100 USD limit in the testing environment.
#### Verification
To test verification behavior in a *sandbox* (A sandbox is an isolated test environment that allows you to test Stripe functionality in your account without affecting your live integration. Use sandboxes to safely experiment with new features and changes):
- For phone verification, use `Verified` as the last name. This returns a verified result. Phone verification is asynchronous, so your integration should poll the verification status until the status is verified or rejected.
- For ID document verification, the `verifyIdentity` SDK presents a testmode UI that lets you select the verification outcome directly, without uploading a real document or selfie. This lets you test all verification outcomes (success, failure, and so on) without manual review delays.
### Live mode testing
Live mode testing validates production mode and has stricter requirements than sandbox testing.
#### Requirements
- **Live API keys**: Use your `sk_live_...` secret key and `pk_live_...` publishable key. Test keys don’t work in live mode.
- **Real card charges**: Live mode transactions charge a real payment method. Test card numbers don’t work.
- **Real KYC**: Users must complete real identity verification. Sandbox test values don’t apply in live mode.
- **Physical device**: The SDK requires a physical iOS or Android device. Live mode doesn’t support simulators or emulators. On iOS, you can run your app from Xcode on a physical device or distribute it through [TestFlight](https://developer.apple.com/testflight/). When running from Xcode, set the `com.apple.developer.devicecheck.appattest-environment` entitlement to `production`. If you previously ran with the `development` environment, delete and reinstall the app to clear any cached states.
## LinkAuthIntent APIs
### Create a LinkAuthIntent
Creates a `LinkAuthIntent` to start a [Log in with Link](https://link.com/) flow. Send the OAuth client id and scopes you need. The API returns an intent id and expiration.
To obtain your `OAUTH_CLIENT_ID`, contact your Stripe account executive or solutions architect. Stripe provisions the credential as part of your onboarding.
OAuth scopes used when creating a `LinkAuthIntent`:
| Scope (string) | Description |
| ------------------------- | ----------------------------------------------------------------------------------------- |
| `kyc.status:read` | Read the customer’s KYC verification status. |
| `crypto:ramp` | Add crypto wallets to deposit from the customer’s account on their behalf. |
| `auth.persist_login:read` | Allow use of a persisted token for seamless sign-in. (For Android, iOS, and React Native) |
```shell
curl -X POST https://login.link.com/v1/link_auth_intent \
-H "Authorization: Bearer $STRIPE_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "oauth_client_id": "$OAUTH_CLIENT_ID", "oauth_scopes": "kyc.status:read,crypto:ramp"}'
```
```json
// Response
{
"id": "lai_xxxx",
"expires_at": 1756238966
}
```
**Parameters**
| Parameter | Type | Description |
| ----------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `email` | string (required) | The user’s email for looking up an existing Link customer. Provide either `email` or `hashed_email`, not both. |
| `hashed_email` | string (required*) | A SHA256 hash of the plain text email for privacy-sensitive flows. Provide either `email` or `hashed_email`, not both. |
| `oauth_client_id` | string (required) | Your OAuth client id (for example, from Link). Identifies your application in the OAuth flow. |
| `oauth_scopes` | string (required) | Comma-separated list of OAuth scopes (for example, `kyc.status:read,crypto:ramp`). Defines the permissions you’re requesting. |
| `data_sharing_merchant` | string (optional) | When set, the recipient business ID for data-sharing (for example, crypto onramp). Must be a valid business ID enabled to receive OAuth tokens. |
**Returns**
| Field | Type | Description |
| ------------ | ------- | -------------------------------------------------------------------- |
| `id` | string | Unique identifier for the `LinkAuthIntent` (for example, `lai_xxx`). |
| `expires_at` | integer | Unix timestamp when the intent expires. |
**Errors**
| HTTP status | Cause |
| ----------- | ------------------------------------------------------------------------------------------------ |
| 400 | Missing or invalid request body. |
| 403 | The CreateLinkAuthIntent isn’t enabled for the business or the API key is invalid or missing. |
| 404 | Can’t find the OAuth client for authIntentId, or the provided email has no active Link customer. |
| 409 | The Link customer previously revoked the connection with this partner. |
### Retrieve access tokens
Retrieving access tokens exchanges a consented `LinkAuthIntent` for an OAuth access token. Call this after the user completes authorization. Use the access token (for example, in the `Stripe-OAuth-Token` header) in subsequent onramp API requests for that user.
```shell
curl -X POST https://login.link.com/v1/link_auth_intent/{authIntentId}/tokens \
-H "Authorization: Bearer $STRIPE_SECRET_KEY"
```
```json
// Response
{
"access_token": "liwltoken_xxx",
"expires_in": 3600,
"token_type": "Bearer",
"refresh": {
"refresh_token": "liwlrefresh_xxx",
"expires_in": 7776000
}
}
```
**Parameters**
| Parameter | Type | Description |
| --------- | ----------------- | ------------------------------------------------- |
| `id` | string (required) | The Link Auth Intent id (for example, `lai_xxx`). |
**Returns**
| Field | Type | Description |
| ----------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `access_token` | string | OAuth access token. Send it on subsequent API requests for this user (for example, in the `Stripe-OAuth-Token` header). |
| `token_type` | string | Token type. Always `Bearer`. |
| `expires_in` | integer | Seconds until the access token expires. |
| `refresh` | object (optional) | Present when a refresh token was issued. Use it to obtain a new access token when the current one expires. See [Refresh an Access Token](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#refresh-an-access-token). |
| `refresh.refresh_token` | string | OAuth refresh token. Store it securely and use it to obtain new access tokens. See [Refresh an Access Token](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#refresh-an-access-token). |
| `refresh.expires_in` | integer | Seconds until the refresh token expires. |
**Errors**
| HTTP status | Cause |
| ----------- | -------------------------------------------------------------------------------------- |
| 403 | Feature not available. |
| 403 | `LinkAuthIntent` hasn’t been consented by the user. |
| 403 | Invalid or missing API key. |
| 404 | `LinkAuthIntent` not found (an invalid id, or the intent belongs to another business). |
### Refresh an access token
Exchanges a refresh token for a new access token. When your access token expires, use the refresh token you received from [Retrieve Access Tokens](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#retrieve-access-tokens) API to obtain a new access token without requiring the user to re-authorize.
To obtain your `OAUTH_CLIENT_SECRET`, contact your Stripe account executive or solutions architect. Stripe provisions the credential as part of your onboarding.
```shell
curl -X POST https://login.link.com/auth/token \
-H "Authorization: Bearer $STRIPE_SECRET_KEY" \
-d "grant_type=refresh_token" \
-d "refresh_token=$REFRESH_TOKEN" \
-d "client_id=$OAUTH_CLIENT_ID" \
-d "client_secret=$OAUTH_CLIENT_SECRET"
```
```json
// Response
{
"access_token": "liwltoken_xxx",
"refresh_token": "liwlrefresh_xxx",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "kyc.status:read crypto:ramp"
}
```
**Parameters**
| Parameter | Type | Description |
| --------------- | ----------------- | -------------------------------------------------------------------------- |
| `grant_type` | string (required) | Must be `refresh_token`. |
| `refresh_token` | string (required) | The refresh token previously obtained from the Retrieve Access Tokens API. |
| `client_id` | string (required) | Your OAuth client ID provided by Link. |
| `client_secret` | string (required) | Your OAuth client secret provided by Link. |
**Returns**
| Field | Type | Description |
| ----------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `token_type` | string | Token type. Always `Bearer`. |
| `access_token` | string | OAuth access token. Expires in 1 hour. Send it on subsequent API requests (for example, in the `Stripe-OAuth-Token` header). |
| `expires_in` | integer | TTL in seconds (3600, that is, 1 hour). |
| `refresh` | object (optional) | Present when a new refresh token was issued. A new refresh token is returned each time you use the old one. Store it for future refresh requests. |
| `refresh.refresh_token` | string | An OAuth refresh token. Store it securely for obtaining new access tokens when the current one expires. |
| `refresh.expires_in` | integer | Seconds until the refresh token expires. |
| `scope` | string | The OAuth scopes granted. |