Environment Setup and API Configuration
Before implementing, you need approved accounts from both platforms. Both require manual underwriting processes that take 3-5 business days for production access. Start this early - regulatory compliance reviews add time, and you'll be stuck in sandbox hell until approval comes through.
Stripe Identity Configuration
Log into your Stripe Dashboard and complete the Identity application process:
- Business verification: Submit KYB documentation including certificates of incorporation
- Use case declaration: Specify your verification requirements and regulatory obligations
- Webhook endpoint setup: Configure production endpoints for verification completion events
- Restricted key creation: Generate API keys with identity-specific permissions
Plaid Identity Verification Setup
Navigate to the Plaid Dashboard and complete onboarding:
- Compliance review: Submit your risk assessment framework and KYC policies
- Integration configuration: Define verification templates for different risk levels
- Webhook configuration: Set up endpoints for verification state changes
- Data retention settings: Configure data lifecycle policies per jurisdiction
Store these credentials securely using encrypted environment variables:
## Production environment variables
STRIPE_IDENTITY_SECRET_KEY=sk_live_...
STRIPE_IDENTITY_WEBHOOK_SECRET=whsec_...
PLAID_CLIENT_ID=your_production_client_id
PLAID_SECRET=your_production_secret
PLAID_IDENTITY_WEBHOOK_SECRET=your_webhook_secret
PLAID_ENV=production
Dual-Layer Verification Workflow

