RyzeDeskRyzeDesk

Stripe

4105 articles

Integrate EU KYC verification for the Embedded Components onramp


Private preview

Integrate EU KYC verification for the Embedded Components onramp Private preview

Collect MiCA identifiers and complete L2 verification for EU users.

Web

React Native

Android

iOS

Before you begin

Before you begin, integrate the Embedded Components Onramp (including Link authentication) and review the KYC tier system.

In the European Union (EU), users must complete additional identity verification steps beyond the standard KYC tiers before they can transact. The primary EU regulation driving these requirements is:

  • MiCA : Markets in Crypto-Assets Regulation. This requires a national identifier for each nationality or country of residence that is one of the following countries: Estonia (EE), Spain (ES), Iceland (IS), Italy (IT), Malta (MT) or Poland (PL).

Users must also accept a Stripe Terms of Service attestation.

L2 verification (document and selfie) is mandatory for all EU users. A user can’t transact until they complete L2 and all EU-specific identifier requirements. See Determining a user’s current KYC tier for details about tier statuses.

Separately, purchases at or above €1,000 are subject to the EU Travel Rule, which requires the user to verify ownership of their destination wallet before the purchase can proceed. See Travel Rule wallet ownership verification.

EU signup flow overview

The full EU verification flow is:

  1. For existing users, check if EU KYC collection is needed by inspecting kyc _ region , kyc _ tiers and provided _ fields on the CryptoCustomer object
  2. Collect basic KYC info including nationalities and submit with attachKycInfo
  3. Call retrieveMissingIdentifiers to determine which MiCA identifiers are needed
  4. Collect any MiCA identifiers from the identifiers array and submit with submitIdentifiers
  5. Present the Stripe Terms of Service (ToS) with presentCrsCarfDeclaration
  6. Complete identity verification (document + selfie) with verifyIdentity

Check if EU KYC collection is needed (existing users) Server-side

For existing users, retrieve the CryptoCustomer and inspect the response to determine if the EU-specific flow is needed. The following fields on the CryptoCustomer are relevant:

FieldDescription
kyc_regionDerived from the user’s country of residence. Possible values: null (KYC not yet submitted), "us" or "eu".
kyc_tiersArray of tier objects, each with a tier name and verification_status. For EU users, l0 and l1 are "not_available" – only the l2 entry is relevant. See the L2 states table below.
provided_fieldsArray of strings indicating which EU-specific data the user has submitted. Key values: "identifiers" (identifier requirements satisfied – either MiCA identifiers were submitted or none were required) and "attestation" (user accepted the Stripe Terms of Service).

The l2 tier’s verification_status can be one of the following for EU users:

StatusMeaning
not_startedBasic KYC info has not been submitted or failed validation.
pendingBasic KYC info submitted. Document verification is either not yet submitted or still processing.
verifiedBasic KYC info and identity document verification are both complete.
rejectedIdentity document verification was rejected. Inspect verification_errors on the l2 tier object to determine whether the user can retry. To learn more, see Handle a rejected L2 verification.

First, check kyc_region to determine whether the user is subject to EU requirements:

  • If kyc _ region is null , the user hasn’t submitted basic KYC information yet. Display a KYC form that includes a country of residence field. Based on the selected country, show the appropriate KYC fields:
  • EU country: show the EU KYC form (nationalities, birth city/country) – see Submit basic KYC info below.
  • US: show the standard KYC form – see the embedded components integration guide .
  • If kyc _ region is us , this flow doesn’t apply. Collect KYC via the embedded components integration guide .
  • If kyc _ region is eu , continue with this guide.

Then check whether the user has completed all EU requirements. All three conditions must be true for the user to transact:

  1. kyc _ tiers contains an l2 entry with verification _ status: "verified"
  2. provided _ fields includes "identifiers" (identifier requirements satisfied)
  3. provided _ fields includes "attestation" (ToS accepted)

If all three are met, the user is fully verified – skip the remaining steps.

Determine where to resume

