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:
- For existing users, check if EU KYC collection is needed by inspecting kyc _ region , kyc _ tiers , and provided _ fields on the CryptoCustomer object
- Collect basic KYC info including nationalities and submit with submitKycInfo
- Call getMissingIdentifiers to determine which MiCA identifiers are needed
- Collect any MiCA identifiers from the identifiers array and submit with updateKycInfo
- Present the Stripe Terms of Service (ToS) with promptUserAttestation
- Complete identity verification (document + selfie) with verifyDocuments
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:
| Field | Description |
|---|---|
kyc_region | Derived from the user’s country of residence. Possible values: null (KYC not yet submitted), "us", or "eu". |
kyc_tiers | Array 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_fields | Array 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:
| Status | Meaning |
|---|---|
not_started | Basic KYC info has not been submitted or failed validation. |
pending | Basic KYC info submitted. Document verification is either not yet submitted or still processing. |
verified | Basic KYC info and identity document verification are both complete. |
rejected | Identity 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:
- kyc _ tiers contains an l2 entry with verification _ status: "verified"
- provided _ fields includes "identifiers" (identifier requirements satisfied)
- 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:
| Condition | Next 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, submitKycInfo requires the nationalities, birth_city and birth_country 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).
await onramp.submitKycInfo({
given_name: 'Maria',
surname: 'Papadopoulos',
date_of_birth: { // 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: {
line1: '123 Example Street',
city: 'Athens', // Free-text city name.
postal_code: '10557',
country: 'GR', // ISO 3166-1 alpha-2 country code.
},
nationalities: ['GR', 'EE'], // Array of ISO 3166-1 alpha-2 country codes.
birth_city: 'Athens',
birth_country: 'GR',
});
Errors
| Error | Message |
|---|---|
the related setting | nationalities is required for EU-based users. Provide at least one ISO 3166-1 alpha-2 country code. |
the related setting | Invalid value for parameter {param}. |
the related setting | User has not authenticated. |
the related setting | The request was rate limited. |
Get missing identifiers Client-side
After submitting basic KYC info, call getMissingIdentifiers 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.
const requirements = await onramp.getMissingIdentifiers();
// {
// carf_tin_required: false,
// identifiers: [
// { type: ‘ee_ik’, regulation: ‘eu_mica’ }
// ],
// alternatives: []
// }
In this example, ee_ik is required because the user has Estonian nationality, which is a MiCA country.
Each entry in identifiers contains:
| Field | Description |
|---|---|
type | The identifier type code (see Identifier types reference) |
regulation | The 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 array:
// Maltese national living in Germany
await onramp.submitKycInfo({ address: { country: 'DE' }, nationalities: ['MT'] });
const requirements = await onramp.getMissingIdentifiers();
// {
// carf_tin_required: false,
// identifiers: [
// { type: 'mt_nic', regulation: 'eu_mica' }
// ],
// alternatives: [
// {
// original_missing_identifiers: ['mt_nic'],
// alternative_missing_identifiers: ['mt_pp']
// }
// ]
// }
In this example, the user can provide either mt_nic (Malta national identity card) or mt_pp (Malta passport) to satisfy the MiCA requirement. Use the alternatives array to present the user with a choice between the two.
Errors
| Error | Message |
|---|---|
the related setting | Basic KYC info has not been submitted via submitKycInfo. |
the related setting | User has not authenticated. |
the related setting | The request was rate limited. |
Submit identifiers Client-side
Before calling updateKycInfo, 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 updateKycInfo. When completed is true, all identifier requirements are satisfied and you can proceed to the next step (attestation).
const result = await onramp.updateKycInfo([
{ type: 'ee_ik', value: '39901011234' },
]);
if (result.completed) {
// All identifier requirements satisfied — proceed to attestation
} else {
// Prompt the user to correct invalid identifiers or provide remaining ones
}
The response contains:
| Field | Description |
|---|---|
completed | true when all identifier requirements (MiCA) are satisfied – proceed to attestation |
carf_tin_required | This field isn’t used. You can ignore it. |
identifiers | Remaining missing MiCA identifiers (same shape as getMissingIdentifiers) |
alternatives | Remaining alternative options for missing MiCA identifiers |
invalid_identifiers | Identifier types that were submitted but rejected (for example, wrong format) |
Example response when all requirements are met:
// {
// completed: true,
// carf_tin_required: false,
// identifiers: [],
// alternatives: [],
// invalid_identifiers: []
// }
Example response when identifiers are invalid or missing:
// {
// completed: false,
// carf_tin_required: false,
// identifiers: [{ type: 'ee_ik', regulation: 'eu_mica' }],
// alternatives: [],
// invalid_identifiers: ['ee_ik']
// }
Errors
| Error | Message |
|---|---|
the related setting | Invalid value for parameter {param}. |
the related setting | User has not authenticated. |
the related setting | The 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. This method returns a mountable HTMLElement that renders a Stripe-hosted iframe. The onCompletion callback fires when the user accepts or abandons the declaration.
const element = await onramp.promptUserAttestation('eu_carf', ({ result }) => {
// result === 'confirmed' — user accepted, proceed to identity verification
// result === 'abandoned' — user closed without confirming
});
document.getElementById('attestation-container').replaceChildren(element);
You must call updateKycInfo and have completed: true before calling promptUserAttestation. If identifiers are incomplete, the SDK throws a MissingEuIdentifiers error.
Errors
| Error | Message |
|---|---|
the related setting | EU identifiers have not been fully submitted via updateKycInfo. |
the related setting | The provided regulation value is not valid. |
the related setting | User has not authenticated. |
the related setting | The request was rate limited. |
Complete identity verification Client-side
After identifiers and attestation are complete, call verifyDocuments to present a Stripe-hosted flow where the user uploads an identity document and a selfie.
await onramp.verifyDocuments();
// User is fully verified and can transact
Handle a rejected L2 verification
If l2.verification_status is rejected, inspect l2.verification_errors to determine the next step:
verification_errors value | Meaning | Next step |
|---|---|---|
id_document_verification_failed | The identity document or selfie was rejected by Stripe’s verification system. | Prompt the user to retry identity verification. |
user_has_reached_max_verification_attempts | The 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_attempts | The last attempt was rejected and no retries remain. | No retry is possible. Ask the user to contact Stripe support. |
const l2Tier = customer.kyc_tiers.find((t) => t.tier === 'l2');
if (l2Tier?.verification_status === 'rejected') {
const errors = l2Tier.verification_errors ?? [];
const canRetry = !errors.includes('user_has_reached_max_verification_attempts');
if (canRetry) {
// Prompt the user to retry identity verification
await onramp.verifyDocuments();
} else {
// User has exhausted all attempts — direct them to contact Stripe support
}
}
Errors
| Error | Message |
|---|---|
the related setting | User has not authenticated. |
the related setting | The 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
- Check whether the destination wallet is already verified by inspecting verified _ ownership on the crypto.consumer_wallet resource.
- If it isn’t verified, request a short-lived wallet ownership challenge with getWalletOwnershipChallenge .
- Ask the user to sign the exact challenge message with their wallet.
- Submit the signature with submitWalletOwnershipSignature .
- After verified _ ownership 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 preemptively 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 with registerWalletAddress. The challenge is short-lived and single-use, and it’s bound to the authenticated user, the wallet address and the network.
const challenge = await onramp.getWalletOwnershipChallenge({
walletAddress: '0x1234567890abcdef1234567890abcdef12345678',
network: 'ethereum',
});
// {
// challengeId: 'woc_123',
// walletAddress: '0x1234567890abcdef1234567890abcdef12345678',
// network: 'ethereum',
// message: '...', // Exact opaque string the wallet must sign.
// expiresAt: '2026-06-15T12:00:00Z',
// }
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 verified_ownership 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.
let signature;
if (challenge.network === 'solana') {
const messageBytes = new TextEncoder().encode(challenge.message);
const signatureBytes = await solanaWallet.signMessage(messageBytes);
signature = base58Encode(signatureBytes);
} else {
// personalSign returns a 0x-prefixed hexadecimal string.
signature = await evmWallet.personalSign(challenge.message);
}
try {
const consumerWallet = await onramp.submitWalletOwnershipSignature({
challengeId: challenge.challengeId,
signature,
});
// consumerWallet.verified_ownership === true — retry the purchase.
} catch (error) {
if (error.code === OnrampErrorCode.WALLET_OWNERSHIP_CHALLENGE_EXPIRED) {
// Request a fresh challenge and ask the wallet to sign again.
} else if (error.code === OnrampErrorCode.INVALID_WALLET_OWNERSHIP_SIGNATURE) {
// 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
import { loadCryptoOnrampAndInitialize } from '@stripe/crypto';
const onramp = await loadCryptoOnrampAndInitialize('pk_test_12345');
// ... authenticate user (see Authentication section) ...
// 1. Submit basic KYC info with nationalities
await onramp.submitKycInfo({
given_name: 'Maria',
surname: 'Papadopoulos',
date_of_birth: { // 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: {
line1: '123 Example Street',
city: 'Athens', // Free-text city name.
postal_code: '10557',
country: 'GR', // ISO 3166-1 alpha-2 country code.
},
nationalities: ['GR', 'EE'], // Array of ISO 3166-1 alpha-2 country codes.
birth_city: 'Athens',
birth_country: 'GR',
});
// 2. Check what identifiers are needed
const requirements = await onramp.getMissingIdentifiers();
// requirements.identifiers — MiCA identifiers the user must provide
// 3. Collect and submit MiCA identifiers
const result = await onramp.updateKycInfo([
{ type: 'ee_ik', value: '39901011234' }, // MiCA identifier for Estonian nationality
]);
// result.completed — true when all requirements are satisfied
// 4. Present Stripe ToS
const element = await onramp.promptUserAttestation('eu_carf', ({ result }) => {
// result === 'confirmed' or 'abandoned'
});
document.getElementById('attestation-container').replaceChildren(element);
// 5. Complete identity verification (document + selfie)
await onramp.verifyDocuments();
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.
| Type | Country | Name |
|---|---|---|
ee_ik | Estonia | Isikukood (PIC) |
es_nif | Spain | Tax Identification Number (NIF) |
is_kt | Iceland | Kennitala (PIC) |
it_cf | Italy | Codice fiscale |
mt_nic | Malta | National Identity Card Number |
mt_pp | Malta | Passport Number (alternative to mt_nic) |
pl_pesel | Poland | the related setting number |
pl_nip | Poland | NIP (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.
Poland the related setting (pl_pesel) – 11 digits, weighted checksum
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]