The integration follows a sequential verification pattern optimized for both security and user experience:
Phase 1: Initial Risk Assessment (Plaid)
Start with Plaid's behavioral analytics to screen high-risk users before document collection:
const { PlaidApi, Configuration, PlaidEnvironments } = require('plaid');
// This will break if you forget the headers - no helpful error message
const plaidConfig = new Configuration({
basePath: PlaidEnvironments.production, // Use 'sandbox' for testing
baseOptions: {
headers: {
'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID,
'PLAID-SECRET': process.env.PLAID_SECRET,
}
}
});
const plaidClient = new PlaidApi(plaidConfig);
// Step 1: Create Plaid Identity Verification session
async function createPlaidVerification(userId, userMetadata) {
try {
const request = {
template_id: 'idvtmp_production_template', // Good luck finding this ID in their docs - it's buried in your dashboard somewhere
user: {
client_user_id: userId,
phone_number: userMetadata.phone, // Format better be E.164 or this breaks
email_address: userMetadata.email,
address: {
street: userMetadata.address.street,
city: userMetadata.address.city,
region: userMetadata.address.state, // 'region' not 'state' because why make it obvious
postal_code: userMetadata.address.zip,
country: userMetadata.address.country // ISO country codes or it shits the bed
}
},
webhook_url: 'https://your-api.com/webhooks/plaid-idv', // HTTPS required, fails silently on HTTP like an asshole
redirect_uri: 'https://your-app.com/verification/complete'
};
const response = await plaidClient.identityVerificationCreate(request);
return {
verificationId: response.data.id,
sharableUrl: response.data.sharable_url,
status: response.data.status
};
} catch (error) {
console.error('Plaid shit the bed again:', error);
// This error message tells you nothing useful - good luck debugging
throw new Error('Plaid verification borked');
}
}
Phase 2: Document Collection (Stripe Identity)
If Plaid's initial screening passes, proceed to document verification:
const stripe = require('stripe')(process.env.STRIPE_IDENTITY_SECRET_KEY);
// Step 2: Create Stripe Identity verification session
async function createStripeVerification(userId, plaidVerificationId) {
try {
const verificationSession = await stripe.identity.verificationSessions.create({
type: 'document',
metadata: {
user_id: userId,
plaid_verification_id: plaidVerificationId,
created_at: new Date().toISOString()
},
options: {
document: {
allowed_types: ['driving_license', 'passport', 'id_card'],
require_id_number: true,
require_live_capture: true,
require_matching_selfie: true
}
}
});
return {
verificationId: verificationSession.id,
clientSecret: verificationSession.client_secret,
url: verificationSession.url
};
} catch (error) {
console.error('Stripe verification exploded:', error);
// At least Stripe's errors are somewhat helpful, unlike Plaid's
throw new Error('Document verification setup failed');
}
}
Combine results from both platforms for final decision:
// Step 3: Comprehensive verification decision engine
async function processVerificationResults(plaidId, stripeId) {
try {
// Get Plaid verification results
const plaidResults = await plaidClient.identityVerificationGet({
identity_verification_id: plaidId
});
// Get Stripe verification results
const stripeResults = await stripe.identity.verificationSessions.retrieve(stripeId);
// Extract risk signals
const plaidRiskSignals = {
behaviorRisk: plaidResults.data.steps.verify_sms.status === 'success',
emailRisk: plaidResults.data.risk_summary.email_risk_score,
phoneRisk: plaidResults.data.risk_summary.phone_risk_score,
deviceRisk: plaidResults.data.risk_summary.device_risk_score,
syntheticIdentity: plaidResults.data.risk_summary.synthetic_identity_score
};
const stripeRiskSignals = {
documentAuthenticity: stripeResults.last_verification_report?.document?.status,
livenessCheck: stripeResults.last_verification_report?.selfie?.status,
idNumberMatch: stripeResults.last_verification_report?.id_number?.status,
addressVerification: stripeResults.last_verification_report?.address?.status
};
// Apply risk-based decision logic
const overallRisk = calculateRiskScore(plaidRiskSignals, stripeRiskSignals);
return {
approved: overallRisk.score < 70, // Configurable threshold
riskScore: overallRisk.score,
riskFactors: overallRisk.factors,
plaidVerificationId: plaidId,
stripeVerificationId: stripeId,
auditTrail: {
plaidResults: plaidResults.data,
stripeResults: stripeResults.last_verification_report,
decision_timestamp: new Date().toISOString()
}
};
} catch (error) {
console.error('Verification processing went to shit:', error);
// Debugging this is a nightmare - you'll spend hours figuring out which platform failed
throw new Error('Verification processing completely fucked');
}
}
// Custom risk scoring algorithm
function calculateRiskScore(plaidSignals, stripeSignals) {
let score = 0;
const factors = [];
// Document authenticity (high impact)
if (stripeSignals.documentAuthenticity !== 'verified') {
score += 50;
factors.push('Document authenticity failed');
}
// Liveness detection (high impact)
if (stripeSignals.livenessCheck !== 'verified') {
score += 40;
factors.push('Liveness detection failed');
}
// Synthetic identity risk (critical)
if (plaidSignals.syntheticIdentity > 0.7) {
score += 60;
factors.push('High synthetic identity risk');
}
// Behavioral anomalies (medium impact)
if (plaidSignals.emailRisk === 'HIGH' || plaidSignals.phoneRisk === 'HIGH') {
score += 25;
factors.push('Suspicious contact information');
}
// Device/network risk (medium impact)
if (plaidSignals.deviceRisk === 'HIGH') {
score += 20;
factors.push('High-risk device or network');
}
return { score, factors };
}
Production-Ready Error Handling & Webhooks
Webhook Management (Where This Gets Messy)
Both platforms require webhook endpoints for production deployment. Implement robust handlers for all verification states:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Stripe Identity webhook handler
app.post('/webhooks/stripe-identity', express.raw({type: 'application/json'}), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_IDENTITY_WEBHOOK_SECRET);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case 'identity.verification_session.verified':
await handleStripeVerificationComplete(event.data.object);
break;
case 'identity.verification_session.requires_input':
await handleStripeVerificationFailure(event.data.object);
break;
case 'identity.verification_session.processing':
await handleStripeVerificationProcessing(event.data.object);
break;
default:
console.log(`Unhandled Stripe event type: ${event.type}`);
}
res.json({received: true});
});
// Plaid Identity Verification webhook handler
app.post('/webhooks/plaid-idv', express.json(), async (req, res) => {
const { webhook_type, identity_verification_id, error } = req.body;
// Verify webhook signature
const expectedSignature = crypto
.createHmac('sha256', process.env.PLAID_IDENTITY_WEBHOOK_SECRET)
.update(JSON.stringify(req.body))
.digest('hex');
if (req.headers['plaid-signature'] !== expectedSignature) {
return res.status(401).send('Invalid signature');
}
switch (webhook_type) {
case 'IDENTITY_VERIFICATION_STATUS_UPDATED':
await handlePlaidVerificationUpdate(identity_verification_id);
break;
case 'IDENTITY_VERIFICATION_FAILED':
await handlePlaidVerificationFailure(identity_verification_id, error);
break;
default:
console.log(`Unhandled Plaid webhook type: ${webhook_type}`);
}
res.json({acknowledged: true});
});
// Process verification state changes
async function handleStripeVerificationComplete(verificationSession) {
const { id, metadata } = verificationSession;
const { user_id, plaid_verification_id } = metadata;
// Update user verification status in database
await updateUserVerificationStatus(user_id, 'stripe_verified', {
stripe_verification_id: id,
completed_at: new Date().toISOString()
});
// Check if both verifications are complete
const combinedResult = await checkCombinedVerificationStatus(user_id);
if (combinedResult.bothComplete) {
await finalizeUserVerification(user_id, combinedResult);
}
}
async function handlePlaidVerificationUpdate(verificationId) {
// Retrieve detailed verification results
const verification = await plaidClient.identityVerificationGet({
identity_verification_id: verificationId
});
if (verification.data.status === 'success') {
const userId = verification.data.user.client_user_id;
await updateUserVerificationStatus(userId, 'plaid_verified', {
plaid_verification_id: verificationId,
risk_summary: verification.data.risk_summary,
completed_at: new Date().toISOString()
});
// Check combined status
const combinedResult = await checkCombinedVerificationStatus(userId);
if (combinedResult.bothComplete) {
await finalizeUserVerification(userId, combinedResult);
}
}
}
What Actually Breaks (And How to Fix It)
Webhook timeouts will ruin your weekend: Both platforms timeout after 30 seconds and you're fucked if your processing takes longer. Process async and respond immediately or prepare for pain.
Rate limiting is aggressive: Plaid's rate limiting will throttle you hard if you hit them too much, especially in sandbox. The error messages won't tell you why you're being throttled - you'll just get 429s and have to implement exponential backoff.
Sandbox vs production is a lie: Plaid's sandbox has perfect users who never fail verification. Production has edge cases from hell that'll break your flow in ways you never tested.
Signature verification fails silently like an asshole: Plaid's webhook signatures are case-sensitive and will fail silently if you mess up the encoding. You'll spend hours debugging why webhooks aren't working.
Template IDs are hidden: These come from your Plaid dashboard configuration and aren't documented anywhere. You'll find them through trial, error, and rage.
Document quality will frustrate users: Stripe's document verification is picky as hell about image quality. Users with old phones or bad lighting will fail repeatedly and blame you.
Bank connections break randomly: Plaid's bank connections fail for smaller credit unions and regional banks. Have a fallback plan or users will be stuck.
This dual-layer approach catches fraud that single vendors miss while keeping most legitimate users happy. The integration is complex but beats getting slammed by synthetic identity fraud.