If any condition above is not met, check the l2 tier’s verification_status and provided_fields to determine which step the user needs next:

ConditionNext step
l2 is "not_started"Submit basic KYC info – basic info not yet submitted
l2 is "pending" and provided_fields doesn’t include "identifiers"Get missing identifiers – basic info done, identifiers still needed
provided_fields includes "identifiers" but not "attestation"Stripe Terms of Service – identifiers submitted, Stripe ToS still needed
provided_fields includes both but l2 is not "verified"Complete identity verification – document and selfie still needed
l2 is "rejected"Handle a rejected L2 verification – check verification_errors to determine whether to retry or direct to support

Submit basic KYC info Client-side

If the user is US-based, collect KYC via the embedded components integration guide instead.

For EU-based users, attachKycInfo requires the nationalities, birthCity and birthCountry fields in addition to the standard fields (name, DOB, address). The user’s nationalities and country of residence together determine which MiCA identifiers are required.

The state field is not required for EU addresses, except for Ireland (IE).

val kycInfo = KycInfo(
 firstName = "Maria",
 lastName = "Papadopoulos",
 idNumber = null, // Not required for EU users — collected through MiCA.
 dateOfBirth = DateOfBirth( // Object with numeric fields, not a date string.
 day = 15, // Day of month (1-31).
 month = 3, // Month of year (1-12).
 year = 1990 // Full 4-digit year.
 ),
 address = PaymentSheet.Address(
 line1 = "123 Example Street",
 city = "Athens", // Free-text city name.
 postalCode = "10557",
 country = "GR" // ISO 3166-1 alpha-2 country code.
 ),
 nationalities = listOf(CountryCode("GR"), CountryCode("EE")), // List of ISO 3166-1 alpha-2 country codes.
 birthCity = "Athens",
 birthCountry = CountryCode("GR")
)
val attachResult = coordinator.attachKycInfo(kycInfo)

Errors

ErrorMessage
the related settingnationalities is required for EU-based users. Provide at least one ISO 3166-1 alpha-2 country code.
the related settingInvalid value for parameter {param}.
the related settingUser has not authenticated.
the related settingThe request was rate limited.

Get missing identifiers Client-side

After submitting basic KYC info, call retrieveMissingIdentifiers to determine which identifiers the user still needs to provide.

The response contains:

  • carf_tin_required : Always false . This field isn’t used. You can ignore it.
  • identifiers : MiCA national identifier requirements (derived from the user’s nationalities and residence country).
  • alternatives : Alternative options for MiCA identifiers (for example, passport instead of national ID for Malta).

Collect any required MiCA identifiers from the identifiers array – these are determined by the user’s nationalities and residence country.

val result = coordinator.retrieveMissingIdentifiers()
when (result) {
 is OnrampRetrieveMissingIdentifiersResult.Completed -> {
 val requirements = result.requirements
 // requirements.identifiers — list of ComplianceIdentifierRequirement
 // requirements.alternatives — list of ComplianceIdentifierAlternativeGroup
 // requirements.carfTinRequired — always false, can be ignored
 }
 is OnrampRetrieveMissingIdentifiersResult.Failed -> {
 // Handle error
 }
}

In this example, ee_ik is required because the user has Estonian nationality, which is a MiCA country.

Each entry in identifiers contains:

FieldDescription
typeThe identifier type code (see Identifier types reference)
regulationThe regulation requiring this identifier (always eu_mica in the identifiers array)

Alternative identifiers

For some countries (currently Malta and Poland), MiCA accepts an alternative identifier. When alternatives exist, they appear in the alternatives list:

// Maltese national living in Germany
val kycInfo = KycInfo(
 firstName = "Maria",
 lastName = "Papadopoulos",
 idNumber = null, // Not required for EU users — collected through MiCA.
 dateOfBirth = DateOfBirth(
 day = 15,
 month = 3,
 year = 1990
 ),
 address = PaymentSheet.Address(
 line1 = "123 Example Street",
 city = "Berlin",
 postalCode = "10557",
 country = "DE"
 ),
 nationalities = listOf(CountryCode("MT")),
 birthCity = "Valletta",
 birthCountry = CountryCode("MT")
)
coordinator.attachKycInfo(kycInfo)

