Problem Summary
In Stripe Connect marketplaces, connected accounts (your sellers or service providers) must pass identity verification before payouts are enabled. When verification fails, the account enters a restricted state and payouts are blocked but Stripe does not send this information to users in a user-friendly way. Your platform must read the account.updated webhook, parse requirements.currently_due and requirements.errors, and surface actionable next steps to the account holder.
Symptoms
- account.updated webhook fires with requirements.currently_due containing 'individual.verification.document'
- Connected account payouts_enabled is false even after the user completed onboarding
- Stripe Dashboard shows account in 'Restricted' or 'Restricted Soon' state
- account.requirements.errors array contains verification error codes like 'verification_document_id_number_mismatch'
- Users report completing identity upload but receiving no confirmation
- AccountLink URLs expire before the user completes the flow
- charges_enabled is true but payouts_enabled is false charges accepted but funds trapped
- requirements.disabled_reason is 'requirements.past_due' and users cannot find what to fix
Business Impact
A marketplace where sellers cannot receive payouts is a marketplace that loses sellers. If your onboarding funnel drops users at the Stripe verification step without clear guidance, you lose the acquisition cost for every user who abandons. Worse, if verification fails silently after initial approval, funds accumulate in connected accounts that cannot be paid out, creating support burden and potential legal exposure.
Root Cause
Most Connect verification failures stem from three sources: (1) the platform does not listen to account.updated events and therefore never re-prompts users when Stripe requests additional information, (2) the AccountLink is generated once and cached but AccountLinks expire after 5 minutes, (3) the platform uses Express onboarding but passes insufficient pre-fill data, forcing users to enter information that could have been pre-populated.
AccountLinks expire after a single use or after 5 minutes, whichever comes first. Never store or reuse an AccountLink URL. Always generate a fresh URL when the user clicks the onboarding button.
Diagnosis
Step 1 Read the Account Object Directly
Retrieve the connected account object and inspect the requirements fields to understand exactly what Stripe is waiting for.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
async function diagnoseAccount(accountId: string) {
const account = await stripe.accounts.retrieve(accountId);
console.log("charges_enabled:", account.charges_enabled);
console.log("payouts_enabled:", account.payouts_enabled);
console.log("disabled_reason:", account.requirements?.disabled_reason);
console.log(
"currently_due:",
JSON.stringify(account.requirements?.currently_due, null, 2)
);
console.log(
"errors:",
JSON.stringify(account.requirements?.errors, null, 2)
);
console.log(
"eventually_due:",
JSON.stringify(account.requirements?.eventually_due, null, 2)
);
}
diagnoseAccount("acct_1ABC2DEF3GHI4JKL");Step 2 Handle account.updated Webhook
The account.updated event fires whenever Stripe changes the connected account's requirements. Listen to this event and update your internal state, then notify the user if action is required.
import Stripe from "stripe";
type AccountRequirements = Stripe.Account.Requirements;
function isActionRequired(requirements: AccountRequirements | null): boolean {
if (!requirements) return false;
return (
(requirements.currently_due?.length ?? 0) > 0 ||
(requirements.past_due?.length ?? 0) > 0
);
}
export async function handleAccountUpdated(event: Stripe.Event) {
const account = event.data.object as Stripe.Account;
await db.connectedAccounts.update({
where: { stripeAccountId: account.id },
data: {
chargesEnabled: account.charges_enabled,
payoutsEnabled: account.payouts_enabled,
requirementsDisabledReason: account.requirements?.disabled_reason ?? null,
requirementsCurrentlyDue: account.requirements?.currently_due ?? [],
requirementsErrors: account.requirements?.errors ?? [],
verificationStatus: account.payouts_enabled ? "verified" : "pending",
},
});
if (isActionRequired(account.requirements ?? null)) {
await notifyUserOfVerificationRequired(account.id);
}
}Step 3 Generate Fresh AccountLinks on Demand
Never cache AccountLink URLs. Generate a new one each time the user clicks the verification button. Include refresh_url to handle expired links gracefully.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
export async function createOnboardingLink(
stripeAccountId: string,
userId: string
): Promise<string> {
const accountLink = await stripe.accountLinks.create({
account: stripeAccountId,
refresh_url: `${process.env.APP_URL}/onboarding/refresh?userId=${userId}`,
return_url: `${process.env.APP_URL}/onboarding/complete?userId=${userId}`,
type: "account_onboarding",
});
// Log for debugging AccountLinks expire quickly
console.log(
`[Connect] AccountLink created for ${stripeAccountId}, expires: ${new Date(accountLink.expires_at * 1000).toISOString()}`
);
return accountLink.url;
}
// Handle the refresh_url route user's session expired during onboarding
export async function handleOnboardingRefresh(userId: string): Promise<string> {
const user = await db.users.findUnique({ where: { id: userId } });
if (!user?.stripeAccountId) throw new Error("No connected account found");
// Generate a fresh link do NOT reuse the expired one
return createOnboardingLink(user.stripeAccountId, userId);
}Step 4 Parse Verification Error Codes
Stripe returns structured error codes in requirements.errors[].code. Map these to user-friendly messages rather than showing the raw Stripe error string.
const VERIFICATION_ERROR_MESSAGES: Record<string, string> = {
verification_document_id_number_mismatch:
"The ID number on your document does not match our records. Please upload a document where the number is clearly visible.",
verification_document_name_mismatch:
"The name on your document does not match the name you provided. Ensure both match exactly.",
verification_document_dob_mismatch:
"Your date of birth does not match the document. Check that the correct document was uploaded.",
verification_document_expired:
"Your identity document has expired. Please upload a current, valid document.",
verification_document_not_readable:
"Your document was not readable. Please upload a clear, high-resolution photo with all four corners visible.",
verification_document_address_mismatch:
"The address on your document does not match the address you provided.",
verification_failed_other:
"Verification could not be completed. Please contact support for assistance.",
};
export function getVerificationErrorMessage(code: string): string {
return (
VERIFICATION_ERROR_MESSAGES[code] ??
"An unexpected verification issue occurred. Please try again or contact support."
);
}Step 5 Pre-fill Account Data to Reduce Drop-off
If you already collected user information during your own registration flow, pass it to Stripe when creating the connected account. This reduces the friction of the Stripe onboarding form and lowers the likelihood of data mismatches causing verification failures.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
export async function createConnectedAccount(user: {
email: string;
firstName: string;
lastName: string;
country: string;
}): Promise<string> {
const account = await stripe.accounts.create({
type: "express",
country: user.country,
email: user.email,
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
business_type: "individual",
individual: {
email: user.email,
first_name: user.firstName,
last_name: user.lastName,
},
settings: {
payouts: {
schedule: {
interval: "weekly",
weekly_anchor: "friday",
},
},
},
});
return account.id;
}Production Failure Scenarios
Scenario 1 User Completes Onboarding but Account Stays Restricted
User clicks through the Stripe Express onboarding flow and lands on your return_url. The platform marks them as 'verified' based on the redirect. However, Stripe may require additional verification documents after initial submission especially for higher-risk categories. The platform must rely on account.updated events, not the return_url redirect, to determine actual verification status.
Scenario 2 Verification Works in Test Mode, Fails in Live Mode
Stripe test mode accepts any document and skips real KYC checks. When you go live, actual identity verification runs and finds mismatches that did not exist in test data. Always test the live onboarding flow before launch using a real identity document in a live-mode test account.
Scenario 3 Country-Specific Requirements Not Handled
Stripe requirements vary significantly by country. A US account requires SSN. A UK account requires a National Insurance Number or passport. An Indian account requires a PAN card. If your onboarding UI assumes a single country's requirements, international users will hit verification failures with no actionable guidance.
Prevention Checklist
- Always subscribe to account.updated events for all connected accounts
- Never cache AccountLink URLs generate a fresh one per user session
- Always implement the refresh_url endpoint to recover from expired onboarding sessions
- Read requirements.currently_due not charges_enabled to determine if action is needed
- Map Stripe error codes to human-readable messages before displaying to users
- Pre-fill connected account data with information you already collected
- Test onboarding in live mode before launch using Stripe's test identity documents
- Set up Dashboard alerts for accounts entering 'Restricted' state
- Store requirements.errors in your database to enable support team troubleshooting
- Implement proactive email notifications when account enters restricted_soon state
Resolution Summary
Fix Connect verification issues by (1) always listening to account.updated and updating your internal account state, (2) generating fresh AccountLinks on every user request instead of caching them, (3) reading requirements.currently_due and requirements.errors to display actionable guidance, and (4) pre-filling as much account data as possible during creation to reduce verification failure rates.
Lessons Learned
- The return_url redirect is not a reliable signal of verification success always use account.updated webhooks
- AccountLinks are single-use and expire in 5 minutes; build refresh flows from day one
- Stripe requirements differ by country, business type, and transaction volume thresholds
- Pre-filling account creation data reduces mismatch errors and improves onboarding completion rates
- requirements.errors gives you specific, actionable error codes always surface these to users and support teams
Conclusion
Stripe Connect verification is not a one-time event it is an ongoing relationship between your platform and each connected account. Build your systems to respond dynamically to account.updated events, generate AccountLinks on demand, and translate Stripe's error codes into language your users can act on. The platforms that do this well have measurably better seller onboarding completion rates.