Stripe | Financial Infrastructure to Grow Your Revenue

Stripe | Financial Infrastructure to Grow Your Revenue

5282 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 submitKycInfo
  3. Call getMissingIdentifiers to determine which MiCA identifiers are needed
  4. Collect any MiCA identifiers from the identifiers array and submit with updateKycInfo
  5. Present the Stripe Terms of Service (ToS) with promptUserAttestation
  6. 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:

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, 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

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 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:

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 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

ErrorMessage
the related settingBasic KYC info has not been submitted via submitKycInfo.
the related settingUser has not authenticated.
the related settingThe 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:

FieldDescription
completedtrue when all identifier requirements (MiCA) are satisfied – proceed to attestation
carf_tin_requiredThis field isn’t used. You can ignore it.
identifiersRemaining missing MiCA identifiers (same shape as getMissingIdentifiers)
alternativesRemaining alternative options for missing MiCA identifiers
invalid_identifiersIdentifier 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

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. 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

ErrorMessage
the related settingEU identifiers have not been fully submitted via updateKycInfo.
the related settingThe provided regulation value is not valid.
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 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 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.
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

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. 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.

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-25

Is this helpful?