val result = coordinator.retrieveMissingIdentifiers()
// Completed result contains:
// requirements.carfTinRequired = false
// requirements.identifiers = [ComplianceIdentifierRequirement(type = MT_NIC, regulation = EU_MICA)]
// requirements.alternatives = [
// ComplianceIdentifierAlternativeGroup(
// originalMissingIdentifiers = [MT_NIC],
// alternativeMissingIdentifiers = [MT_PP]
// )
// ]

In this example, for the MiCA requirement, the user can provide either mt_nic (Malta national identity card) or mt_pp (Malta passport). Use the alternatives list to present the user with a choice between the two.

Errors

ErrorMessage
the related settingBasic KYC info has not been submitted via attachKycInfo.
the related settingUser has not authenticated.
the related settingThe request was rate limited.

Submit identifiers Client-side

Before calling submitIdentifiers, validate identifiers client-side using the regex patterns and structure rules documented in Identifier validation logic. Client-side validation lets you provide immediate feedback to users about formatting errors (for example, wrong length or invalid characters) without a round trip.

Submit the collected MiCA identifiers with submitIdentifiers. When the result indicates completion, all identifier requirements are satisfied and you can proceed to the next step (attestation).

val identifiers = listOf(
 ComplianceIdentifier().type(ComplianceIdentifierType.EE_IK).value("39901011234")
)
val submitResult = coordinator.submitIdentifiers(identifiers)
when (submitResult) {
 is OnrampSubmitIdentifiersResult.Completed -> {
 if (submitResult.result.completed) {
 // All identifier requirements satisfied — proceed to attestation
 } else {
 // Prompt the user to correct invalid identifiers or provide remaining ones
 }
 }
 is OnrampSubmitIdentifiersResult.Failed -> {
 // Handle error
 }
}

The result contains:

FieldDescription
completedtrue when all identifier requirements (MiCA) are satisfied – proceed to attestation
carfTinRequiredThis field isn’t used. You can ignore it.
identifiersRemaining missing MiCA identifiers (same shape as retrieveMissingIdentifiers)
alternativesRemaining alternative options for missing MiCA identifiers
invalidIdentifiersIdentifier types that were submitted but rejected (for example, wrong format)

Errors

ErrorMessage
the related settingInvalid value for parameter {param}.
the related settingUser has not authenticated.
the related settingThe request was rate limited.

Stripe Terms of Service Client-side

After all identifiers are submitted ( completed: true), present the Stripe Terms of Service declaration for the user to review and accept. The result is delivered through OnrampCallbacks.

presenter.presentCrsCarfDeclaration()
// Result delivered via OnrampCallbacks.crsCarfDeclarationCallback

You must call submitIdentifiers and have completed: true before calling presentCrsCarfDeclaration. If identifiers are incomplete, the SDK returns an error.

Errors

ErrorMessage
the related settingEU identifiers have not been fully submitted via submitIdentifiers.
the related settingUser has not authenticated.
the related settingThe request was rate limited.

Complete identity verification Client-side

After identifiers and attestation are complete, call verifyIdentity to present a Stripe-hosted flow where the user uploads an identity document and a selfie.

presenter.verifyIdentity()
// Result delivered via OnrampCallbacks.verifyIdentityCallback

Handle a rejected L2 verification

If l2.verification_status is rejected, inspect l2.verification_errors to determine the next step:

