All Features / Payment Processing Flow
3-5 hours
intermediate

Payment Processing Flow

Stripe checkout + payment intents + receipt generation

paymentssaase-commerce

Paste this into Claude Code to start implementing

šŸ”§Works With

This spec is compatible with:

The implementation prompt includes guidance for these tech stacks.

Overview

Production-grade payment processing with Stripe Payment Intents, 3D Secure authentication, webhook verification, and automated receipt generation. Handles both one-time payments and checkout flows with full error handling.

Use Cases

  • E-commerce checkouts: Accept payments for physical/digital products
  • SaaS one-time fees: Setup fees, credits, add-ons
  • Service bookings: Coaching sessions, consultations, workshops
  • Digital downloads: Courses, templates, assets

When to Use This Pattern

Use this pattern when you need to:

  • Accept one-time payments (not subscriptions - see stripe-subscriptions spec)
  • Support multiple payment methods (cards, Apple Pay, Google Pay)
  • Handle Strong Customer Authentication (SCA) / 3D Secure
  • Generate receipts and confirmations automatically
  • Track payment status in your database

Pro Tips

Before you start implementing, read these carefully:

  1. Use Payment Intents, not Charges API - Charges API is legacy, Payment Intents handle SCA automatically
  2. Never trust client-side payment status - Always verify via webhook before fulfilling orders
  3. Test with Stripe test cards - Use 4242424242424242 for success, 4000000000009995 for declined
  4. Set up webhook signature verification - Prevents spoofed webhook attacks
  5. Store Stripe customer ID - Enables better fraud detection and customer support

Implementation Phases

Phase 1: Stripe Setup

Configure Stripe account and API keys:

  • Create Stripe account (or use existing)
  • Get API keys from Dashboard → Developers → API keys
  • Store in environment variables (never commit to git)
  • Install Stripe SDK: npm install stripe @stripe/stripe-js

Environment variables needed:

STRIPE_SECRET_KEY=sk_test_... # Server-side only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... # Client-side safe
STRIPE_WEBHOOK_SECRET=whsec_... # From webhook endpoint setup

Phase 2: Database Schema

Track payments in your database:

model Payment {
  id                String   @id @default(uuid())
  userId            String?
  amount            Int      // Amount in cents
  currency          String   @default("usd")
  status            String   // pending, succeeded, failed, refunded
  stripePaymentId   String   @unique
  stripeCustomerId  String?
  description       String?
  receiptUrl        String?
  metadata          Json?
  createdAt         DateTime @default(now())
  updatedAt         DateTime @updatedAt

  user User? @relation(fields: [userId], references: [id])
}

Why track in database:

  • Audit trail for accounting
  • Enable refund workflows
  • Support customer service lookups
  • Generate reports and analytics

Phase 3: Create Payment Intent

Server-side API route (app/api/create-payment-intent/route.ts):

import Stripe from 'stripe'
import { NextResponse } from 'next/server'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-11-20.acacia',
})

export async function POST(request: Request) {
  try {
    const { amount, currency = 'usd', metadata = {} } = await request.json()

    // Validate amount
    if (!amount || amount < 50) { // $0.50 minimum
      return NextResponse.json(
        { error: 'Invalid amount' },
        { status: 400 }
      )
    }

    // Create Payment Intent
    const paymentIntent = await stripe.paymentIntents.create({
      amount,
      currency,
      automatic_payment_methods: {
        enabled: true, // Enables cards, Apple Pay, Google Pay
      },
      metadata, // Store custom data (userId, productId, etc.)
    })

    // Save to database
    await prisma.payment.create({
      data: {
        amount,
        currency,
        status: 'pending',
        stripePaymentId: paymentIntent.id,
        metadata,
      },
    })

    return NextResponse.json({
      clientSecret: paymentIntent.client_secret,
    })
  } catch (error) {
    console.error('Payment Intent creation failed:', error)
    return NextResponse.json(
      { error: 'Payment failed' },
      { status: 500 }
    )
  }
}

Phase 4: Client-Side Payment Form

React component with Stripe Elements:

'use client'

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

const stripePromise = loadStripe(
  process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
)

