Payments Integration Checklist: Stripe, Adyen, Checkout.com and Failure Modes
Payment integration looks simple until money goes missing. Learn to integrate Stripe, Adyen, or Checkout.com correctly with proper error handling and reconciliation.
Payment integration requires more than calling an API. You need proper webhook handling, idempotency keys, retry logic, and daily reconciliation. Treat webhooks as the source of truth, not API responses. Always verify payments server-side. Test failure modes as thoroughly as success paths.
Choosing a Payment Processor
The three major modern payment processors each have strengths. Choose based on your needs:Stripe- Best for: Most startups, great developer experience- Strengths: Documentation, APIs, broad feature set, fast onboarding- Weaknesses: Higher fees at scale, support can be slow- Pricing: 2.9% + $0.30 per transaction (US cards)- Onboarding: Minutes to hoursAdyen- Best for: Enterprise, global businesses, high volume- Strengths: Global coverage, unified platform, competitive rates at scale- Weaknesses: Complex integration, slower onboarding, enterprise focus- Pricing: Interchange++ (negotiated)- Onboarding: Days to weeksCheckout.com- Best for: EU-focused, high-growth companies- Strengths: Competitive rates, good global coverage, modern APIs- Weaknesses: Smaller ecosystem than Stripe- Pricing: ~2.5% + $0.25 (varies by region)- Onboarding: DaysDecision Framework:| Factor | Stripe | Adyen | Checkout.com ||--------|--------|-------|--------------|| Developer Experience | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ || Documentation | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ || Pricing (startup) | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ || Pricing (enterprise) | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ || Global Coverage | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ || Onboarding Speed | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |Our recommendation: Start with Stripe. It's the fastest to integrate and has the best documentation. Evaluate Adyen or Checkout.com when you hit $10M+ in annual transaction volume and want to negotiate rates.
The Payment Flow: Client to Server
A correct payment integration has multiple verification points. Here's the complete flow:Step 1: Create Payment Intent (Server)
1 // Server-side: Create payment intent
2 app.post('/api/create-payment-intent', async (req, res) => {
3 const { amount, currency, customerId, orderId } = req.body;
4
5 // Validate the order exists and amount matches
6 const order = await db.order.findUnique({ where: { id: orderId } });
7 if (!order || order.amount !== amount) {
8 return res.status(400).json({ error: 'Invalid order' });
9 }
10
11 // Create payment intent with idempotency key
12 const paymentIntent = await stripe.paymentIntents.create({
13 amount,
14 currency,
15 customer: customerId,
16 metadata: { orderId },
17 }, {
18 idempotencyKey: `order_${orderId}`, // Prevents duplicate charges
19 });
20
21 // Store payment intent ID with order
22 await db.order.update({
23 where: { id: orderId },
24 data: { paymentIntentId: paymentIntent.id, status: 'pending_payment' }
25 });
26
27 res.json({ clientSecret: paymentIntent.client_secret });
28 });1 // Client-side: Collect payment with Stripe Elements
2 import { useStripe, useElements, CardElement } from '@stripe/react-stripe-js';
3
4 function CheckoutForm({ orderId, amount }) {
5 const stripe = useStripe();
6 const elements = useElements();
7 const [error, setError] = useState(null);
8 const [processing, setProcessing] = useState(false);
9
10 const handleSubmit = async (e) => {
11 e.preventDefault();
12 setProcessing(true);
13
14 // Get client secret from server
15 const { clientSecret } = await fetch('/api/create-payment-intent', {
16 method: 'POST',
17 body: JSON.stringify({ orderId, amount, currency: 'usd' }),
18 }).then(r => r.json());
19
20 // Confirm payment
21 const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {
22 payment_method: { card: elements.getElement(CardElement) }
23 });
24
25 if (error) {
26 setError(error.message);
27 setProcessing(false);
28 } else if (paymentIntent.status === 'succeeded') {
29 // DON'T fulfill the order here! Wait for webhook.
30 // Just show success state to user
31 window.location.href = '/order/confirmation?pending=true';
32 }
33 };
34
35 return (
36 <form onSubmit={handleSubmit}>
37 <CardElement />
38 <button disabled={processing}>Pay</button>
39 {error && <div className="error">{error}</div>}
40 </form>
41 );
42 }1 // This is where you actually fulfill the order
2 app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
3 const sig = req.headers['stripe-signature'];
4
5 let event;
6 try {
7 event = stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
8 } catch (err) {
9 return res.status(400).send(`Webhook Error: ${err.message}`);
10 }
11
12 if (event.type === 'payment_intent.succeeded') {
13 const paymentIntent = event.data.object;
14 const orderId = paymentIntent.metadata.orderId;
15
16 // Idempotent fulfillment - check if already processed
17 const order = await db.order.findUnique({ where: { id: orderId } });
18 if (order.status === 'paid') {
19 return res.json({ received: true }); // Already processed
20 }
21
22 // Fulfill the order
23 await db.order.update({
24 where: { id: orderId },
25 data: { status: 'paid', paidAt: new Date() }
26 });
27
28 await fulfillOrder(order);
29 await sendConfirmationEmail(order);
30 }
31
32 res.json({ received: true });
33 });Why this order matters:1. Server creates the payment intent—you control the amount2. Client collects card details—they never touch your server3. Webhook confirms payment—the source of truth4. You fulfill after webhook—not after client confirmation
Webhook Implementation
Webhooks are the most critical (and most often broken) part of payment integration.Why webhooks matter:- API responses can be lost due to network issues- Client-side confirmation can be spoofed- Payments can succeed after initial timeout- Async events (disputes, refunds) only come via webhooksSetting up webhooks correctly:
1 import { buffer } from 'micro';
2
3 // 1. Use raw body for signature verification
4 export const config = { api: { bodyParser: false } };
5
6 export default async function handler(req, res) {
7 const rawBody = await buffer(req);
8 const sig = req.headers['stripe-signature'];
9
10 // 2. Always verify signature
11 let event;
12 try {
13 event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET);
14 } catch (err) {
15 console.error('Webhook signature verification failed:', err.message);
16 return res.status(400).json({ error: 'Invalid signature' });
17 }
18
19 // 3. Handle events idempotently
20 const eventId = event.id;
21 const alreadyProcessed = await db.processedWebhook.findUnique({
22 where: { eventId }
23 });
24
25 if (alreadyProcessed) {
26 return res.json({ received: true });
27 }
28
29 // 4. Process the event
30 try {
31 await processWebhookEvent(event);
32
33 // 5. Mark as processed AFTER successful handling
34 await db.processedWebhook.create({ data: { eventId, processedAt: new Date() } });
35 } catch (err) {
36 console.error('Webhook processing failed:', err);
37 // Return 500 so Stripe retries
38 return res.status(500).json({ error: 'Processing failed' });
39 }
40
41 res.json({ received: true });
42 }Handling retries:Stripe retries webhooks for up to 3 days. Your handler must be idempotent:
1 async function handlePaymentSuccess(paymentIntent) {
2 const orderId = paymentIntent.metadata.orderId;
3
4 // Transaction ensures atomicity
5 await db.$transaction(async (tx) => {
6 const order = await tx.order.findUnique({ where: { id: orderId } });
7
8 // Idempotent: skip if already paid
9 if (order.status === 'paid') {
10 return;
11 }
12
13 await tx.order.update({
14 where: { id: orderId },
15 data: { status: 'paid', paidAt: new Date() }
16 });
17
18 await tx.fulfillment.create({
19 data: { orderId, status: 'pending' }
20 });
21 });
22 }Events to handle:| Event | What to Do ||-------|------------|| `payment_intent.succeeded` | Fulfill order || `payment_intent.payment_failed` | Notify customer, update status || `charge.refunded` | Update order, adjust inventory || `charge.dispute.created` | Alert team, freeze order || `customer.subscription.updated` | Update subscription status || `invoice.payment_failed` | Retry logic, notify customer |
Idempotency Keys
Idempotency keys prevent double charges when requests are retried. Always use them.What happens without idempotency keys:1. Customer clicks "Pay"2. Request times out (but payment succeeded)3. Customer clicks "Pay" again4. Second charge created → Customer charged twiceImplementing idempotency:
1 // Generate idempotency key from order ID
2 function getIdempotencyKey(orderId: string, attempt: number = 0): string {
3 // Include attempt for explicit retries
4 return `order_${orderId}_attempt_${attempt}`;
5 }
6
7 // Create payment with idempotency
8 async function createPayment(orderId: string) {
9 const order = await db.order.findUnique({ where: { id: orderId } });
10
11 const paymentIntent = await stripe.paymentIntents.create({
12 amount: order.amount,
13 currency: 'usd',
14 metadata: { orderId },
15 }, {
16 idempotencyKey: getIdempotencyKey(orderId),
17 });
18
19 return paymentIntent;
20 }Retry logic with idempotency:
1 async function createPaymentWithRetry(orderId: string, maxRetries = 3) {
2 let lastError;
3
4 for (let attempt = 0; attempt < maxRetries; attempt++) {
5 try {
6 const paymentIntent = await stripe.paymentIntents.create({
7 amount: order.amount,
8 currency: 'usd',
9 metadata: { orderId },
10 }, {
11 idempotencyKey: getIdempotencyKey(orderId, attempt),
12 });
13
14 return paymentIntent;
15 } catch (error) {
16 lastError = error;
17
18 // Don't retry on certain errors
19 if (error.type === 'card_error') {
20 throw error; // Card declined, don't retry
21 }
22
23 // Exponential backoff
24 await sleep(Math.pow(2, attempt) * 1000);
25 }
26 }
27
28 throw lastError;
29 }Idempotency key rules:1. Key must be unique per intended operation2. Same key + same parameters = same result (no new charge)3. Same key + different parameters = error4. Keys expire after 24 hours (Stripe)5. Store keys if you need to retry later
Handling Failed Payments
Payments fail for many reasons. Handle each case appropriately.Common failure types:| Failure Type | Decline Code | Action ||--------------|--------------|--------|| Insufficient funds | `insufficient_funds` | Ask to try different card || Card declined | `card_declined` | Ask to try different card || Expired card | `expired_card` | Ask to update card || Incorrect CVC | `incorrect_cvc` | Ask to re-enter || Processing error | `processing_error` | Retry automatically || Fraud block | `fraudulent` | Review manually |Implementing error handling:
1 async function handlePaymentError(error: Stripe.StripeError, orderId: string) {
2 const order = await db.order.findUnique({ where: { id: orderId } });
3
4 switch (error.code) {
5 case 'card_declined':
6 case 'insufficient_funds':
7 case 'expired_card':
8 // Customer can fix this - ask for new payment method
9 await db.order.update({
10 where: { id: orderId },
11 data: { status: 'payment_failed', failureReason: error.code }
12 });
13 await sendEmail(order.customerEmail, 'payment_failed', {
14 reason: getHumanReadableReason(error.code),
15 retryUrl: `/checkout/${orderId}/retry`
16 });
17 break;
18
19 case 'processing_error':
20 // Temporary issue - retry automatically
21 await scheduleRetry(orderId, { delay: '5m' });
22 break;
23
24 case 'fraudulent':
25 // Flag for manual review
26 await db.order.update({
27 where: { id: orderId },
28 data: { status: 'fraud_review' }
29 });
30 await alertFraudTeam(order);
31 break;
32
33 default:
34 // Unknown error - log and alert
35 console.error('Unknown payment error:', error);
36 await alertEngineering({ orderId, error });
37 }
38 }
39
40 function getHumanReadableReason(code: string): string {
41 const messages = {
42 card_declined: 'Your card was declined. Please try a different card.',
43 insufficient_funds: 'Your card has insufficient funds. Please try a different card.',
44 expired_card: 'Your card has expired. Please update your payment method.',
45 incorrect_cvc: 'The security code was incorrect. Please try again.',
46 };
47 return messages[code] || 'Payment failed. Please try again or use a different card.';
48 }Network failure handling:
1 async function chargeWithNetworkRetry(paymentIntentId: string) {
2 const maxRetries = 3;
3
4 for (let i = 0; i < maxRetries; i++) {
5 try {
6 return await stripe.paymentIntents.confirm(paymentIntentId);
7 } catch (error) {
8 // Only retry on network errors
9 if (error.type === 'StripeConnectionError') {
10 await sleep(1000 * (i + 1));
11 continue;
12 }
13 throw error;
14 }
15 }
16
17 // After retries, check status directly
18 const intent = await stripe.paymentIntents.retrieve(paymentIntentId);
19 if (intent.status === 'succeeded') {
20 return intent; // It actually worked!
21 }
22
23 throw new Error('Payment failed after retries');
24 }Reconciliation and Accounting
Reconciliation catches bugs before customers do. Run it daily.Daily reconciliation process:
1 async function dailyReconciliation(date: Date) {
2 const startOfDay = new Date(date.setHours(0, 0, 0, 0));
3 const endOfDay = new Date(date.setHours(23, 59, 59, 999));
4
5 // Get all payments from Stripe
6 const stripePayments = await stripe.paymentIntents.list({
7 created: {
8 gte: Math.floor(startOfDay.getTime() / 1000),
9 lte: Math.floor(endOfDay.getTime() / 1000),
10 },
11 limit: 100,
12 });
13
14 // Get all orders from our database
15 const ourOrders = await db.order.findMany({
16 where: {
17 paidAt: { gte: startOfDay, lte: endOfDay }
18 }
19 });
20
21 // Find discrepancies
22 const discrepancies = [];
23
24 for (const payment of stripePayments.data) {
25 if (payment.status !== 'succeeded') continue;
26
27 const order = ourOrders.find(o => o.paymentIntentId === payment.id);
28
29 if (!order) {
30 discrepancies.push({
31 type: 'missing_order',
32 paymentIntentId: payment.id,
33 amount: payment.amount,
34 });
35 } else if (order.amount !== payment.amount) {
36 discrepancies.push({
37 type: 'amount_mismatch',
38 paymentIntentId: payment.id,
39 stripeAmount: payment.amount,
40 orderAmount: order.amount,
41 });
42 }
43 }
44
45 // Check for orders without corresponding payments
46 for (const order of ourOrders) {
47 if (order.status === 'paid') {
48 const payment = stripePayments.data.find(p => p.id === order.paymentIntentId);
49 if (!payment || payment.status !== 'succeeded') {
50 discrepancies.push({
51 type: 'orphan_order',
52 orderId: order.id,
53 paymentIntentId: order.paymentIntentId,
54 });
55 }
56 }
57 }
58
59 if (discrepancies.length > 0) {
60 await alertFinanceTeam(discrepancies);
61 }
62
63 return {
64 date,
65 stripeTotal: stripePayments.data.reduce((sum, p) => sum + p.amount, 0),
66 orderTotal: ourOrders.reduce((sum, o) => sum + o.amount, 0),
67 discrepancies,
68 };
69 }1 // Webhook handler for disputes
2 async function handleDispute(dispute: Stripe.Dispute) {
3 const paymentIntent = await stripe.paymentIntents.retrieve(dispute.payment_intent);
4 const orderId = paymentIntent.metadata.orderId;
5
6 await db.order.update({
7 where: { id: orderId },
8 data: {
9 disputeId: dispute.id,
10 disputeStatus: dispute.status,
11 disputeAmount: dispute.amount,
12 }
13 });
14
15 // Alert appropriate team
16 await alertFinanceTeam({
17 type: 'chargeback',
18 orderId,
19 amount: dispute.amount,
20 reason: dispute.reason,
21 deadline: dispute.evidence_due_by,
22 });
23
24 // Pause fulfillment if applicable
25 await pauseFulfillment(orderId);
26 }Testing Strategy
Test failure modes as thoroughly as success paths.Test cards for different scenarios:| Scenario | Stripe Test Card | Expected Result ||----------|------------------|-----------------|| Success | 4242424242424242 | Payment succeeds || Decline | 4000000000000002 | Card declined || Insufficient funds | 4000000000009995 | Insufficient funds || Requires auth | 4000002500003155 | 3D Secure required || Fraud block | 4100000000000019 | Blocked as fraudulent || Network error | 4000000000000341 | Payment fails |End-to-end test suite:
1 describe('Payment Flow', () => {
2 it('successfully processes payment', async () => {
3 const order = await createTestOrder({ amount: 1000 });
4
5 const intent = await api.createPaymentIntent({
6 orderId: order.id,
7 amount: 1000,
8 });
9
10 // Simulate successful payment
11 await stripe.paymentIntents.confirm(intent.id, {
12 payment_method: 'pm_card_visa', // Test card
13 });
14
15 // Simulate webhook
16 await simulateWebhook('payment_intent.succeeded', intent);
17
18 // Verify order updated
19 const updatedOrder = await db.order.findUnique({ where: { id: order.id } });
20 expect(updatedOrder.status).toBe('paid');
21 });
22
23 it('handles declined cards gracefully', async () => {
24 const order = await createTestOrder({ amount: 1000 });
25
26 const intent = await api.createPaymentIntent({
27 orderId: order.id,
28 amount: 1000,
29 });
30
31 // Use decline test card
32 await expect(
33 stripe.paymentIntents.confirm(intent.id, {
34 payment_method: 'pm_card_chargeDeclined',
35 })
36 ).rejects.toThrow();
37
38 // Verify order status
39 const updatedOrder = await db.order.findUnique({ where: { id: order.id } });
40 expect(updatedOrder.status).toBe('payment_failed');
41 });
42
43 it('prevents double charges with idempotency', async () => {
44 const order = await createTestOrder({ amount: 1000 });
45
46 // Create payment intent twice with same idempotency key
47 const intent1 = await api.createPaymentIntent({
48 orderId: order.id,
49 amount: 1000,
50 });
51
52 const intent2 = await api.createPaymentIntent({
53 orderId: order.id,
54 amount: 1000,
55 });
56
57 // Should be the same intent
58 expect(intent1.id).toBe(intent2.id);
59 });
60 });1 // Test webhook signature verification
2 describe('Webhook Handler', () => {
3 it('rejects invalid signatures', async () => {
4 const response = await request(app)
5 .post('/webhooks/stripe')
6 .set('stripe-signature', 'invalid')
7 .send({ type: 'payment_intent.succeeded' });
8
9 expect(response.status).toBe(400);
10 });
11
12 it('processes valid webhooks idempotently', async () => {
13 const event = createTestWebhookEvent('payment_intent.succeeded');
14
15 // Process twice
16 await processWebhook(event);
17 await processWebhook(event);
18
19 // Order should only be updated once
20 const fulfillments = await db.fulfillment.findMany({
21 where: { orderId: event.data.object.metadata.orderId }
22 });
23 expect(fulfillments).toHaveLength(1);
24 });
25 });Security Checklist
Payment integrations are high-value targets. Secure them properly.PCI Compliance:- [ ] Never log full card numbers- [ ] Never store CVV (not even encrypted)- [ ] Use Stripe Elements / Adyen Web Components (SAQ A)- [ ] TLS 1.2+ on all endpoints- [ ] Card data never touches your serversWebhook Security:- [ ] Verify webhook signatures on every request- [ ] Use raw body for signature verification- [ ] Webhook endpoint not publicly discoverable- [ ] Rate limiting on webhook endpoint- [ ] Webhook secret rotated periodicallyAPI Key Management:- [ ] API keys not in source code- [ ] Use environment variables or secrets manager- [ ] Separate keys for test/production- [ ] Restricted API key permissions where possible- [ ] Keys rotated after employee departuresLogging (What NOT to log):
1 // ❌ NEVER log these
2 logger.info('Payment received', {
3 cardNumber: '4242424242424242', // NEVER
4 cvv: '123', // NEVER
5 fullToken: 'tok_...', // Avoid
6 });
7
8 // ✅ Log these instead
9 logger.info('Payment received', {
10 paymentIntentId: 'pi_...',
11 last4: '4242',
12 amount: 1000,
13 currency: 'usd',
14 status: 'succeeded',
15 });Fraud Prevention:- [ ] Velocity checks on payments per user- [ ] Geographic anomaly detection- [ ] Device fingerprinting- [ ] Stripe Radar or equivalent enabled- [ ] Manual review queue for flagged payments
Production Checklist
Before going live with payments:Integration:- [ ] Webhook endpoints configured and verified- [ ] Idempotency keys on all payment operations- [ ] Error handling for all failure modes- [ ] Retry logic with exponential backoff- [ ] Timeout handling (payments can take time)Testing:- [ ] All test card scenarios verified- [ ] Webhook replay tested- [ ] Network failure recovery tested- [ ] Load testing completed- [ ] Manual end-to-end test on productionMonitoring:- [ ] Payment success/failure metrics- [ ] Webhook delivery monitoring- [ ] Latency tracking- [ ] Alerting on failure rate spikes- [ ] Daily reconciliation scheduledCompliance:- [ ] PCI SAQ completed- [ ] Privacy policy updated- [ ] Terms of service updated- [ ] Refund policy documented- [ ] Dispute response process definedOperations:- [ ] Runbook for common issues- [ ] Escalation path for payment emergencies- [ ] Customer support trained- [ ] Finance team has dashboard access- [ ] Backup payment processor identifiedGo-live:- [ ] Switch from test to production API keys- [ ] Verify production webhook endpoints- [ ] Process small test transaction- [ ] Monitor first hour closely- [ ] Have rollback plan readyPayments are unforgiving—bugs mean real money lost. Take the time to get it right.
We help teams design and ship production-grade software in eLearning, fintech, and AI. Let's talk about your project.
Book a call