Skip to main content

Command Palette

Search for a command to run...

Payment Gateway: Stripe Integration

Published
9 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Why Legacy Payment Integration Approaches Fail

Payment integration patterns from even three years ago are insufficient for current requirements. The shift to SCA (Strong Customer Authentication) across Europe, the deprecation of legacy Stripe APIs, and the rise of embedded finance have fundamentally changed implementation requirements.

Older integrations typically stored card details directly, created synchronous payment flows that couldn't handle authentication challenges, and treated webhooks as optional. In 2025, these approaches create immediate problems:

Compliance violations: Direct card handling requires full PCI DSS Level 1 compliance, costing $50,000+ annually for small teams. Stripe's tokenization eliminates this burden, but only if implemented correctly.

Authentication failures: Payment regulations now mandate SCA for European customers. Integrations that don't handle requires_action status correctly see 20-30% transaction failure rates.

Race conditions: Synchronous payment confirmation creates race conditions in distributed systems. A customer might receive a success page while the payment actually failed, or vice versa.

Webhook vulnerabilities: Unverified webhooks allow attackers to forge payment confirmations, granting unauthorized access to paid features.

The modern approach treats payments as asynchronous, event-driven workflows with cryptographic verification at every step.

Production-Grade Stripe Integration Architecture

A robust stripe payment gateway integration uses Stripe Elements for PCI-compliant card collection, Payment Intents for SCA-ready processing, and cryptographically verified webhooks for state synchronization.

The architecture separates three concerns:

  1. Client-side tokenization: Card data never touches your servers
  2. Server-side intent creation: Your backend creates payment intents with metadata
  3. Webhook-driven fulfillment: Asynchronous events trigger order fulfillment

Here's a production-ready implementation using TypeScript with Next.js 15 and the Stripe SDK:

// app/api/create-payment-intent/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
  typescript: true,
});

export async function POST(req: NextRequest) {
  try {
    const { amount, currency, customerId, metadata } = await req.json();

    // Validate amount to prevent manipulation
    if (amount < 50 || amount > 99999999) {
      return NextResponse.json(
        { error: 'Invalid amount' },
        { status: 400 }
      );
    }

    const paymentIntent = await stripe.paymentIntents.create({
      amount,
      currency: currency || 'usd',
      customer: customerId,
      automatic_payment_methods: {
        enabled: true,
        allow_redirects: 'never', // Prevent redirect-based methods in embedded flows
      },
      metadata: {
        orderId: metadata.orderId,
        userId: metadata.userId,
        environment: process.env.NODE_ENV,
      },
      // Critical: Enable idempotency for distributed systems
      idempotencyKey: `${metadata.orderId}-${Date.now()}`,
    });

    return NextResponse.json({
      clientSecret: paymentIntent.client_secret,
      paymentIntentId: paymentIntent.id,
    });
  } catch (error) {
    console.error('Payment intent creation failed:', error);
    return NextResponse.json(
      { error: 'Payment initialization failed' },
      { status: 500 }
    );
  }
}

The client-side implementation uses Stripe Elements with the Payment Element, which automatically handles multiple payment methods and authentication flows:

// components/CheckoutForm.tsx
'use client';

import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { useState, FormEvent } from 'react';

