# Integrate the Embedded Components onramp Step-by-step integration guide for the Embedded Components onramp. # 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* (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). ## Configure the mobile SDK ### Add the onramp dependency (Client-side) The [Stripe iOS SDK](https://github.com/stripe/stripe-ios) is open source and [fully documented](https://stripe.dev/stripe-ios/index.html). It supports apps that run iOS 13 or later. Add the `StripeCryptoOnramp` product to your app with your package manager. #### Swift Package Manager 1. In Xcode, select **File** > **Add Package Dependencies…** and enter **https://github.com/stripe/stripe-ios-spm** as the repository URL. 1. Select the latest version number from the [releases page](https://github.com/stripe/stripe-ios/releases). 1. Add the `StripeCryptoOnramp` product to the [target of your app](https://developer.apple.com/documentation/swift_packages/adding_package_dependencies_to_your_app). #### CocoaPods 1. If you haven’t already, install the latest version of [CocoaPods](https://guides.cocoapods.org/using/getting-started.html). 1. If you don’t have an existing [Podfile](https://guides.cocoapods.org/syntax/podfile.html), run the following command to create one: ```shell pod init ``` 1. Add this line to your `Podfile`: ```ruby pod 'StripeCryptoOnramp' ``` 1. Run the following command: ```shell pod install ``` 1. Open your project in Xcode with the `.xcworkspace` file instead of the `.xcodeproj` file. 1. To update to the latest version of the SDK later, run: ```shell pod update StripeCryptoOnramp ``` #### Carthage 1. If you haven’t already, install the latest version of [Carthage](https://github.com/Carthage/Carthage#installing-carthage). 1. Add this line to your `Cartfile`: ```text github "stripe/stripe-ios" ``` 1. Follow the [Carthage installation instructions](https://github.com/Carthage/Carthage#if-youre-building-for-ios-tvos-or-watchos). Make sure that you embed [all required frameworks](https://github.com/stripe/stripe-ios/tree/master/StripeCryptoOnramp#manual-linking). 1. To update to the latest version of the SDK later, run: ```shell carthage update stripe-ios --platform ios ``` #### Manual framework 1. Go to the [GitHub releases page](https://github.com/stripe/stripe-ios/releases/latest) and download and unzip `Stripe.xcframework.zip`. 1. Drag `StripeCryptoOnramp.xcframework` to the **Embedded Binaries** section of the **General** settings in your Xcode project. Make sure that you select **Copy items if needed**. 1. Repeat step 2 for all required frameworks listed [here](https://github.com/stripe/stripe-ios/blob/master/StripeCryptoOnramp/README.md#manual-linking). 1. To update to the latest version of the SDK later, repeat steps 1–3. ### 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: ```swift @_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. ```swift STPAPIClient.shared.publishableKey = "pk_test_123" ``` ```swift let appearance = LinkAppearance( colors: .init(primary: .systemBlue, selectedBorder: .label), primaryButton: .init(cornerRadius: 16, height: 56), style: .alwaysDark ) ``` ```swift 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](https://link.com) 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](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#step-authorize). - If they don’t have an account, [register a new Link customer](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#step-register-a-new-link-customer-if-needed), then go to [Authorize](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#step-authorize). ```swift 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](https://docs.stripe.com/crypto/onramp/embedded-components-integration-guide.md#step-authorize). ```swift 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 `OAUTH_CLIENT_ID` and the onramp OAuth scopes, receives the `authIntentId`, and sends it to the client. #### Client-side ```swift // createAuthIntent is a function you implement to call your backend. let response = try await clientBackend.createAuthIntent(oauthScopes: scopes) let authIntentId = response.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,auth.persist_login:read"}' ``` #### 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. ```swift 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. ```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 `LinkAuthIntent` token after a successful `authorize` call and exchanging it for a `linkAuthTokenClientSecret` (LATCS) through your back end in the next session. Pass the LATCS 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. ```swift 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. ```swift do { try await coordinator.logOut() // The customer successfully logged out. } catch { // Handle thrown errors. } ``` ### View an end-to-end authentication example The following example shows how to use the authentication APIs together in a complete authentication lifecycle, including seamless sign-in, new and existing customer handling, OTP authorization, and logout. ```swift @_spi(CryptoOnrampAlpha) import StripeCryptoOnramp import UIKit final class OnrampAuthExample { private let coordinator: CryptoOnrampCoordinator private let backend: MerchantBackend private let tokenStore: SeamlessTokenStore init( coordinator: CryptoOnrampCoordinator, backend: MerchantBackend, tokenStore: SeamlessTokenStore ) { self.coordinator = coordinator self.backend = backend self.tokenStore = tokenStore } // Call when the customer starts an onramp session. func authenticateUserForOnramp() async throws { // 1) Try Seamless Sign-In first. This is optional and skips OTP for a returning customer. if let storedLAIToken = tokenStore.load() { do { let latcs = try await backend.createLinkAuthTokenClientSecret( fromStoredLAIToken: storedLAIToken ) try await coordinator.authenticateUserWithToken(latcs) return } catch { // Tokens can expire. Clear and fall back to the standard flow. tokenStore.clear() } } // 2) Collect email in your own UI. let email = try await promptForEmailFromYourUI() // 3) Check for an existing Link account. let hasLinkAccount = try await coordinator.hasLinkAccount(with: email) if !hasLinkAccount { // 4) Collect details and register the customer. let reg = try await promptForRegistrationInfoFromYourUI(prefilledEmail: email) try await coordinator.registerLinkUser( email: reg.email, fullName: reg.fullName, phone: reg.phoneE164, country: reg.country ) } // 5) Create a LinkAuthIntent on your backend. let authIntent = try await backend.createLinkAuthIntent( oauthScopes: Scopes.requiredScopes ) // 6) Present the OTP and consent UI. let presentingVC = try presentingViewControllerForOTP() let authResult = try await coordinator.authorize( linkAuthIntentId: authIntent.authIntentId, from: presentingVC ) switch authResult { case .consented: // Store the token to enable Seamless Sign-In in future sessions. tokenStore.save(authIntent.token) case .denied: throw AuthFlowError.authorizationDenied case .canceled: throw AuthFlowError.authorizationCanceled @unknown default: throw AuthFlowError.unknownError } } // Call when the customer logs out of your app. func logOutUserFromApp() async { do { try await coordinator.logOut() } catch { // Continue with your own logout flow. } tokenStore.clear() } // MARK: - Placeholder UI hooks (replace with your app's UI) private func promptForEmailFromYourUI() async throws -> String { return "user@example.com" } private func promptForRegistrationInfoFromYourUI( prefilledEmail: String ) async throws -> RegistrationInput { RegistrationInput( email: prefilledEmail, fullName: "Jane Doe", phoneE164: "+12125551234", country: "US" ) } private func presentingViewControllerForOTP() throws -> UIViewController { guard let vc = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .flatMap(\.windows) .first(where: \.isKeyWindow)? .rootViewController else { throw AuthFlowError.noPresentingViewController } return vc } } // MARK: - Supporting types struct RegistrationInput { let email: String let fullName: String? let phoneE164: String let country: String } protocol MerchantBackend { func createLinkAuthIntent(oauthScopes: [Scopes]) async throws -> CreateLinkAuthIntentResponse func createLinkAuthTokenClientSecret(fromStoredLAIToken token: String) async throws -> String } struct CreateLinkAuthIntentResponse { let authIntentId: String let token: String } protocol SeamlessTokenStore { func load() -> String? func save(_ token: String) func clear() } enum AuthFlowError: Error { case authorizationDenied case authorizationCanceled case noPresentingViewController case unknownError } enum Scopes: String { static let requiredScopes: [Scopes] = [.cryptoRamp, .kycStatusRead, .authPersistLoginRead] case cryptoRamp = "crypto:ramp" case kycStatusRead = "kyc.status:read" case authPersistLoginRead = "auth.persist_login:read" } ``` ## Verify identity For details about KYC tiers and identity requirements, see the [KYC integration guide](https://docs.stripe.com/crypto/onramp/kyc-integration-guide.md). ### 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](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" ``` ### 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. ```swift 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. ```swift 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. 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. ```swift 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. #### 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 ```swift do { try await coordinator.registerWalletAddress( walletAddress: "bc1qztnc…", network: .bitcoin ) // Wallet registered. Proceed to collect a payment method. } catch { // Handle thrown errors. } ``` ### 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, bank accounts, and Apple Pay. 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. #### 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 ```swift do { let type = PaymentMethodType.card // or PaymentMethodType.bankAccount if let displayData = try await coordinator.collectPaymentMethod( type: type, from: presentingViewController ) { // Payment method selected. Optionally use displayData in your UI. } else { // The customer canceled payment method selection. } } catch { // Handle thrown errors. } ``` #### Apple Pay To offer Apple Pay, check whether the device supports it with [`StripeAPI.deviceSupportsApplePay()`](https://stripe.dev/stripe-ios/stripe/documentation/stripe/stripeapi/devicesupportsapplepay\(\)) before you show the button. For example, in a SwiftUI view: ```swift if StripeAPI.deviceSupportsApplePay() { PayWithApplePayButton(.plain) { // Proceed with Apple Pay collection. } } ``` For `PaymentMethodType.applePay`, you must supply a [`PKPaymentRequest`](https://developer.apple.com/documentation/passkit/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: ```swift 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**: ```swift 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`. ```swift 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](https://docs.stripe.com/api/crypto/onramp_sessions/create.md) 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. #### Client-side ```swift 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. } ``` #### 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" \ -d "source_currency=usd" \ -d "destination_currency=usdc" \ -d "destination_network=base" \ -d "destination_networks[]=base" \ -d "wallet_address=0x1234567890abcdef1234567890abcdef12345678" \ -d "customer_ip_address=203.0.113.1" ``` Pass `source_amount` or `destination_amount`, not both. Use the singular `destination_network` to pin the transaction to a network. When you set `wallet_address`, you must also set `destination_networks[]`, and its value must match `destination_network`. ### Perform checkout (Client-side) (Server-side) To perform checkout, your view controller must conform to [`STPAuthenticationContext`](https://stripe.dev/stripe-ios/stripe/documentation/stripe/stpauthenticationcontext) so the SDK can present authentication challenges: ```swift extension MyCheckoutViewController: STPAuthenticationContext { func authenticationPresentingViewController() -> UIViewController { self } } ``` Call `performCheckout(onrampSessionId:authenticationContext:clientSecretProvider:)` with the session ID and a closure that retrieves the checkout client secret from your back end. The closure might be called more than once during a single checkout, for example, after handling a 3D Secure challenge. 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 ```swift do { let checkoutResult = try await coordinator.performCheckout( onrampSessionId: sessionResponse.sessionId, authenticationContext: presentingViewController ) { onrampSessionId in let result = try await APIClient.shared.checkout(onrampSessionId: onrampSessionId) return result.clientSecret } switch checkoutResult { case .completed: // Checkout completed successfully. case .canceled: // Checkout canceled during an authentication challenge. } } catch { // Handle thrown errors. } ``` #### 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 complete, 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(onrampSessionId:authenticationContext:clientSecretProvider:)` handles payment next actions such as 3D Secure 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 customer complete KYC in the SDK, for example, `attachKYCInfo`. Then call checkout again. | | `missing_document_verification` | Identity document verification is required. | Have the customer complete verification in the SDK, for example, `verifyIdentity`. Then call checkout again. | | `charged_with_expired_quote` | The quote expired. | Refresh the quote on your back end, then call checkout again. | | `transaction_limit_reached` | The customer exceeded their limit. | Display an error message. | | `location_not_supported` | We don’t support the customer’s location. | Show that the service isn’t available in their region. | | `transaction_failed` | A generic failure occurred. | Display a generic error message. | | `missing_consumer_wallet` | The wallet address doesn’t exist for the current customer. | Have the customer register the wallet, then call checkout again. | ## Troubleshoot the integration ### Configuration error | Error | Cause and fix | | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CryptoOnrampCoordinator.create()` throws an error | The `create()` factory method can fail if the underlying Link SDK fails to initialize. A common cause is an invalid publishable key set on `STPAPIClient`. Inspect the error for details. | ### Authentication error | Error | Cause and fix | | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `LinkController.IntegrationError.noActiveLinkConsumer` | The customer’s session wasn’t established or expired. Make sure that they completed authentication through `authorize` or `authenticateUserWithToken` before you call other APIs. This error can come from `authenticateUserWithToken`, `registerWalletAddress`, `attachKYCInfo`, `verifyKYCInfo`, `verifyIdentity`, `collectPaymentMethod`, and `createCryptoPaymentToken`. Re-authenticate the customer by calling `authorize` again. | | `CryptoOnrampCoordinator.Error.seamlessSignInTokenInvalid` | `authenticateUserWithToken` throws this error when the provided token expired, was already used, or was revoked. Fall back to `authorize` to sign in the customer manually and clear any stored tokens. | ### Registration error | Error | Cause and fix | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CryptoOnrampCoordinator.Error.linkAccountAlreadyExists` | `registerLinkUser` throws this error if the email is already associated with an existing Link account. Use `hasLinkAccount(with:)` to check before you attempt registration, or direct the customer to sign in with `authorize` instead. | | `CryptoOnrampCoordinator.Error.invalidPhoneFormat` | `registerLinkUser` throws this error if the phone number isn’t in E.164 format, for example, `+12125551234`. Validate the format before you call this API. | ### Payment error | Error | Cause and fix | | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CryptoOnrampCoordinator.Error.missingCryptoCustomerID` | `createCryptoPaymentToken` throws this error. A crypto customer ID is created during `authorize`, `authenticateUserWithToken`, or `registerLinkUser`. Make sure that one of these steps completed before you try to create a payment token. | | `CryptoOnrampCoordinator.Error.invalidSelectedPaymentSource` | `createCryptoPaymentToken` throws this error if no payment method has been collected. `collectPaymentMethod` can also throw it if the selected method can’t be resolved internally. Make sure that `collectPaymentMethod` succeeded before you call `createCryptoPaymentToken`. If the error occurs during payment collection, retry the `collectPaymentMethod` call. | | `CryptoOnrampCoordinator.Error.linkAccountNotVerified` | `collectPaymentMethod` throws this error for Link payment types (`.card`, `.bankAccount`) when the Link account session isn’t in a verified state. Make sure that the customer’s authentication and verification flow completed before you collect a payment method. | ### Checkout error | Error | Cause and fix | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CheckoutError.paymentFailed` | The underlying `PaymentIntent` reached a terminal failure state, for example, a declined card, processing error, or failed 3D Secure. Inspect the error and offer the customer an option to retry or select a different payment method. | | `CheckoutError.missingPaymentMethod` | The `PaymentIntent` doesn’t have an associated payment method. Make sure that a payment method was collected successfully before you initiate checkout. | | `CheckoutError.unexpectedError` | A catch-all error for unexpected states during checkout. Log the surrounding context and retry the checkout. | ### Identity and KYC error | Error | Cause and fix | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CryptoOnrampCoordinator.Error.missingEphemeralKey` | `verifyIdentity` throws this error when the server responds without an ephemeral key. This usually indicates a back-end configuration issue. Make sure that the customer’s account is set up correctly for identity verification. | | `VerifyKYCResult.updateAddress` | This isn’t an error. When `verifyKYCInfo` returns `.updateAddress`, show your own address form and call `verifyKYCInfo(updatedAddress:from:)` again with the new address. | ### General guidance - All errors thrown by `CryptoOnrampCoordinator` APIs conform to `LocalizedError`. Use the `localizedDescription` property for detailed diagnostics. - Only one `CryptoOnrampCoordinator` instance can be active at a time. Creating multiple instances can lead to undefined behavior. - Always call `logOut()` when the customer logs out of your app to clean up SDK state and avoid stale session issues. - When you use a test mode publishable key that contains `test`, the SDK operates against the Stripe test environment. No real transactions are processed, and no actual identity verification occurs. ## 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. |