verification_errors valueMeaningNext step
id_document_verification_failedThe identity document or selfie was rejected by Stripe’s verification system.Prompt the user to retry identity verification.
user_has_reached_max_verification_attemptsThe user has exhausted all allowed verification attempts.No retry is possible. Ask the user to contact Stripe support.
id_document_verification_failed and user_has_reached_max_verification_attemptsThe last attempt was rejected and no retries remain.No retry is possible. Ask the user to contact Stripe support.
// After retrieving the CryptoCustomer from your server
val l2Tier = customer.kycTiers.find { it.tier == "l2" }
if (l2Tier?.verificationStatus == "rejected") {
 val canRetry = !l2Tier.verificationErrors.contains("user_has_reached_max_verification_attempts")

 if (canRetry) {
 // Prompt the user to retry identity verification
 presenter.verifyIdentity()
 } else {
 // User has exhausted all attempts — direct them to contact Stripe support
 }
}

Errors

ErrorMessage
the related settingUser has not authenticated.
the related settingThe request was rate limited.

Travel Rule wallet ownership verification

In the EU, purchases at or above €1,000 are subject to the EU Travel Rule. Before a user can complete an on-ramp purchase at or above this threshold, they must prove that they control the destination wallet by signing a Stripe-issued challenge with that wallet.

This flow is separate from the identity verification steps above. It applies at purchase time and is scoped to a specific registered wallet address and network and not to the user’s identity.

Note

EVM-compatible networks (for example, Ethereum, Base and Polygon) and Solana support wallet ownership verification at launch. Requesting a challenge for an unsupported network returns an error (see Wallet ownership verification errors).

Flow overview

  1. Check whether the destination wallet is already verified by inspecting verified _ ownership on the crypto.consumer_wallet resource.
  2. If it isn’t verified, request a short-lived wallet ownership challenge with getWalletOwnershipChallenge .
  3. Ask the user to sign the exact challenge message with their wallet.
  4. Submit the signature with submitWalletOwnershipSignature .
  5. When verifiedOwnership is true , the user can complete the purchase. If you triggered verification in response to a checkout that failed with wallet _ ownership _ verification _ required , retry that checkout.

Verification persists on the wallet, so a verified wallet address and network don’t need to be re-verified for future purchases. Decide when to verify based on your checkout flow, either pre-emptively after wallet registration for wallets likely to be used above the threshold, or on demand when a purchase indicates verification is required.

Check whether a wallet is already verified

The verified_ownership field on each registered wallet indicates whether it has already completed ownership verification. Retrieve the user’s registered wallets from your server and only run the challenge flow when verified_ownership is false. verified_ownership defaults to false for existing and newly registered wallets until verification succeeds.

Request a wallet ownership challenge

Request a challenge for a wallet the user has already registered. The challenge is short-lived and single-use, and it’s bound to the authenticated user, the wallet address and the network.

val result = coordinator.getWalletOwnershipChallenge(
 walletAddress = "0x1234567890abcdef1234567890abcdef12345678",
 network = CryptoNetwork.Ethereum
)
when (result) {
 is OnrampGetWalletOwnershipChallengeResult.Completed -> {
 val challenge = result.challenge
 // challenge.challengeId — opaque identifier for this challenge
 // challenge.network — network bound to this challenge
 // challenge.message — exact opaque message the wallet must sign
 // challenge.expiresAt — ISO 8601 expiry timestamp
 }
 is OnrampGetWalletOwnershipChallengeResult.Failed -> {
 // Inspect result.error — see Wallet ownership verification errors.
 }
}

Treat message as opaque and sign it exactly as returned. Stripe owns message construction and verification. Sign and encode the result according to the wallet’s network:

  • EVM-compatible networks: Sign with personal _ sign , which applies the EIP-191 0x45 (personal_sign) envelope . Submit the complete signature bytes as a 0x -prefixed hexadecimal string. Stripe uses the EIP-6492 universal verification procedure , which supports EOA signatures, deployed smart contract wallets that implement EIP-1271 and EIP-6492-wrapped signatures from counterfactual smart contract wallets.
  • Solana: UTF-8 encode message and sign those exact bytes, without a prefix, with the wallet’s signMessage method. Submit the raw 64-byte Ed25519 signature as a base58-encoded string.

For the errors these methods can return and how to recover from them, see Wallet ownership verification errors.