function CheckoutForm({ clientSecret }: { clientSecret: string }) {
  const stripe = useStripe()
  const elements = useElements()
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

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

    if (!stripe || !elements) return

    setLoading(true)
    setError(null)

    const { error: submitError } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/payment/success`,
      },
    })

    if (submitError) {
      setError(submitError.message || 'Payment failed')
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />

      {error && (
        <div className="text-red-600 text-sm mt-2">{error}</div>
      )}

      <button
        type="submit"
        disabled={!stripe || loading}
        className="mt-4 w-full bg-blue-600 text-white py-2 rounded"
      >
        {loading ? 'Processing...' : 'Pay Now'}
      </button>
    </form>
  )
}

export default function PaymentPage({ amount }: { amount: number }) {
  const [clientSecret, setClientSecret] = useState<string | null>(null)

  // Create Payment Intent on mount
  useEffect(() => {
    fetch('/api/create-payment-intent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ amount }),
    })
      .then(res => res.json())
      .then(data => setClientSecret(data.clientSecret))
  }, [amount])

  if (!clientSecret) return <div>Loading...</div>

  return (
    <Elements stripe={stripePromise} options={{ clientSecret }}>
      <CheckoutForm clientSecret={clientSecret} />
    </Elements>
  )
}

Phase 5: Webhook Handler

Verify payments server-side (app/api/webhooks/stripe/route.ts):

import Stripe from 'stripe'
import { NextResponse } from 'next/server'
import { headers } from 'next/headers'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-11-20.acacia',
})

export async function POST(request: Request) {
  const body = await request.text()
  const signature = headers().get('stripe-signature')!

  let event: Stripe.Event

  try {
    // Verify webhook signature
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    console.error('Webhook signature verification failed:', err)
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  // Handle payment success
  if (event.type === 'payment_intent.succeeded') {
    const paymentIntent = event.data.object as Stripe.PaymentIntent

    // Update database
    await prisma.payment.update({
      where: { stripePaymentId: paymentIntent.id },
      data: {
        status: 'succeeded',
        stripeCustomerId: paymentIntent.customer as string,
        receiptUrl: paymentIntent.charges.data[0]?.receipt_url,
      },
    })

    // Send receipt email
    await sendReceiptEmail(paymentIntent)

    // Fulfill order (grant access, send download link, etc.)
    await fulfillOrder(paymentIntent)
  }

  // Handle payment failure
  if (event.type === 'payment_intent.payment_failed') {
    const paymentIntent = event.data.object as Stripe.PaymentIntent

    await prisma.payment.update({
      where: { stripePaymentId: paymentIntent.id },
      data: { status: 'failed' },
    })

    // Notify user of failure
    await sendPaymentFailureEmail(paymentIntent)
  }

  return NextResponse.json({ received: true })
}

Edge Cases to Handle

Critical Edge Cases

Incomplete payments (user abandons 3D Secure):

  • Payment Intent stuck in requires_action status
  • Set up webhook for payment_intent.canceled
  • Clean up abandoned payments after 24 hours
  • Consider reminder emails for incomplete checkouts

Network failures during confirmation:

  • User clicks "Pay" but network drops before confirmation
  • Payment may succeed on Stripe but user never sees success page
  • Solution: Check payment status on return_url page
  • Don't rely solely on client-side redirect

Duplicate payment attempts:

  • User clicks "Pay" multiple times (button not disabled)
  • Creates multiple Payment Intents for same order
  • Solution: Use idempotency keys in Payment Intent creation
  • Disable submit button after first click

Currency mismatch:

  • User's card is USD but you charge in EUR
  • Stripe handles conversion but adds fees
  • Solution: Detect user location, offer local currency
  • Show converted amount before payment

Refunds and disputes:

  • Customer requests refund after successful payment
  • Create refund endpoint: stripe.refunds.create()
  • Update database status to 'refunded'
  • Handle partial refunds (restocking fees, etc.)

Webhook delivery failures:

  • Stripe sends webhook but your server is down
  • Stripe retries for 3 days with exponential backoff
  • Solution: Implement webhook event log table
  • Manually replay failed events from Stripe dashboard

Test mode vs Live mode confusion:

  • Accidentally using test API keys in production
  • Solution: Environment-based key selection
  • Add admin panel showing current Stripe mode
  • Test webhook endpoints in both modes

Tech Stack Recommendations

Minimum Viable Stack

  • Framework: Next.js with API routes
  • Payment Provider: Stripe (easiest, most flexible)
  • Database: Any (PostgreSQL, MySQL, Supabase)
  • Email: Resend or SendGrid for receipts

Production-Grade Stack

  • Framework: Next.js 15 with App Router
  • Payment Processing: Stripe Payment Intents API
  • Database: Prisma + PostgreSQL
  • Email: Resend for transactional emails
  • Queue: Inngest or Trigger.dev for async fulfillment
  • Monitoring: Sentry for error tracking

Architecture Diagram

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│   Browser   │
│   (Client)  │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
       │
       ā”œā”€ā”€ā”€ Step 1: Request Payment Intent
       │    POST /api/create-payment-intent
       │    { amount: 5000, currency: 'usd' }
       │
       ↓
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  Your Server     │
│  (Next.js API)   │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
         │
         ā”œā”€ā”€ā”€ Step 2: Create Payment Intent
         │    stripe.paymentIntents.create()
         │
         ↓
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│   Stripe API     │
│  (Returns secret)│
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
         │
         ā”œā”€ā”€ā”€ Step 3: Return client secret
         ↓
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│   Browser   │
│ Stripe.js   │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
       │
       ā”œā”€ā”€ā”€ Step 4: User enters payment details
       │    (Card number, expiry, CVC)
       │
       ā”œā”€ā”€ā”€ Step 5: stripe.confirmPayment()
       │    → 3D Secure challenge if required
       │
       ↓
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│   Stripe         │
│ (Processes)      │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
         │
         ā”œā”€ā”€ā”€ Step 6: Webhook Event
         │    payment_intent.succeeded
         │    POST /api/webhooks/stripe
         │
         ↓
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  Your Server     │
│ (Fulfillment)    │
│                  │
│ - Update DB      │
│ - Send receipt   │
│ - Grant access   │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

Full Implementation Prompt

Copy this prompt to use with Claude Code:


I need to implement a production-grade payment processing flow with Stripe. Before we start coding, help me review my setup.

First, let me understand my current state:

  1. Do I have a Stripe account? If so, show me where to find my API keys.
  2. What does my User/Order data model look like? (Show me schema)
  3. How am I handling emails currently? (For receipts)
  4. What's my hosting setup? (For webhook URL configuration)

Then let's discuss these critical decisions:

  • Should I create Payment Intent on page load or on form submit? (UX vs security trade-off)
  • How should I handle failed payments? (Retry? Email user? How many attempts?)
  • What metadata should I store in Payment Intent? (userId, productId, etc.)
  • Should I use Stripe Checkout (hosted) or Payment Element (embedded)? (Faster vs customizable)
  • How do I want to handle refunds? (Manual via Stripe dashboard or programmatic?)

Then we'll implement in phases: Phase 1: Stripe account setup + environment variables Phase 2: Database schema for tracking payments Phase 3: Create Payment Intent API endpoint Phase 4: Client-side payment form with Stripe Elements Phase 5: Webhook handler for payment confirmation Phase 6: Receipt email template and sending logic

After implementation, let's test:

  • Successful payment (4242 4242 4242 4242)
  • Declined payment (4000 0000 0000 9995)
  • Requires 3D Secure (4000 0025 0000 3155)
  • Webhook delivery and signature verification
  • Refund workflow

Sound good? Let's start by reviewing your current setup.


Related Feature Specs

Success Criteria

You've successfully implemented this when:

āœ… Users can complete payments with cards, Apple Pay, Google Pay āœ… 3D Secure authentication works automatically when required āœ… Payments are tracked in your database with correct status āœ… Webhook signature verification is working āœ… Receipt emails are sent automatically on success āœ… Failed payments are handled gracefully with clear error messages āœ… Refunds can be processed (manually or programmatically) āœ… All edge cases have error handling

Common Mistakes to Avoid

āŒ Using legacy Charges API instead of Payment Intents āŒ Not verifying webhook signatures (security risk) āŒ Fulfilling orders before webhook confirmation āŒ Storing API keys in client-side code āŒ Not handling 3D Secure redirects properly āŒ Missing error handling for declined cards āŒ Not testing with Stripe test cards āŒ Forgetting to set up webhook endpoint in Stripe dashboard

Implementation Checklist

Setup:

  • Create Stripe account (or use existing)
  • Get API keys (secret + publishable)
  • Store keys in environment variables
  • Install Stripe SDK (npm install stripe @stripe/stripe-js)
  • Install Stripe React (npm install @stripe/react-stripe-js)

Database:

  • Create Payment model with required fields
  • Add indexes on stripePaymentId, userId
  • Test database connection

Backend:

  • Create Payment Intent API endpoint
  • Add validation (amount, currency)
  • Store Payment Intent in database
  • Handle errors and return proper status codes

Frontend:

  • Load Stripe.js with publishable key
  • Create checkout form component
  • Add Stripe Elements (PaymentElement)
  • Handle form submission with confirmPayment
  • Add loading states and error handling
  • Create success/failure pages

Webhooks:

  • Create webhook endpoint
  • Verify webhook signatures
  • Handle payment_intent.succeeded event
  • Handle payment_intent.payment_failed event
  • Update database on webhook events
  • Add webhook URL to Stripe dashboard
  • Test webhook with Stripe CLI: stripe listen --forward-to localhost:3000/api/webhooks/stripe

Email:

  • Create receipt email template
  • Send email on payment success
  • Include payment details, receipt URL
  • Test email delivery

Testing:

  • Test successful payment (4242 4242 4242 4242)
  • Test declined payment (4000 0000 0000 9995)
  • Test 3D Secure required (4000 0025 0000 3155)
  • Test webhook delivery
  • Test refund flow
  • Test error handling (network failures, invalid amounts)

Deployment:

  • Set production environment variables
  • Switch to live Stripe API keys
  • Update webhook endpoint in Stripe dashboard
  • Monitor first production transactions
  • Set up error tracking (Sentry)

Last Updated: 2025-12-04 Difficulty: Intermediate Estimated Time: 3-5 hours Prerequisites: Next.js API routes, database basics, async/await patterns

Built with this spec? Share your implementation and we'll feature it in our showcase!

Need Implementation Help?

Get expert guidance on architecture, security, and best practices.

Book a Consultation
Payment Processing Flow | Claude Code Implementation Guide | HashBuilds