Commit a7507226661 for woocommerce

commit a75072266611fc5a7b97fa6e4e5d05b4d4cb5ebf
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date:   Fri Jun 26 12:11:40 2026 +0100

    Harden embedded KYC loader failures (#65863)

    * fix: harden embedded KYC loader failures

    * Refine embedded KYC failure handling

    * Improve embedded KYC failure accessibility

    * Harden embedded KYC failure tests

diff --git a/plugins/woocommerce/changelog/fix-embedded-kyc-loader-failure-hardening b/plugins/woocommerce/changelog/fix-embedded-kyc-loader-failure-hardening
new file mode 100644
index 00000000000..b8bbd899b70
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-embedded-kyc-loader-failure-hardening
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent embedded WooPayments business verification from hanging when the Stripe loader fails.
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/components/embedded/index.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/components/embedded/index.tsx
index 8e25452f73b..6821bc40118 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/components/embedded/index.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/components/embedded/index.tsx
@@ -19,7 +19,12 @@ import { __ } from '@wordpress/i18n';
  */
 import { createEmbeddedKycSession } from '../../utils/actions';
 import appearance from './appearance';
-import { OnboardingFields } from '../../types';
+import {
+	type EmbeddedAccountInitializationFailure,
+	EmbeddedKycSession,
+	EmbeddedKycSessionCreateResult,
+	OnboardingFields,
+} from '../../types';
 import BannerNotice from '../../../../components/banner-notice';
 import { useOnboardingContext } from '../../../../data/onboarding-context';

@@ -32,9 +37,75 @@ interface EmbeddedAccountOnboardingProps extends EmbeddedComponentProps {
 	onboardingData: OnboardingFields;
 	onExit: () => void;
 	onStepChange?: ( step: string ) => void;
+	onInitializationError?: (
+		failure: EmbeddedAccountInitializationFailure
+	) => void;
 	collectPayoutRequirements?: boolean;
 }

+const defaultLocale = 'en-US';
+const genericInitializationError = __(
+	'Unable to start the business verification session. If this problem persists, please contact support.',
+	'woocommerce'
+);
+
+const isObjectRecord = ( value: unknown ): value is Record< string, unknown > =>
+	!! value && typeof value === 'object' && ! Array.isArray( value );
+
+const getObjectKeys = ( value: unknown ): string[] =>
+	isObjectRecord( value ) ? Object.keys( value ).sort() : [];
+
+const isNonEmptyString = ( value: unknown ): value is string =>
+	typeof value === 'string' && value.trim() !== '';
+
+const normalizeLocale = ( value: unknown ): string =>
+	isNonEmptyString( value )
+		? value.trim().replace( /_/g, '-' )
+		: defaultLocale;
+
+const validateEmbeddedKycSessionCreateResult = (
+	result: unknown
+):
+	| { result: EmbeddedKycSessionCreateResult }
+	| { error: EmbeddedAccountInitializationFailure } => {
+	const session = isObjectRecord( result ) ? result.session : undefined;
+	const receivedKeys = isObjectRecord( session )
+		? getObjectKeys( session )
+		: getObjectKeys( result );
+
+	if ( ! isObjectRecord( session ) ) {
+		return {
+			error: {
+				reason: 'bad_session',
+				message: genericInitializationError,
+				receivedKeys,
+			},
+		};
+	}
+
+	if (
+		! isNonEmptyString( session.clientSecret ) ||
+		! isNonEmptyString( session.publishableKey )
+	) {
+		return {
+			error: {
+				reason: 'bad_session',
+				message: genericInitializationError,
+				receivedKeys,
+			},
+		};
+	}
+
+	const normalizedSession: EmbeddedKycSession = {
+		...( session as unknown as EmbeddedKycSession ),
+		clientSecret: session.clientSecret,
+		publishableKey: session.publishableKey,
+		locale: normalizeLocale( session.locale ),
+	};
+
+	return { result: { session: normalizedSession } };
+};
+
 /**
  * Hook to initialize Stripe Connect.
  *
@@ -47,9 +118,9 @@ const useInitializeStripe = ( onboardingData: OnboardingFields ) => {
 		useState< StripeConnectInstance | null >( null );
 	const { currentStep, sessionEntryPoint: onboardingSource } =
 		useOnboardingContext();
-	const [ initializationError, setInitializationError ] = useState<
-		string | null
-	>( null );
+	const kycSessionUrl = currentStep?.actions?.kyc_session?.href ?? '';
+	const [ initializationError, setInitializationError ] =
+		useState< EmbeddedAccountInitializationFailure | null >( null );
 	const [ loading, setLoading ] = useState< boolean >( true );

 	useEffect( () => {
@@ -57,21 +128,20 @@ const useInitializeStripe = ( onboardingData: OnboardingFields ) => {
 			try {
 				const accountSession = await createEmbeddedKycSession(
 					onboardingData,
-					currentStep?.actions?.kyc_session?.href ?? '',
+					kycSessionUrl,
 					onboardingSource
 				);

-				const { clientSecret, publishableKey } = accountSession.session;
-
-				if ( ! publishableKey ) {
-					throw new Error(
-						__(
-							'Unable to start the business verification session. If this problem persists, please contact support.',
-							'woocommerce'
-						)
-					);
+				const validation =
+					validateEmbeddedKycSessionCreateResult( accountSession );
+				if ( 'error' in validation ) {
+					setInitializationError( validation.error );
+					return;
 				}

+				const { clientSecret, publishableKey, locale } =
+					validation.result.session;
+
 				const instance = loadConnectAndInitialize( {
 					publishableKey,
 					fetchClientSecret: async () => clientSecret,
@@ -79,26 +149,22 @@ const useInitializeStripe = ( onboardingData: OnboardingFields ) => {
 						overlays: 'drawer',
 						...appearance,
 					},
-					locale: accountSession.session.locale.replace( '_', '-' ),
+					locale,
 				} );

 				setStripeConnectInstance( instance );
 			} catch ( err ) {
-				setInitializationError(
-					err instanceof Error
-						? err.message
-						: __(
-								'Unable to start the business verification session. If this problem persists, please contact support.',
-								'woocommerce'
-						  )
-				);
+				setInitializationError( {
+					reason: 'init_error',
+					message: genericInitializationError,
+				} );
 			} finally {
 				setLoading( false );
 			}
 		};

 		initializeStripe();
-	}, [ onboardingData ] );
+	}, [ kycSessionUrl, onboardingData, onboardingSource ] );

 	return { stripeConnectInstance, initializationError, loading };
 };
@@ -112,6 +178,7 @@ const useInitializeStripe = ( onboardingData: OnboardingFields ) => {
  * @param onLoaderStart                     - Callback function when the onboarding loader starts.
  * @param onLoadError                       - Callback function when the onboarding load error occurs.
  * @param [onStepChange]                    - Callback function when the onboarding step changes.
+ * @param [onInitializationError]           - Callback function when the onboarding initialization fails.
  * @param [collectPayoutRequirements=false] - Whether to collect payout requirements.
  *
  * @return Rendered Account Onboarding component.
@@ -124,16 +191,23 @@ export const EmbeddedAccountOnboarding: React.FC<
 	onLoaderStart,
 	onLoadError,
 	onStepChange,
+	onInitializationError,
 	collectPayoutRequirements = false,
 } ) => {
 	const { stripeConnectInstance, initializationError } =
 		useInitializeStripe( onboardingData );

+	useEffect( () => {
+		if ( initializationError ) {
+			onInitializationError?.( initializationError );
+		}
+	}, [ initializationError, onInitializationError ] );
+
 	return (
 		<>
-			{ initializationError && (
+			{ initializationError && ! onInitializationError && (
 				<BannerNotice status="error">
-					{ initializationError }
+					{ initializationError.message }
 				</BannerNotice>
 			) }
 			{ stripeConnectInstance && (
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/sections/embedded-kyc.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/sections/embedded-kyc.tsx
index 62ce9cae177..0e493501051 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/sections/embedded-kyc.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/sections/embedded-kyc.tsx
@@ -1,7 +1,7 @@
 /**
  * External dependencies
  */
-import React, { useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
 import { __ } from '@wordpress/i18n';
 import { LoadError } from '@stripe/connect-js';

@@ -14,6 +14,7 @@ import BannerNotice from '../../../components/banner-notice';
 import { useBusinessVerificationContext } from '../data/business-verification-context';
 import { finalizeEmbeddedKycSession } from '../utils/actions';
 import { EmbeddedAccountOnboarding } from '../components/embedded';
+import { type EmbeddedAccountInitializationFailure } from '../types';
 import { recordPaymentsOnboardingEvent } from '~/settings-payments/utils';

 interface Props {
@@ -21,6 +22,75 @@ interface Props {
 	collectPayoutRequirements?: boolean;
 }

+type EmbeddedKycLoadFailureReason =
+	| 'timeout'
+	| 'load_error'
+	| 'bad_session'
+	| 'init_error';
+
+type EmbeddedKycLoadFailure = {
+	reason: EmbeddedKycLoadFailureReason;
+	message?: string;
+	errorType?: string;
+	receivedKeys?: string[];
+};
+
+const embeddedKycLoadTimeoutMs = 20000;
+const embeddedKycTroubleshootingUrl =
+	'https://woocommerce.com/document/woopayments/startup-guide/#requirements';
+const embeddedKycFailureMessage = __(
+	"We couldn't load this step. This can happen when your site's security or server settings block a required connection to Stripe. Check the setup requirements, or contact support if the error persists.",
+	'woocommerce'
+);
+const embeddedKycHttpsFailureMessage = __(
+	'Payment activation through our financial partner requires HTTPS and cannot be completed.',
+	'woocommerce'
+);
+const embeddedKycLoadingMessage = __( 'Loading onboarding…', 'woocommerce' );
+const embeddedKycFinalizingMessage = __(
+	'Finalizing onboarding…',
+	'woocommerce'
+);
+
+const getFailureTrackingDetails = (
+	failure: EmbeddedKycLoadFailure
+): Record< string, string > => {
+	const details: Record< string, string > = {};
+
+	if ( failure.errorType ) {
+		details.error_type = failure.errorType;
+	}
+
+	if ( failure.message && failure.reason !== 'init_error' ) {
+		details.error_message = failure.message;
+	}
+
+	if ( failure.receivedKeys ) {
+		details.received_keys = failure.receivedKeys.join( ',' ) || 'none';
+	}
+
+	return details;
+};
+
+const getFailureNoticeMessage = ( failure: EmbeddedKycLoadFailure ) => {
+	if (
+		failure.reason === 'load_error' &&
+		failure.errorType === 'invalid_request_error'
+	) {
+		return embeddedKycHttpsFailureMessage;
+	}
+
+	return embeddedKycFailureMessage;
+};
+
+const getFailureNoticeStatus = (
+	failure: EmbeddedKycLoadFailure
+): 'error' | 'warning' =>
+	failure.reason === 'load_error' &&
+	failure.errorType === 'invalid_request_error'
+		? 'warning'
+		: 'error';
+
 const EmbeddedKyc: React.FC< Props > = ( {
 	collectPayoutRequirements = false,
 } ) => {
@@ -32,9 +102,59 @@ const EmbeddedKyc: React.FC< Props > = ( {
 	} = useOnboardingContext();
 	const [ finalizingSession, setFinalizingSession ] = useState( false );
 	const [ loading, setLoading ] = useState( true );
-	const [ loadError, setLoadError ] = useState< LoadError | null >( null );
+	const [ loadFailure, setLoadFailure ] =
+		useState< EmbeddedKycLoadFailure | null >( null );
+	const loadFailureRef = useRef( false );
+	const loadFailureNoticeRef = useRef< HTMLDivElement >( null );
 	const fallbackUrl = currentStep?.actions?.kyc_fallback?.href ?? '';

+	const failEmbeddedKycLoad = useCallback(
+		( failure: EmbeddedKycLoadFailure ) => {
+			if ( loadFailureRef.current ) {
+				return;
+			}
+
+			loadFailureRef.current = true;
+			setLoading( false );
+			setLoadFailure( failure );
+			recordPaymentsOnboardingEvent(
+				'woopayments_onboarding_modal_kyc_load_error',
+				{
+					reason: failure.reason,
+					collect_payout_requirements: collectPayoutRequirements,
+					source: onboardingSource,
+					...getFailureTrackingDetails( failure ),
+				}
+			);
+		},
+		[ collectPayoutRequirements, onboardingSource ]
+	);
+
+	useEffect( () => {
+		if ( ! loading || loadFailure || finalizingSession ) {
+			return;
+		}
+
+		const timerId = window.setTimeout( () => {
+			failEmbeddedKycLoad( { reason: 'timeout' } );
+		}, embeddedKycLoadTimeoutMs );
+
+		return () => window.clearTimeout( timerId );
+	}, [ failEmbeddedKycLoad, finalizingSession, loadFailure, loading ] );
+
+	useEffect( () => {
+		if ( ! loadFailure ) {
+			return;
+		}
+
+		const notice = loadFailureNoticeRef.current;
+		const activeElement = notice?.ownerDocument.activeElement ?? null;
+
+		if ( notice && ! notice.contains( activeElement ) ) {
+			notice.focus();
+		}
+	}, [ loadFailure ] );
+
 	const handleStepChange = ( step: string ) => {
 		recordPaymentsOnboardingEvent(
 			'woopayments_onboarding_modal_kyc_step_change',
@@ -66,6 +186,10 @@ const EmbeddedKyc: React.FC< Props > = ( {
 	};

 	const handleLoaderStart = () => {
+		if ( loadFailureRef.current ) {
+			return;
+		}
+
 		recordPaymentsOnboardingEvent(
 			'woopayments_onboarding_modal_kyc_started_loading',
 			{
@@ -78,75 +202,76 @@ const EmbeddedKyc: React.FC< Props > = ( {
 	};

 	const handleLoadError = ( err: LoadError ) => {
-		recordPaymentsOnboardingEvent(
-			'woopayments_onboarding_modal_kyc_load_error',
-			{
-				error_type: err.error.type,
-				error_message: err.error.message || 'no_message',
-				collect_payout_requirements: collectPayoutRequirements,
-				source: onboardingSource,
-			}
-		);
-
-		setLoadError( err );
+		failEmbeddedKycLoad( {
+			reason: 'load_error',
+			errorType: err.error.type,
+			message: err.error.message || 'no_message',
+		} );
 	};

+	const handleInitializationError = useCallback(
+		( failure: EmbeddedAccountInitializationFailure ) => {
+			failEmbeddedKycLoad( failure );
+		},
+		[ failEmbeddedKycLoad ]
+	);
+
 	return (
 		<>
-			{ loadError &&
-				( loadError.error.type === 'invalid_request_error' ? (
+			{ loadFailure && (
+				<div ref={ loadFailureNoticeRef } tabIndex={ -1 }>
 					<BannerNotice
-						className={ 'woopayments-banner-notice--embedded-kyc' }
-						status="warning"
+						className="woopayments-banner-notice--embedded-kyc"
+						status={ getFailureNoticeStatus( loadFailure ) }
 						isDismissible={ false }
 						actions={ [
 							{
-								label: 'Learn more',
+								label: __( 'Learn more', 'woocommerce' ),
 								variant: 'primary',
-								url: 'https://woocommerce.com/document/woopayments/startup-guide/#requirements',
+								url: embeddedKycTroubleshootingUrl,
 								urlTarget: '_blank',
 							},
 							{
-								label: 'Cancel',
+								label: __( 'Cancel', 'woocommerce' ),
 								variant: 'link',
 								url: fallbackUrl,
 							},
 						] }
 					>
-						{ __(
-							'Payment activation through our financial partner requires HTTPS and cannot be completed.',
-							'woocommerce'
-						) }
-					</BannerNotice>
-				) : (
-					<BannerNotice
-						className={ 'woopayments-banner-notice--embedded-kyc' }
-						status="error"
-						isDismissible={ false }
-					>
-						{ loadError.error.message }
+						{ getFailureNoticeMessage( loadFailure ) }
 					</BannerNotice>
-				) ) }
+				</div>
+			) }
 			{ loading && (
-				<div className="embedded-kyc-loader-wrapper padded">
+				<div
+					className="embedded-kyc-loader-wrapper padded"
+					role="status"
+				>
+					<span className="screen-reader-text">
+						{ embeddedKycLoadingMessage }
+					</span>
 					<StripeSpinner />
 				</div>
 			) }
 			{ finalizingSession && (
-				<div className="embedded-kyc-loader-wrapper">
+				<div className="embedded-kyc-loader-wrapper" role="status">
+					<span className="screen-reader-text">
+						{ embeddedKycFinalizingMessage }
+					</span>
 					<StripeSpinner />
 				</div>
 			) }
-			{
+			{ ! loadFailure && (
 				<EmbeddedAccountOnboarding
 					onExit={ handleOnExit }
 					onStepChange={ handleStepChange }
 					onLoaderStart={ handleLoaderStart }
 					onLoadError={ handleLoadError }
+					onInitializationError={ handleInitializationError }
 					onboardingData={ data }
 					collectPayoutRequirements={ collectPayoutRequirements }
 				/>
-			}
+			) }
 		</>
 	);
 };
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/embedded-account-onboarding.test.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/embedded-account-onboarding.test.tsx
new file mode 100644
index 00000000000..6fdf656c757
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/embedded-account-onboarding.test.tsx
@@ -0,0 +1,210 @@
+/**
+ * External dependencies
+ */
+import { loadConnectAndInitialize } from '@stripe/connect-js';
+import { render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import { EmbeddedAccountOnboarding } from '../components/embedded';
+import { createEmbeddedKycSession } from '../utils/actions';
+import { useOnboardingContext } from '../../../data/onboarding-context';
+import type { EmbeddedKycSessionCreateResult } from '../types';
+
+jest.mock( '@stripe/connect-js', () => ( {
+	loadConnectAndInitialize: jest.fn( () => ( {
+		mockStripeConnectInstance: true,
+	} ) ),
+} ) );
+
+jest.mock( '@stripe/react-connect-js', () => ( {
+	ConnectComponentsProvider: ( {
+		children,
+	}: {
+		children: React.ReactNode;
+	} ) => <div data-testid="connect-components-provider">{ children }</div>,
+	ConnectAccountOnboarding: () => (
+		<div data-testid="connect-account-onboarding" />
+	),
+} ) );
+
+jest.mock( '../utils/actions', () => ( {
+	createEmbeddedKycSession: jest.fn(),
+} ) );
+
+jest.mock( '../../../data/onboarding-context', () => ( {
+	useOnboardingContext: jest.fn(),
+} ) );
+
+const mockCreateEmbeddedKycSession =
+	createEmbeddedKycSession as jest.MockedFunction<
+		typeof createEmbeddedKycSession
+	>;
+const mockLoadConnectAndInitialize =
+	loadConnectAndInitialize as jest.MockedFunction<
+		typeof loadConnectAndInitialize
+	>;
+const mockUseOnboardingContext = useOnboardingContext as jest.Mock;
+
+const createSession = (
+	overrides: Partial< EmbeddedKycSessionCreateResult[ 'session' ] > = {}
+): EmbeddedKycSessionCreateResult => ( {
+	session: {
+		clientSecret: 'test-secret',
+		publishableKey: 'test-key',
+		locale: 'en_US',
+		expiresAt: 1234567890,
+		accountId: 'acct_test',
+		isLive: false,
+		accountCreated: true,
+		...overrides,
+	},
+} );
+
+const renderEmbeddedAccountOnboarding = (
+	overrides: Partial<
+		React.ComponentProps< typeof EmbeddedAccountOnboarding >
+	> = {}
+) => {
+	return render(
+		<EmbeddedAccountOnboarding
+			onboardingData={ {} }
+			onExit={ jest.fn() }
+			{ ...overrides }
+		/>
+	);
+};
+
+describe( 'EmbeddedAccountOnboarding', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+		mockUseOnboardingContext.mockReturnValue( {
+			currentStep: {
+				actions: {
+					kyc_session: {
+						href: 'https://example.com/session',
+					},
+				},
+			},
+			sessionEntryPoint: 'settings',
+		} );
+		mockCreateEmbeddedKycSession.mockResolvedValue( createSession() );
+	} );
+
+	it( 'notifies the parent when the KYC session shape is invalid', async () => {
+		const mockOnInitializationError = jest.fn();
+		mockCreateEmbeddedKycSession.mockResolvedValue( {
+			session: {
+				unexpected: 'value',
+			},
+		} as never );
+
+		renderEmbeddedAccountOnboarding( {
+			onInitializationError: mockOnInitializationError,
+		} );
+
+		await waitFor( () =>
+			expect( mockOnInitializationError ).toHaveBeenCalledWith( {
+				reason: 'bad_session',
+				message:
+					'Unable to start the business verification session. If this problem persists, please contact support.',
+				receivedKeys: [ 'unexpected' ],
+			} )
+		);
+		expect(
+			screen.queryByTestId( 'connect-account-onboarding' )
+		).not.toBeInTheDocument();
+		expect( mockLoadConnectAndInitialize ).not.toHaveBeenCalled();
+	} );
+
+	it( 'notifies the parent when initialization throws unexpectedly', async () => {
+		const mockOnInitializationError = jest.fn();
+		mockCreateEmbeddedKycSession.mockRejectedValue(
+			new Error( 'Network unavailable.' )
+		);
+
+		renderEmbeddedAccountOnboarding( {
+			onInitializationError: mockOnInitializationError,
+		} );
+
+		await waitFor( () =>
+			expect( mockOnInitializationError ).toHaveBeenCalledWith( {
+				reason: 'init_error',
+				message:
+					'Unable to start the business verification session. If this problem persists, please contact support.',
+			} )
+		);
+		expect(
+			screen.queryByTestId( 'connect-account-onboarding' )
+		).not.toBeInTheDocument();
+	} );
+
+	it.each( [ undefined, '', 42 ] )(
+		'defaults the locale when the KYC session returns %p',
+		async ( locale ) => {
+			mockCreateEmbeddedKycSession.mockResolvedValue(
+				createSession( {
+					locale: locale as never,
+				} )
+			);
+
+			renderEmbeddedAccountOnboarding();
+
+			expect(
+				await screen.findByTestId( 'connect-account-onboarding' )
+			).toBeInTheDocument();
+			expect( mockLoadConnectAndInitialize ).toHaveBeenCalledWith(
+				expect.objectContaining( {
+					locale: 'en-US',
+				} )
+			);
+		}
+	);
+
+	it.each( [
+		[ 'en_US', 'en-US' ],
+		[ ' sr_Latn_RS ', 'sr-Latn-RS' ],
+	] )( 'normalizes locale %p to %p', async ( locale, expectedLocale ) => {
+		mockCreateEmbeddedKycSession.mockResolvedValue(
+			createSession( { locale } )
+		);
+
+		renderEmbeddedAccountOnboarding();
+
+		expect(
+			await screen.findByTestId( 'connect-account-onboarding' )
+		).toBeInTheDocument();
+		expect( mockLoadConnectAndInitialize ).toHaveBeenCalledWith(
+			expect.objectContaining( {
+				locale: expectedLocale,
+			} )
+		);
+	} );
+
+	it( 'passes session credentials through to Stripe for a valid session', async () => {
+		renderEmbeddedAccountOnboarding();
+
+		expect(
+			await screen.findByTestId( 'connect-account-onboarding' )
+		).toBeInTheDocument();
+
+		const [ firstInitializeCall ] = mockLoadConnectAndInitialize.mock.calls;
+		const initializeOptions = firstInitializeCall?.[ 0 ];
+
+		if ( ! initializeOptions ) {
+			throw new Error( 'Expected Stripe initialization options.' );
+		}
+
+		expect( initializeOptions ).toEqual(
+			expect.objectContaining( {
+				publishableKey: 'test-key',
+				locale: 'en-US',
+			} )
+		);
+		await expect( initializeOptions.fetchClientSecret() ).resolves.toBe(
+			'test-secret'
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/embedded-kyc.test.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/embedded-kyc.test.tsx
new file mode 100644
index 00000000000..cd12cc8fc83
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/embedded-kyc.test.tsx
@@ -0,0 +1,316 @@
+/**
+ * External dependencies
+ */
+import { act, render, screen } from '@testing-library/react';
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import EmbeddedKyc from '../sections/embedded-kyc';
+import { useOnboardingContext } from '../../../data/onboarding-context';
+import { useBusinessVerificationContext } from '../data/business-verification-context';
+import { recordPaymentsOnboardingEvent } from '~/settings-payments/utils';
+
+type MockEmbeddedAccountOnboardingProps = {
+	onLoaderStart?: ( value: { elementTagName: string } ) => void;
+	onLoadError?: ( value: {
+		error: { type: string; message?: string };
+		elementTagName: string;
+	} ) => void;
+	onInitializationError?: ( failure: {
+		reason: 'bad_session' | 'init_error';
+		message: string;
+		receivedKeys?: string[];
+	} ) => void;
+	[ key: string ]: unknown;
+};
+
+let mockEmbeddedAccountOnboardingProps: MockEmbeddedAccountOnboardingProps;
+
+jest.mock( '../components/embedded', () => ( {
+	EmbeddedAccountOnboarding: (
+		props: MockEmbeddedAccountOnboardingProps
+	) => {
+		mockEmbeddedAccountOnboardingProps = props;
+		return <div data-testid="embedded-account-onboarding" />;
+	},
+} ) );
+
+jest.mock( '../../../data/onboarding-context', () => ( {
+	useOnboardingContext: jest.fn(),
+} ) );
+
+jest.mock( '../data/business-verification-context', () => ( {
+	useBusinessVerificationContext: jest.fn(),
+} ) );
+
+jest.mock( '../../../components/stripe-spinner', () => ( {
+	__esModule: true,
+	default: () => <div data-testid="stripe-spinner" />,
+} ) );
+
+jest.mock( '../utils/actions', () => ( {
+	finalizeEmbeddedKycSession: jest.fn(),
+} ) );
+
+jest.mock( '~/settings-payments/utils', () => ( {
+	recordPaymentsOnboardingEvent: jest.fn(),
+} ) );
+
+const mockUseOnboardingContext = useOnboardingContext as jest.Mock;
+const mockUseBusinessVerificationContext =
+	useBusinessVerificationContext as jest.Mock;
+const mockRecordPaymentsOnboardingEvent =
+	recordPaymentsOnboardingEvent as jest.MockedFunction<
+		typeof recordPaymentsOnboardingEvent
+	>;
+
+const mockContexts = () => {
+	mockUseBusinessVerificationContext.mockReturnValue( { data: {} } );
+	mockUseOnboardingContext.mockReturnValue( {
+		currentStep: {
+			actions: {
+				kyc_fallback: {
+					href: 'https://example.com/fallback',
+				},
+				kyc_session_finish: {
+					href: 'https://example.com/session/finish',
+				},
+			},
+		},
+		navigateToNextStep: jest.fn(),
+		sessionEntryPoint: 'settings',
+	} );
+};
+
+const getFailureNotice = (): HTMLElement => {
+	const notice = screen
+		.getByRole( 'link', { name: 'Learn more' } )
+		.closest< HTMLElement >( '[tabindex="-1"]' );
+
+	if ( ! notice ) {
+		throw new Error( 'Expected focused failure notice.' );
+	}
+
+	return notice;
+};
+
+describe( 'EmbeddedKyc', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+		jest.useRealTimers();
+		mockEmbeddedAccountOnboardingProps = {};
+		mockContexts();
+	} );
+
+	afterEach( () => {
+		jest.useRealTimers();
+	} );
+
+	it( 'announces the loading state while waiting for the embedded loader', () => {
+		render( <EmbeddedKyc /> );
+
+		expect( screen.getByRole( 'status' ) ).toHaveTextContent(
+			'Loading onboarding…'
+		);
+	} );
+
+	it( 'shows a unified error when the embedded loader does not start before the timeout', () => {
+		jest.useFakeTimers();
+
+		render( <EmbeddedKyc /> );
+
+		act( () => {
+			jest.advanceTimersByTime( 20000 );
+		} );
+
+		const notice = getFailureNotice();
+
+		expect( notice ).toHaveTextContent(
+			"We couldn't load this step. This can happen when your site's security or server settings block a required connection to Stripe. Check the setup requirements, or contact support if the error persists."
+		);
+		expect( notice ).toHaveFocus();
+		expect(
+			screen.getByRole( 'link', { name: 'Learn more' } )
+		).toHaveAttribute(
+			'href',
+			'https://woocommerce.com/document/woopayments/startup-guide/#requirements'
+		);
+		expect(
+			screen.queryByTestId( 'embedded-account-onboarding' )
+		).not.toBeInTheDocument();
+		expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+			'woopayments_onboarding_modal_kyc_load_error',
+			{
+				reason: 'timeout',
+				collect_payout_requirements: false,
+				source: 'settings',
+			}
+		);
+	} );
+
+	it( 'clears the timeout when the embedded loader starts', () => {
+		jest.useFakeTimers();
+
+		render( <EmbeddedKyc collectPayoutRequirements /> );
+
+		act( () => {
+			mockEmbeddedAccountOnboardingProps.onLoaderStart?.( {
+				elementTagName: 'connect-account-onboarding',
+			} );
+		} );
+		act( () => {
+			jest.advanceTimersByTime( 20000 );
+		} );
+
+		expect(
+			screen.queryByRole( 'link', { name: 'Learn more' } )
+		).not.toBeInTheDocument();
+		expect(
+			screen.getByTestId( 'embedded-account-onboarding' )
+		).toBeInTheDocument();
+		expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+			'woopayments_onboarding_modal_kyc_started_loading',
+			{
+				collect_payout_requirements: true,
+				source: 'settings',
+			}
+		);
+		expect( mockRecordPaymentsOnboardingEvent ).not.toHaveBeenCalledWith(
+			'woopayments_onboarding_modal_kyc_load_error',
+			expect.anything()
+		);
+	} );
+
+	it( 'shows a unified error when Stripe reports a load error', () => {
+		render( <EmbeddedKyc /> );
+
+		act( () => {
+			mockEmbeddedAccountOnboardingProps.onLoadError?.( {
+				error: {
+					type: 'api_connection_error',
+					message: 'Stripe failed to load.',
+				},
+				elementTagName: 'connect-account-onboarding',
+			} );
+		} );
+
+		expect(
+			screen.getByRole( 'link', { name: 'Learn more' } )
+		).toBeInTheDocument();
+		expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+			'woopayments_onboarding_modal_kyc_load_error',
+			{
+				reason: 'load_error',
+				error_type: 'api_connection_error',
+				error_message: 'Stripe failed to load.',
+				collect_payout_requirements: false,
+				source: 'settings',
+			}
+		);
+	} );
+
+	it( 'shows HTTPS-specific copy when Stripe reports an invalid request', () => {
+		render( <EmbeddedKyc /> );
+
+		act( () => {
+			mockEmbeddedAccountOnboardingProps.onLoadError?.( {
+				error: {
+					type: 'invalid_request_error',
+					message: 'This application requires HTTPS.',
+				},
+				elementTagName: 'connect-account-onboarding',
+			} );
+		} );
+
+		expect( getFailureNotice() ).toHaveTextContent(
+			'Payment activation through our financial partner requires HTTPS and cannot be completed.'
+		);
+		expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+			'woopayments_onboarding_modal_kyc_load_error',
+			{
+				reason: 'load_error',
+				error_type: 'invalid_request_error',
+				error_message: 'This application requires HTTPS.',
+				collect_payout_requirements: false,
+				source: 'settings',
+			}
+		);
+	} );
+
+	it( 'shows a unified error when initialization fails', () => {
+		render( <EmbeddedKyc /> );
+
+		act( () => {
+			mockEmbeddedAccountOnboardingProps.onInitializationError?.( {
+				reason: 'bad_session',
+				message: 'Unable to start onboarding.',
+				receivedKeys: [ 'unexpected' ],
+			} );
+		} );
+
+		expect(
+			screen.getByRole( 'link', { name: 'Learn more' } )
+		).toBeInTheDocument();
+		expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+			'woopayments_onboarding_modal_kyc_load_error',
+			{
+				reason: 'bad_session',
+				error_message: 'Unable to start onboarding.',
+				received_keys: 'unexpected',
+				collect_payout_requirements: false,
+				source: 'settings',
+			}
+		);
+	} );
+
+	it( 'omits initialization error messages from analytics', () => {
+		render( <EmbeddedKyc /> );
+
+		act( () => {
+			mockEmbeddedAccountOnboardingProps.onInitializationError?.( {
+				reason: 'init_error',
+				message: 'Network failed for https://internal.example/token.',
+			} );
+		} );
+
+		expect( getFailureNotice() ).toHaveTextContent(
+			"We couldn't load this step. This can happen when your site's security or server settings block a required connection to Stripe. Check the setup requirements, or contact support if the error persists."
+		);
+		expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+			'woopayments_onboarding_modal_kyc_load_error',
+			{
+				reason: 'init_error',
+				collect_payout_requirements: false,
+				source: 'settings',
+			}
+		);
+	} );
+
+	it( 'keeps the unified error when the loader starts after the timeout', () => {
+		jest.useFakeTimers();
+
+		render( <EmbeddedKyc /> );
+		const onLoaderStart = mockEmbeddedAccountOnboardingProps.onLoaderStart;
+
+		act( () => {
+			jest.advanceTimersByTime( 20000 );
+			onLoaderStart?.( {
+				elementTagName: 'connect-account-onboarding',
+			} );
+		} );
+
+		expect(
+			screen.getByRole( 'link', { name: 'Learn more' } )
+		).toBeInTheDocument();
+		expect(
+			screen.queryByTestId( 'embedded-account-onboarding' )
+		).not.toBeInTheDocument();
+		expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledTimes( 1 );
+		expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+			'woopayments_onboarding_modal_kyc_load_error',
+			expect.objectContaining( { reason: 'timeout' } )
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/types.ts b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/types.ts
index e1b95b94282..6e5c0ab8e8f 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/types.ts
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/types.ts
@@ -63,6 +63,16 @@ export interface EmbeddedKycSessionCreateResult {
 	session: EmbeddedKycSession;
 }

+export type EmbeddedAccountInitializationFailureReason =
+	| 'bad_session'
+	| 'init_error';
+
+export interface EmbeddedAccountInitializationFailure {
+	reason: EmbeddedAccountInitializationFailureReason;
+	message: string;
+	receivedKeys?: string[];
+}
+
 /**
  * Finalize embedded KYC session response.
  */