Private preview
Integrate the Embedded Components onramp Private preview
Step-by-step integration guide for the Embedded Components onramp.
Web
React Native
Android
iOS
This guide explains how to build your integration with the iOS Crypto Onramp SDK. Use it when you need full control over the onramp flow, want to understand each API, or want to customize the flow for your app.
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 . To request access:
- Submit your application .
- 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 .
- Obtain your OAuth client ID and client secret. Stripe provisions these credentials, and you need them for the [authentication flow](/the relevant part of the product#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 from the API keys page .
- The server-side SDK bindings in this guide are only available in private-preview releases. Install the latest private-preview version listed in the server-side SDKs .
Configure a Stripe server-side client before using the server-side examples:
const Stripe = require('stripe');
const stripe = Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: `${Stripe.API_VERSION};crypto_onramp_beta=v2`,
});
Configure the mobile SDK
Add the onramp dependency Client-side
The Stripe iOS SDK is open source and fully documented. It supports apps that run iOS 13 or later. Add the StripeCryptoOnramp product to your app with your package manager.
- In Xcode, select File > Add Package Dependencies… and enter https://github.com/stripe/stripe-ios-spm as the repository URL.
- Select the latest version number from the releases page .
- Add the StripeCryptoOnramp product to the target of your app .
Opt in to experimental APIs Client-side
The SDK is in private preview. You must opt in with the @_spi(CryptoOnrampAlpha) attribute. Mark the StripeCryptoOnramp import like this:
@_spi(CryptoOnrampAlpha) import StripeCryptoOnramp
Configure the SDK Client-side
Before you call any onramp APIs, set your publishable key and create a CryptoOnrampCoordinator instance. You can also create a LinkAppearance instance to customize Stripe-provided UI elements such as one-time passcode entry, payment method selection, and identity verification.
Only one CryptoOnrampCoordinator instance can be active at a time because the SDK uses shared internal state.
STPAPIClient.shared.publishableKey = "pk_test_123"
let appearance = LinkAppearance(
colors: .init(primary: .systemBlue, selectedBorder: .label),
primaryButton: .init(cornerRadius: 16, height: 56),
style: .alwaysDark
)
Task {
do {
self.coordinator = try await CryptoOnrampCoordinator.create(appearance: appearance)
// Coordinator successfully configured.
} catch {
// Handle thrown errors.
}
}
Authenticate the customer
Check for a Link account Client-side
The customer must have a Link account to use the onramp APIs. Use hasLinkAccount(with:) to determine whether the customer’s email is associated with an existing Link account.
- If the customer has an account, go to Authorize .
- If they don’t have an account, register a new Link customer , then go to Authorize .
do {
if try await coordinator.hasLinkAccount(with: email) {
// The customer has an account. Proceed to authorization.
} else {
// Register the customer first.
}
} catch {
// Handle thrown errors.
}
Register a new Link customer if needed Client-side
If the customer doesn’t have a Link account, use registerLinkUser to create one with information that you collect in your UI. After account creation succeeds, go to Authorize.
do {
try await coordinator.registerLinkUser(
email: email, // Standard email format, max 800 characters.
fullName: fullName,
phone: phoneNumber, // E.164 formatted phone number.
country: country
)
// The customer is registered. Proceed to authorization.
} catch {
// Handle thrown errors.
}
Authorize Client-side Server-side
The primary authentication method uses two-factor authorization.
Create a LinkAuthIntent
A LinkAuthIntent tracks the scopes of the OAuth requests and the status of customer consent. Your backend calls the Create a LinkAuthIntent API with your the related setting and the onramp OAuth scopes, receives the authIntentId, and sends it to the client.
// createAuthIntent is a function you implement to call your backend.
let response = try await clientBackend.createAuthIntent(oauthScopes: scopes)
let authIntentId = response.authIntentId
Collect customer consent
Call authorize(linkAuthIntentId:from:) on CryptoOnrampCoordinator with the authIntentId to complete consent. This presents the OTP dialog so the customer can authorize the request.
do {
let authResult = try await coordinator.authorize(
linkAuthIntentId: authIntentId,
from: presentingViewController
)
switch authResult {
case .denied, .canceled:
// The customer denied or canceled the authentication flow.
case let .consented(customerId):
// The customer successfully authenticated.
// Proceed to KYC, identity verification, or payment.
// Store authIntent.token to enable Seamless Sign-In in future sessions.
}
} catch {
// Handle thrown errors.
}
Request access tokens
If the result is .consented, your back end calls the Retrieve Access Tokens API to request access tokens. Store the access token and use it in all subsequent onramp API requests, for example, in the Stripe-OAuth-Token header.
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 LinkAuthIntent token after a successful authorize call and exchanging it for a linkAuthTokenClientSecret (the related setting) through your back end in the next session. Pass the the related setting to authenticateUserWithToken(_:) to sign in without customer interaction.
If authentication fails, for example because the token expired, clear the stored token and fall back to the standard hasLinkAccount and authorize flow.
do {
let result = try await clientBackend.createLinkAuthToken(storedLAIToken)
let latcs = result.linkAuthTokenClientSecret
try await coordinator.authenticateUserWithToken(latcs)
// The customer successfully authenticated.
} catch {
// Seamless Sign-In failed. Clear stored tokens and fall back to authorize.
}
Log out Client-side
Call logOut() when the customer logs out of your app to clear all SDK state, including authorization, the selected payment method, and the crypto payment token. Also clear any locally stored tokens that you use for seamless sign-in.
do {
try await coordinator.logOut()
// The customer successfully logged out.
} catch {
// Handle thrown errors.
}
View an end-to-end authentication example
Verify identity
For details about KYC tiers and identity requirements, see the KYC integration guide. For EU-specific identity requirements, see the EU KYC integration guide.
Check whether KYC collection is needed Server-side
Your back end calls the Retrieve a CryptoCustomer API with the customerId. Inspect the verifications array in the response. If it includes an entry with type kyc_verified and status not_started, go to Collect KYC.
Select a language
Node.js
curl
No results
const customer = await stripe.crypto.customers.retrieve(
customerId,
{},
{headers: {'Stripe-OAuth-Token': accessToken}}
);
Collect KYC if needed Client-side
If the customer needs KYC verification, call attachKYCInfo(info:) to collect and submit KYC data. Present your own UI to collect this information.
Regional considerations EU
EU customers require additional fields such as nationalities and birthCountry. See the EU KYC integration guide for the complete list of required fields.
let kycInfo = KycInfo(
firstName: firstName,
lastName: lastName,
idNumber: idNumber,
address: address,
dateOfBirth: dateOfBirth
)
do {
try await coordinator.attachKYCInfo(info: kycInfo)
// KYC attached. Proceed to identity verification if needed, or to payment.
} catch {
// Handle thrown errors.
}
Verify KYC if needed Client-side
When a customer already has KYC information on file, use verifyKYCInfo(updatedAddress:from:) to present a Stripe-provided screen where the customer can confirm their existing information. If the customer needs to update their address, call verifyKYCInfo again with the updated address.
do {
let result = try await coordinator.verifyKYCInfo(
updatedAddress: nil,
from: presentingViewController
)
switch result {
case .confirmed:
// KYC verified. Proceed to identity verification or payment.
case .updateAddress:
// The customer wants to update their address.
// Show your address form, then call verifyKYCInfo(updatedAddress:from:) again.
case .canceled:
// The customer dismissed the flow without confirming.
}
} catch {
// Handle thrown errors.
}
Verify identity if needed Client-side
Some customers must verify their identity before they can complete checkout. When required, call verifyIdentity(from:) to present a Stripe-hosted flow where the customer uploads an identity document and a selfie.
If the customer uploads the wrong documents or retrieve a Crypto Customer returns a failed or rejected verification for the L2 tier, call verifyIdentity(from:) again to start a new verification session to let the customer re-upload their identity documents.
Verification is asynchronous. After the customer completes the flow, your back end can call the Retrieve a CryptoCustomer API and inspect the verifications array to check the result.
do {
let result = try await coordinator.verifyIdentity(from: presentingViewController)
switch result {
case .completed:
// The customer completed identity verification. Proceed to payment.
case .canceled:
// The customer canceled the identity verification flow.
}
} catch {
// Handle thrown errors.
}
Collect payment
Register a crypto wallet Client-side Server-side
You must register a wallet address before you can create a payment token. This validates that the address is valid for the given network. Your back end can call the List ConsumerWallets API to determine whether the customer already has wallets on file.
If the list is empty or the customer wants to add another address, have the client call registerWalletAddress(walletAddress:network:) with the customer’s chosen address and network. You can reuse a previously registered wallet in future sessions.
Select a language
Node.js
curl
No results
const wallets = await stripe.crypto.customers.listConsumerWallets(
customerId,
{},
{headers: {'Stripe-OAuth-Token': accessToken}}
);
Collect a payment method Client-side Server-side
You must collect a payment method before a transaction can occur. Your back end can call the List PaymentTokens API to determine which payment methods the customer already has. If the list is empty or the customer wants to use a different method, have the client call collectPaymentMethod(type:from:) on CryptoOnrampCoordinator.
We support cards ( PaymentMethodType.card), bank accounts ( PaymentMethodType.bankAccount), or both ( PaymentMethodType.cardAndBankAccount), and Apple Pay ( .applePay(paymentRequest:)). For card and bank account, collectPaymentMethod presents the Stripe wallet UI, which lists existing stored payment methods, lets the customer add new ones, and lets the customer select one. After a successful selection, it returns a PaymentMethodDisplayData instance with paymentMethodType, icon, label, and sublabel properties that you can use in your UI.
Regional considerations EU
EU sessions support card payments. Pass .card as the payment method type. Bank account payment methods aren’t supported for EU transactions.
Select a language
Node.js
curl
No results
const paymentTokens = await stripe.crypto.customers.listPaymentTokens(
customerId,
{},
{headers: {'Stripe-OAuth-Token': accessToken}}
);
Apple Pay
To offer Apple Pay, check whether the device supports it with StripeAPI.deviceSupportsApplePay() before you show the button. For example, in a SwiftUI view:
if StripeAPI.deviceSupportsApplePay() {
PayWithApplePayButton(.plain) {
// Proceed with Apple Pay collection.
}
}
For PaymentMethodType.applePay, you must supply a PKPaymentRequest. You can use the StripeCore framework to generate one. The following example creates a payment request with a pending amount because fees aren’t determined until checkout:
let request = StripeAPI.paymentRequest(
withMerchantIdentifier: "my_merchant_id",
country: "US",
currency: "USD"
)
request.paymentSummaryItems = [
PKPaymentSummaryItem(
label: "My Company",
amount: .zero,
type: .pending
)
]
When you have the PKPaymentRequest, call collectPaymentMethod with PaymentMethodType.applePay when the customer taps Apple Pay:
do {
let type = PaymentMethodType.applePay(paymentRequest: request)
if let displayData = try await coordinator.collectPaymentMethod(
type: type,
from: presentingViewController
) {
// Apple Pay payment method selected.
} else {
// The customer canceled Apple Pay.
}
} catch {
// Handle thrown errors.
}
The CryptoOnrampCoordinator instance tracks the most recently selected payment method and uses it in the next transaction.
Create a payment token Client-side
Create a payment token for the selected payment method by calling createCryptoPaymentToken(). Use the returned token when you create the CryptoOnrampSession.
do {
let token = try await coordinator.createCryptoPaymentToken()
// Payment token created. Proceed to session creation and checkout.
} catch {
// Handle thrown errors.
}
Create a crypto onramp session Server-side
From your UI, determine the amount, source currency such as usd, destination currency such as usdc, and network. Your back end calls the Create a CryptoOnrampSession API. The iOS SDK doesn’t provide APIs for session creation. Your back end handles this step. The following example shows how a client application might call your back end.
Regional considerations EU
For EU sessions, set source_currency to eur.
let request = CreateOnrampSessionRequest(
paymentToken: paymentToken,
sourceAmount: 100.0,
sourceCurrency: "usd",
destinationCurrency: "usdc",
destinationNetwork: wallet.network,
walletAddress: wallet.walletAddress
)
do {
let sessionResponse = try await clientBackend.createOnrampSession(request: request)
// Session created. Use sessionResponse.sessionId for checkout.
} catch {
// Handle thrown errors.
}
Note
If the API returns an HTTP 400 error with the crypto_onramp_missing_document_verification code even though the transaction amount is within the customer’s current tier limit, the customer will need to complete an identity challenge.
Perform checkout Client-side Server-side
To perform checkout, your view controller must conform to STPAuthenticationContext so the SDK can present authentication challenges:
extension MyCheckoutViewController: STPAuthenticationContext {
func authenticationPresentingViewController() -> UIViewController {
self
}
}
Call performCheckout(onrampSessionId:authenticationContext:clientSecretProvider:) with the session ID and a closure that calls your back end to perform the onramp checkout and return the resulting client secret. Your back end must call the onramp session checkout endpoint with the session ID and return the client_secret from the response. This closure might be called more than once during a single checkout (for example, after handling a 3D Secure challenge).
Caution
Always call the onramp session checkout endpoint from within this closure—never call it directly from your back end outside of performCheckout. The SDK invokes the closure because it must handle any required payment next actions, such as 3DS authentication, between checkout calls. Calling the checkout endpoint directly might appear to succeed in a sandbox (where next actions are rarely required), but the transaction isn’t considered finalized until performCheckout returns a successful result.
For ACH, the API may indicate that mandate_data is missing. Collect acceptance and send it on a later checkout call if required.
Optional Display a price estimate
Troubleshoot the integration
For errors and fixes specific to the iOS integration, see Handle errors.
Supported networks and currencies
Livemode
| Currency | Network | Address |
|---|---|---|
the related setting ( usdc) | Solana ( solana) | EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v |
the related setting ( usdc) | Base ( base) | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
the related setting ( ousd) | Base ( base) | Coming soon |
the related setting ( ousd) | Solana ( solana) | Coming soon |
the related setting ( ousd) | Ethereum ( ethereum) | Coming soon |
the related setting ( ousd) | Tempo ( tempo) | Coming soon |
the related setting ( usdc) | Sui ( sui) | 0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::the related setting |
the related setting.e ( usdc) | Tempo ( tempo) | 0x20c000000000000000000000b9537d11c60e8b50 |
the related setting ( usdc) | Ethereum ( ethereum) | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 |
the related setting ( usdb) | Solana ( solana) | ENL66PGy8d8j5KNqLtCcg4uidDUac5ibt45wbjH9REzB |
USDsui ( usdsui) | Sui ( sui) | 0x44f838219cf67b058f3b37907b655f226153c18e33dfcd0da559a844fea9b1c1::usdsui::the related setting |
the related setting ( usdc) | Arbitrum ( arbitrum) | 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 |
the related setting ( usdc) | World Chain ( worldchain) | 0x79A02482A880bCE3F13e09Da970dC34db4CD24d1 |
the related setting ( usdc) | Polygon ( polygon) | 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 |
the related setting ( usdt) | Ethereum ( ethereum) | 0xdac17f958d2ee523a2206206994597c13d831ec7 |
the related setting ( usdc) | Celo ( celo) | 0xcebA9300f2b948710d2653dD7B07f33A8B32118C |
RipUSD ( ripusd) | Solana ( solana) | ripNyDf7Vi6g4qoBp9F9ZfVSTNd1jvmFHQbRVRSXpaj |
Phantom Cash ( phantom_cash) | Solana ( solana) | CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH |
Sandbox (test mode)
| Currency | Network | Supported regions |
|---|---|---|
the related setting ( usdc) | Solana ( solana) | US |
the related setting ( usdc) | Ethereum ( ethereum) | US, EU |
the related setting ( usdc) | Base ( base) | US |
Testing
Note
You can test your integration in two ways, in a sandbox using test API keys, or in live mode 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 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:
| 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.
In a sandbox, the source amount used to execute the trade is hardcoded. As a result, the destination_crypto_amount returned in the API response reflects the original quote and might not match the amount that was actually executed. This is expected sandbox behavior. In production, the settled amount reflects what was actually executed.
Verification
To test verification behavior in a sandbox:
- For phone verification, use
Verifiedas 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
verifyIdentitySDK 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 . 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 flow. Send the OAuth client id and scopes you need. The API returns an intent id and expiration.
To obtain your the related setting, 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 your app to create authentication tokens for seamless sign-in on future visits. (For Android, iOS, and React Native) |
// 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 the related setting 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
The Retrieve Access Tokens API returns the OAuth tokens associated with a consented LinkAuthIntent: an access token and, when issued, a refresh token. After the user completes authorization, your back end can call this endpoint when it needs the tokens or securely store them for reuse. To limit credential exposure, we recommend that you don’t send OAuth access tokens or refresh tokens to the client. Use the access token (for example, in the Stripe-OAuth-Token header) in subsequent onramp API requests for that user.
// Response
{
"access_token": "liwltoken_xxx",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "kyc.status:read crypto:ramp",
"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. |
scope | string | The OAuth scopes granted, separated by spaces. |
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. |
refresh.refresh_token | string | OAuth refresh token. Store it securely and use it to obtain new access tokens. See 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 API to obtain a new access token without requiring the user to re-authorize.
To obtain your the related setting, contact your Stripe account executive or solutions architect. Stripe provisions the credential as part of your onboarding.
// 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 |
|---|---|---|
access_token | string | OAuth access token. Expires in 1 hour. Send it on subsequent API requests (for example, in the Stripe-OAuth-Token header). |
refresh_token | string | A new OAuth refresh token. Store it securely for obtaining new access tokens when the current one expires. |
token_type | string | Token type. Always Bearer. |
expires_in | integer | TTL in seconds (3600, that is, 1 hour). |
scope | string | The OAuth scopes granted, separated by commas. |
Listen to webhook events
Stripe sends a crypto.onramp_session.updated webhook every time the status of an onramp session changes after creation. Stripe doesn’t send an event when a new session is created. Configure webhooks in the Dashboard.
The SDK’s performCheckout callback covers the payment step synchronously on the client. Use the webhook on your server to track asynchronous fulfillment. In particular, the transition from fulfillment_processing to fulfillment_complete, which occurs after checkout resolves as the crypto delivery is confirmed on-chain.
The session status field progresses through these states: initialized > requires_payment > fulfillment_processing > fulfillment_complete. The session moves to rejected if the session is blocked.
The webhook payload uses the CryptoOnrampSession resource:
{
"id": "evt_123",
"object": "event",
"data": {
"object": {
"id": "cos_0MYvv9589O8KAxCGPm84FhVR",
"object": "crypto.onramp_session",
"client_secret": "cos_0MYvv9589O8KAxCGPm84FhVR_secret_IGBYKVlTlnJL8UGxji48pKxBO00deNcBuVc",
"created": 1675794575,
"livemode": false,
"status": "fulfillment_complete",
"transaction_details": {
"destination_currency": "eth",
"destination_amount": null,
"destination_network": "ethereum",
"fees": null,
"lock_wallet_address": false,
"source_currency": "usd",
"source_amount": null,
"destination_currencies": ["eth"],
"destination_networks": ["ethereum"],
"transaction_id": null,
"wallet_address": null,
"wallet_addresses": {
"bitcoin": null,
"ethereum": "0xB00F0759DbeeF5E543Cc3E3B07A6442F5f3928a2",
"polygon": null,
"solana": null,
"stellar": null,
"destination_tags": null
}
}
}
}
}
Handle identity challenges
Risk rules can require a customer to complete an identity challenge even when their transaction amount is within the limit for their current verification tier. In this case, the Create a CryptoOnrampSession API returns an HTTP 400 error with the crypto_onramp_missing_document_verification code.
Retrieve the CryptoCustomer and check their current verification tier. If the customer is only verified at L0, first prompt them to complete the KYC collection step by submitting their date of birth and Social Security number.
After the customer reaches L1, prompt them to complete the identity verification step, where they provide a photo ID and selfie. After they complete the flow, retrieve the CryptoCustomer again and check the L2 verification status. When the status is verified, retry creating the CryptoOnrampSession.
Completing the identity challenge also verifies the customer at L2, so their future transactions use the L2 transaction limits.