Collect a signature and submit it

Ask the user to sign the exact message from the challenge with their wallet, encode the signature as specified above, then submit the encoded string. submitWalletOwnershipSignature returns the updated wallet on success, with verifiedOwnership set to true.

The challengeId is authoritative and already identifies the wallet address and network, so you don’t pass them again. In this example, base58Encode represents the encoder provided by your base58 library.

// Ask the user to sign the exact challenge message with their wallet.
val signature = if (challenge.network == CryptoNetwork.Solana) {
 val messageBytes = challenge.message.toByteArray(Charsets.UTF_8)
 val signatureBytes = solanaWallet.signMessage(messageBytes)
 base58Encode(signatureBytes)
} else {
 // personalSign returns a 0x-prefixed hexadecimal string.
 evmWallet.personalSign(challenge.message)
}

val submitResult = coordinator.submitWalletOwnershipSignature(
 challengeId = challenge.challengeId,
 signature = signature
)
when (submitResult) {
 is OnrampSubmitWalletOwnershipSignatureResult.Completed -> {
 // submitResult.consumerWallet.verifiedOwnership == true — retry the purchase.
 }
 is OnrampSubmitWalletOwnershipSignatureResult.Failed -> {
 when (submitResult.error) {
 is WalletOwnershipChallengeExpiredException -> {
 // Request a fresh challenge and ask the wallet to sign again.
 }
 is InvalidWalletOwnershipSignatureException -> {
 // The signature does not prove ownership. Restart the full flow to retry.
 }
 else -> {
 // Handle unexpected errors.
 }
 }
 }
}

If the user cancels signing, stop the flow before calling submitWalletOwnershipSignature. Signing failures that happen in the signing UI aren’t Stripe API errors.

Complete EU flow example

// ... authenticate user (see Authentication section) ...

// 1. Submit basic KYC info with nationalities
val kycInfo = KycInfo(
 firstName = "Maria",
 lastName = "Papadopoulos",
 idNumber = null, // Not required for EU users — collected through MiCA.
 dateOfBirth = DateOfBirth( // Object with numeric fields, not a date string.
 day = 15, // Day of month (1-31).
 month = 3, // Month of year (1-12).
 year = 1990 // Full 4-digit year.
 ),
 address = PaymentSheet.Address(
 line1 = "123 Example Street",
 city = "Athens", // Free-text city name.
 postalCode = "10557",
 country = "GR" // ISO 3166-1 alpha-2 country code.
 ),
 nationalities = listOf(CountryCode("GR"), CountryCode("EE")), // List of ISO 3166-1 alpha-2 country codes.
 birthCity = "Athens",
 birthCountry = CountryCode("GR")
)
coordinator.attachKycInfo(kycInfo)

// 2. Check what identifiers are needed
val requirementsResult = coordinator.retrieveMissingIdentifiers()
// requirements.identifiers — MiCA identifiers the user must provide

// 3. Collect and submit identifiers
val identifiers = listOf(
 ComplianceIdentifier().type(ComplianceIdentifierType.EE_IK).value("39901011234")
)
coordinator.submitIdentifiers(identifiers)
// result.result.completed — true when all requirements are satisfied

// 4. Present Stripe ToS
presenter.presentCrsCarfDeclaration()
// Result delivered via OnrampCallbacks.crsCarfDeclarationCallback

// 5. Complete identity verification (document + selfie)
presenter.verifyIdentity()

Identifier types reference

Identifier type codes follow the {country_code}_{abbreviation} convention, aligned with the Persons API v2 id_numbers.type enum.

MiCA identifiers

National identifiers required for users whose nationality or country of residence is one of: EE, ES, IS, IT, MT, PL.

TypeCountryName
ee_ikEstoniaIsikukood (PIC)
es_nifSpainTax Identification Number (NIF)
is_ktIcelandKennitala (PIC)
it_cfItalyCodice fiscale
mt_nicMaltaNational Identity Card Number
mt_ppMaltaPassport Number (alternative to mt_nic)
pl_peselPolandthe related setting number
pl_nipPolandNIP (alternative to pl_pesel)