export default function CheckoutForm({ clientSecret }: { clientSecret: string }) {
  const stripe = useStripe();
  const elements = useElements();
  const [isProcessing, setIsProcessing] = useState(false);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();

    if (!stripe || !elements) {
      return;
    }

    setIsProcessing(true);
    setErrorMessage(null);

    const { error, paymentIntent } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/payment/complete`,
      },
      redirect: 'if_required', // Handle authentication inline when possible
    });

    if (error) {
      setErrorMessage(error.message || 'Payment failed');
      setIsProcessing(false);
    } else if (paymentIntent && paymentIntent.status === 'succeeded') {
      // Payment succeeded without redirect
      window.location.href = `/payment/success?payment_intent=${paymentIntent.id}`;
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement 
        options={{
          layout: 'tabs',
          wallets: {
            applePay: 'auto',
            googlePay: 'auto',
          },
        }}
      />
      {errorMessage && (
        <div className="error-message" role="alert">
          {errorMessage}
        </div>
      )}
      <button 
        type="submit" 
        disabled={!stripe || isProcessing}
        aria-busy={isProcessing}
      >
        {isProcessing ? 'Processing...' : 'Pay Now'}
      </button>
    </form>
  );
}

Webhook Implementation for Reliable Payment Confirmation

The critical component most stripe api typescript implementations get wrong is webhook handling. Webhooks provide the authoritative source of payment status, but only if properly verified and processed.

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { headers } from 'next/headers';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: NextRequest) {
  const body = await req.text();
  const headersList = headers();
  const signature = headersList.get('stripe-signature');

  if (!signature) {
    return NextResponse.json(
      { error: 'Missing signature' },
      { status: 400 }
    );
  }

  let event: Stripe.Event;

  try {
    // Cryptographic verification prevents webhook forgery
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 400 }
    );
  }

  // Implement idempotent event processing
  const eventId = event.id;
  const isProcessed = await checkEventProcessed(eventId);

  if (isProcessed) {
    return NextResponse.json({ received: true });
  }

  try {
    switch (event.type) {
      case 'payment_intent.succeeded': {
        const paymentIntent = event.data.object as Stripe.PaymentIntent;
        await handlePaymentSuccess(paymentIntent);
        break;
      }

      case 'payment_intent.payment_failed': {
        const paymentIntent = event.data.object as Stripe.PaymentIntent;
        await handlePaymentFailure(paymentIntent);
        break;
      }

      case 'charge.dispute.created': {
        const dispute = event.data.object as Stripe.Dispute;
        await handleDispute(dispute);
        break;
      }

      case 'customer.subscription.updated': {
        const subscription = event.data.object as Stripe.Subscription;
        await handleSubscriptionUpdate(subscription);
        break;
      }
    }

    await markEventProcessed(eventId);
    return NextResponse.json({ received: true });
  } catch (error) {
    console.error('Webhook processing error:', error);
    // Return 500 to trigger Stripe retry
    return NextResponse.json(
      { error: 'Processing failed' },
      { status: 500 }
    );
  }
}

async function handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent) {
  const { orderId, userId } = paymentIntent.metadata;

  // Use database transaction to ensure atomicity
  await db.transaction(async (tx) => {
    await tx.orders.update({
      where: { id: orderId },
      data: {
        status: 'paid',
        paymentIntentId: paymentIntent.id,
        paidAt: new Date(),
      },
    });

    await tx.users.update({
      where: { id: userId },
      data: {
        subscriptionStatus: 'active',
      },
    });

    // Trigger fulfillment workflow
    await triggerFulfillment(orderId);
  });
}

async function checkEventProcessed(eventId: string): Promise<boolean> {
  const existing = await db.processedWebhooks.findUnique({
    where: { eventId },
  });
  return !!existing;
}

async function markEventProcessed(eventId: string): Promise<void> {
  await db.processedWebhooks.create({
    data: {
      eventId,
      processedAt: new Date(),
    },
  });
}

Handling Subscription Payments and Recurring Billing

For SaaS applications, stripe subscription payments require additional considerations around billing cycles, proration, and failed payment recovery.

// app/api/subscriptions/create/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  try {
    const { customerId, priceId, trialDays } = await req.json();

    const subscription = await stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: priceId }],
      payment_behavior: 'default_incomplete',
      payment_settings: {
        save_default_payment_method: 'on_subscription',
        payment_method_types: ['card'],
      },
      expand: ['latest_invoice.payment_intent'],
      trial_period_days: trialDays || undefined,
      metadata: {
        environment: process.env.NODE_ENV,
      },
    });

    const invoice = subscription.latest_invoice as Stripe.Invoice;
    const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent;

    return NextResponse.json({
      subscriptionId: subscription.id,
      clientSecret: paymentIntent.client_secret,
    });
  } catch (error) {
    console.error('Subscription creation failed:', error);
    return NextResponse.json(
      { error: 'Subscription initialization failed' },
      { status: 500 }
    );
  }
}

Common Pitfalls and Edge Cases

Webhook replay attacks: Always verify webhook signatures. Attackers can replay captured webhook payloads to grant unauthorized access.

Double fulfillment: Stripe may send duplicate webhook events. Implement idempotency checks using event IDs to prevent duplicate order fulfillment.

Amount manipulation: Validate payment amounts server-side. Never trust client-provided amounts—attackers can modify JavaScript to change payment values.

Currency mismatches: Stripe amounts are in the currency's smallest unit (cents for USD, yen for JPY which has no subunit). Incorrect conversion causes 100x payment errors.

Incomplete authentication: When payment_intent.status is requires_action, the payment needs additional authentication. Handle this status explicitly or use redirect: 'if_required' in confirmPayment.

Webhook timeout failures: Webhook handlers must respond within 30 seconds. Offload heavy processing to background jobs and return 200 immediately after validation.

Test mode data leakage: Never use test API keys in production. Implement environment-specific key management and validate key prefixes (sk_live_ vs sk_test_).

Best Practices for Production Stripe Integration

Implement comprehensive logging: Log all payment intent IDs, webhook events, and error states. This audit trail is essential for debugging payment issues and resolving disputes.

Use Stripe's idempotency keys: For any state-changing operation, include an idempotency key to prevent duplicate charges during network retries.

Handle all payment statuses: Payment intents have multiple states beyond succeeded/failed. Handle requires_payment_method, requires_confirmation, requires_action, processing, and canceled.

Implement retry logic with exponential backoff: Stripe API calls can fail due to rate limits or network issues. Implement retries with exponential backoff and jitter.

Store minimal payment data: Never store full card numbers or CVV codes. Store only Stripe customer IDs, payment method IDs, and payment intent IDs.

Monitor webhook delivery: Set up alerts for webhook failures. Stripe retries failed webhooks for 3 days, but you should investigate failures immediately.

Test with Stripe's test cards: Use test cards that simulate specific scenarios: successful payments, declined cards, authentication required, insufficient funds.

Implement proper error messages: Show user-friendly error messages for common failures. Map Stripe error codes to actionable customer guidance.

Use Stripe Radar for fraud prevention: Enable Stripe Radar rules to automatically block high-risk payments. Configure custom rules based on your risk tolerance.

Implement payment reconciliation: Regularly reconcile your database payment records against Stripe's dashboard to catch discrepancies.

Frequently Asked Questions

What is the difference between Payment Intents and Charges in Stripe?

Payment Intents represent the modern Stripe API that handles the complete payment lifecycle, including authentication flows like 3D Secure. The older Charges API is deprecated and doesn't support SCA requirements. All new integrations should use Payment Intents exclusively.

How does stripe webhook implementation work in 2025?

Webhooks deliver real-time events about payment status changes. Stripe signs each webhook with a secret key, which you verify using stripe.webhooks.constructEvent(). Your endpoint must respond within 30 seconds with a 200 status code. Failed webhooks are retried automatically for 3 days with exponential backoff.

What is the best way to handle failed subscription payments?

Stripe automatically retries failed subscription payments using Smart Retries, which optimize retry timing based on historical success rates. Implement webhook handlers for invoice.payment_failed to notify customers and provide a payment update link. Use Stripe Billing's dunning features to send automated email reminders.

When should you avoid using Stripe Checkout?

Avoid Stripe Checkout when you need deep customization of the payment flow, want to keep users on your domain throughout checkout, or need to collect additional custom data during payment. Use Payment Element with Elements instead for embedded, customizable payment forms.

How to scale stripe integration for high-volume transactions?

Implement request queuing with Redis or SQS to handle traffic spikes, use Stripe's bulk API operations for batch processing, enable webhook event filtering to reduce processing load, implement database connection pooling, and use read replicas for payment history queries. Consider Stripe's Connect platform for marketplace scenarios with multiple sellers.

What are the pci compliance requirements when using Stripe?

Using Stripe Elements or Checkout makes you eligible for the simplest PCI compliance level (SAQ A), requiring only an annual self-assessment questionnaire. Never handle raw card data in your code—always use Stripe's tokenization. Ensure your webhook endpoints use HTTPS and verify webhook signatures.

How do you test stripe payment gateway integration before going live?

Use Stripe's test mode with test API keys and test card numbers. Test successful payments (4242 4242 4242 4242), declined cards (4000 0000 0000 0002), authentication required (4000 0025 0000 3155), and insufficient funds (4000 0000 0000 9995). Use Stripe CLI to trigger webhook events locally during development.

Conclusion

A production-ready stripe integration tutorial implementation requires more than basic API calls—it demands proper webhook verification, idempotent event processing, comprehensive error handling, and security-first architecture. The patterns shown here handle real-world scenarios: authentication challenges, webhook retries, subscription management, and fraud prevention.

Start by implementing the Payment Intent flow with proper client-side tokenization. Add webhook handling with signature verification and idempotency checks. Test thoroughly using Stripe's test cards and webhook event simulator. Monitor your integration in production using Stripe's dashboard and implement alerts for failed webhooks or unusual payment patterns.

Next steps include implementing subscription management for recurring revenue, adding support for alternative payment methods like ACH or SEPA, integrating Stripe Radar rules for fraud prevention, and building payment analytics dashboards. Consider exploring Stripe Connect for marketplace applications or Stripe Terminal for in-person payments as your payment infrastructure matures.