For MT and PL, an alternative identifier (passport or NIP respectively) can be used instead of the primary national identifier.

Identifier validation logic

All identifiers are first stripped of whitespace. Hyphens, slashes and spaces are stripped before checksum computation unless noted otherwise. Validation proceeds in three stages: (1) regex pattern match, (2) structural validation (date fields, prefixes), (3) checksum verification.

MiCA validation algorithms

Estonia (ee_ik) – 11 digits, dual-weight mod-11 checksum

Regex: /^\d{11}$/
Checksum:
 weights1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 1]
 sum = Σ(digit[i] * weights1[i]) for i=0..9
 remainder = sum % 11
 if remainder == 10:
 weights2 = [3, 4, 5, 6, 7, 8, 9, 1, 2, 3]
 sum = Σ(digit[i] * weights2[i]) for i=0..9
 remainder = sum % 11
 if remainder == 10: remainder = 0
 check_digit = remainder
 Valid if check_digit == digit[10]

Spain (es_nif) – 9 chars, modulo-23 check letter

Iceland (is_kt) – 10 digits, date structure validation

Regex: /^\d{10}$/
Structure:
 - Digits 0-1: day (01-31)
 - Digits 2-3: month (01-12)
 - Digits 4-5: year (last 2 digits)
 - Digit 9: century indicator (9=1900s, 0=2000s)
No checksum — validation is date structure only.

Italy (it_cf) – 16 alphanumeric, odd-even positional checksum

Regex: /^[A-Za-z]{6}\d{2}[A-Za-z]\d{2}[A-Za-z]\d{3}[A-Za-z]$/
Structure:
 - Position 8 (0-indexed): month letter from "ABCDEHLMPRST"
 - Positions 9-10: day (01-31 for males, 41-71 for females)
Checksum:
 odd_values = {0:1, 1:0, 2:5, 3:7, 4:9, 5:13, 6:15, 7:17, 8:19, 9:21,
 A:1, B:0, C:5, D:7, E:9, F:13, G:15, H:17, I:19, J:21,
 K:2, L:4, M:18, N:20, O:11, P:3, Q:6, R:8, S:12, T:14,
 U:16, V:10, W:22, X:25, Y:24, Z:23}
 even_values = {0:0, 1:1, ..., 9:9, A:0, B:1, ..., Z:25}
 sum = 0
 for i = 0..14:
 if (i+1) is odd: sum += odd_values[char[i]]
 else: sum += even_values[char[i]]
 expected = chr(65 + (sum % 26))
 Valid if expected == char[15]

Malta national ID (mt_nic) – 8 chars or 9 digits

Regex: /^\d{7}[MGAPLHBZ]$|^\d{9}$/
Validation:
 If 8 chars: last char must be one of M, G, A, P, L, H, B, Z → valid
 If 9 digits: first 2 digits must be one of: 11, 22, 33, 44, 55, 66, 77, 88

Malta passport (mt_pp) – 7 digits

Regex: /^\d{7}$/
No additional validation.
Regex: /^\d{11}$/
Structure:
 - Digits 2-3: month (01-12, 21-32, 41-52, 61-72, or 81-92 for different centuries)
 - Digits 4-5: day (01-31)
Checksum:
 weights = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3]
 sum = Σ((digit[i] * weights[i]) % 10) for i=0..9
 check_digit = (10 - (sum % 10)) % 10
 Valid if check_digit == digit[10]

Poland NIP (pl_nip) – 10 digits, weighted mod-11 checksum

Regex: /^\d{10}$/
Checksum:
 weights = [6, 5, 7, 2, 3, 4, 5, 6, 7]
 sum = Σ(digit[i] * weights[i]) for i=0..8
 remainder = sum % 11
 Invalid if remainder == 10
 Valid if remainder == digit[9]
Last verified 2026-09-27

Is this helpful?