TL;DR: Stripe integration connects your app to Stripe's payment service via their API. Stripe handles the credit card processing so you never touch card numbers. You create a checkout session, Stripe collects the payment, and a webhook notifies your server when it succeeds. Keep your API keys in environment variables — never in your code.

Why AI Coders Need This

At some point, every real app needs to collect money. A SaaS subscription. A one-time purchase. An online course. A donation button. The moment your AI-built project moves from "cool demo" to "actual business," you need payment processing.

Here's the good news: you don't need to build payment processing yourself. You don't need to understand credit card networks, bank transfers, or fraud detection. You don't need a finance degree. You need Stripe.

Stripe is a payment processing company that provides an API (a set of endpoints your code can talk to) that handles everything related to accepting money online. Over 3.4 million websites use Stripe. When you ask any AI coding tool to add payments, it will almost always generate Stripe code — because Stripe has the best documentation, the most examples in training data, and the cleanest API design.

The challenge? Payment code is not like a to-do list or a blog page. Mistakes with payments can mean:

  • Charging customers but not delivering the product
  • Leaking API keys that let strangers charge against your Stripe account
  • Processing test payments in production (or worse — live payments during testing)
  • Missing webhook events, so subscriptions never activate

AI gets the structure of Stripe integration right nearly every time. But it sometimes skips the safety checks that matter most when real money is involved. This article teaches you what to look for.

What Stripe Actually Does

Think of Stripe as a middleman between your app and the banking system. Here's the flow in plain English:

  1. Your app creates a "checkout session" — this tells Stripe what you're selling, how much it costs, and where to send the customer afterward.
  2. Stripe shows the customer a payment page — this is hosted on Stripe's servers (stripe.com), not yours. The customer enters their credit card info on Stripe's page, so card numbers never touch your server.
  3. Stripe processes the payment — talks to credit card networks, checks for fraud, handles 3D Secure verification if needed.
  4. Stripe notifies your app — via a webhook (a server-to-server HTTP request), Stripe tells your app "payment succeeded" or "payment failed."
  5. Your app acts on the result — activates the subscription, unlocks the course, sends a confirmation email.

The critical thing to understand: your server never sees credit card numbers. Stripe handles all of that. This is called PCI compliance — the security standard for handling payment card data. By using Stripe Checkout (the hosted payment page), you offload nearly all PCI responsibility to Stripe. This is a massive deal. Building your own payment form that directly handles card numbers would require tens of thousands of dollars in security audits annually.

The Two Sides of Stripe

Stripe gives you two sets of API keys:

Publishable key (pk_test_ or pk_live_) — used in your frontend (browser) code. This is safe to expose publicly. It can only create checkout sessions and confirm payments — it can't read customer data or issue refunds.

⚠️ Secret key (sk_test_ or sk_live_) — used on your server only. This key can do everything: charge cards, issue refunds, read customer data, cancel subscriptions. If someone gets your secret key, they can charge customers, refund payments to themselves, or drain your account. Never put this in frontend code, never commit it to Git, never paste it in a chat.

Both keys come in test and live versions. Test keys (starting with _test_) process simulated payments — no real money moves. Live keys (starting with _live_) charge real credit cards. The code stays exactly the same; you just swap the keys. Store them as environment variables.

The Real Scenario: Asking AI to Add Stripe

Let's say you've built a SaaS app with Cursor and you're ready to charge for it. Here's the prompt you'd type:

Prompt You'd Give Your AI

Add Stripe subscription payments to my Next.js app.
I need a pricing page with two tiers: Basic ($9/mo) and Pro ($29/mo).
When a user subscribes, update their role in my database.
Use Stripe Checkout (the hosted payment page).
Store all keys in environment variables.

Here's what a good AI tool will generate — broken into pieces so you can understand what each part does.

Part 1: The API Route That Creates a Checkout Session

// app/api/checkout/route.ts
import Stripe from 'stripe';

// Load the secret key from environment variables — never hardcode this
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const { priceId, userId } = await request.json();

  // Create a Checkout Session — this tells Stripe what to charge for
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',           // 'subscription' for recurring, 'payment' for one-time
    payment_method_types: ['card'],  // accept credit/debit cards
    line_items: [
      {
        price: priceId,             // Stripe Price ID from your dashboard (e.g., price_1234abc)
        quantity: 1,
      },
    ],
    // Where to send the user after checkout:
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
    // Attach metadata so the webhook knows which user paid:
    metadata: { userId },
    // Pre-fill their email if you have it:
    customer_email: undefined,  // or pass the user's email
  });

  // Send the Stripe checkout URL back to the frontend
  return Response.json({ url: session.url });
}

This is a REST API endpoint. Your pricing page calls this route, gets back a Stripe URL, and redirects the user to it. The user then enters their payment info on Stripe's hosted page — your app never sees the card number.

Part 2: The Webhook Handler

This is the most important part. The success_url redirect is not reliable — the user could close their browser after paying. The webhook is the guaranteed notification from Stripe that money changed hands.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
import { db } from '@/lib/db';

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

// This is a DIFFERENT secret — get it from Stripe Dashboard → Webhooks
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: Request) {
  // IMPORTANT: Use .text(), not .json() — signature verification needs raw bytes
  const body = await request.text();
  const signature = headers().get('stripe-signature');

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

  let event: Stripe.Event;
  try {
    // Verify this request actually came from Stripe, not a random attacker
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    console.error('⚠️ Webhook signature verification failed:', err);
    return Response.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // Process the event based on its type
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      const userId = session.metadata?.userId;

      if (userId) {
        await db.user.update({
          where: { id: userId },
          data: {
            role: 'subscriber',
            stripeCustomerId: session.customer as string,
            stripeSubscriptionId: session.subscription as string,
          },
        });
      }
      break;
    }

    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription;
      await db.user.updateMany({
        where: { stripeCustomerId: subscription.customer as string },
        data: {
          role: subscription.status === 'active' ? 'subscriber' : 'free',
        },
      });
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      await db.user.updateMany({
        where: { stripeCustomerId: subscription.customer as string },
        data: { role: 'free' },
      });
      break;
    }

    default:
      // Unhandled event types are fine — just ignore them
      console.log(`Unhandled event: ${event.type}`);
  }

  // Always return 200 quickly — Stripe retries on failure
  return Response.json({ received: true });
}

If you've read our webhook guide, this pattern should look familiar. The key difference: with payment webhooks, the stakes are higher. A missed webhook means a paying customer doesn't get access. A faked webhook means someone gets free access.

Part 3: The Environment Variables

# .env.local (NEVER commit this file to Git)
STRIPE_SECRET_KEY=sk_test_51abc123...
STRIPE_PUBLISHABLE_KEY=pk_test_51abc123...
STRIPE_WEBHOOK_SECRET=whsec_abc123...
NEXT_PUBLIC_URL=http://localhost:3000

Three separate secrets. The secret key authenticates your server with Stripe. The webhook secret verifies incoming webhooks. The publishable key is used in the browser. Each serves a different purpose — don't mix them up. Learn more about why this matters in our environment variables guide.

Part 4: The Pricing Page (Frontend)

// app/pricing/page.tsx
'use client';

const plans = [
  { name: 'Basic', price: '$9/mo', priceId: 'price_basic123' },
  { name: 'Pro', price: '$29/mo', priceId: 'price_pro456' },
];

export default function PricingPage() {
  const handleSubscribe = async (priceId: string) => {
    const response = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ priceId, userId: 'current-user-id' }),
    });
    const { url } = await response.json();
    // Redirect to Stripe's hosted checkout page
    window.location.href = url;
  };

  return (
    <div>
      {plans.map((plan) => (
        <div key={plan.priceId}>
          <h3>{plan.name}</h3>
          <p>{plan.price}</p>
          <button onClick={() => handleSubscribe(plan.priceId)}>
            Subscribe
          </button>
        </div>
      ))}
    </div>
  );
}

Notice: no credit card form on this page. The button redirects to Stripe's hosted checkout. Card numbers never touch your code. That's the whole point.

What Can Go Wrong (and How to Avoid It)

Payment integration is one of the highest-stakes things your AI will generate. Here are the real pitfalls — ranked by how badly they can hurt you.

🔴 Critical: API Keys in Your Code

⚠️ This is the #1 most dangerous mistake. If your Stripe secret key ends up in a Git commit and gets pushed to GitHub, automated bots will find it within minutes and start making charges against your account.

AI tools sometimes hardcode API keys directly in the source code, especially when generating quick examples. Watch for this pattern:

// ❌ NEVER DO THIS — secret key hardcoded in source
const stripe = new Stripe('sk_live_51abc123actualkey...');

// ✅ ALWAYS DO THIS — loaded from environment variable
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

Also make sure .env.local (or .env) is listed in your .gitignore file so it never gets committed. Ask your AI: "Is .env.local in my .gitignore?" Read more about protecting secrets in our security basics guide.

🔴 Critical: Missing Webhook Signature Verification

Your webhook URL (something like https://yourapp.com/api/webhooks/stripe) is a public endpoint. Anyone on the internet could send a fake POST request to it claiming "payment succeeded." Without signature verification, your app would believe it and grant access.

The stripe.webhooks.constructEvent() call verifies that the request actually came from Stripe using a cryptographic signature. If AI generates a webhook handler without this verification, add it. This is non-negotiable.

🟡 Serious: Confusing Test and Live Keys

Stripe gives you two completely separate environments:

ModeKey prefixWhat happens
Testsk_test_ / pk_test_Simulated payments, no real money, use card number 4242 4242 4242 4242
Livesk_live_ / pk_live_Real credit card charges, real money, real consequences

During development: always use test keys. The test card number 4242 4242 4242 4242 (any future expiry, any CVC) simulates a successful payment. Stripe's test mode has its own dashboard where you can see test transactions without affecting real data.

When you deploy to production, switch to live keys in your production environment variables. Never put live keys in your local .env.local. The code doesn't change — only the environment variables do.

🟡 Serious: Not Setting Up Webhooks at All

Some AI-generated Stripe integrations rely entirely on the success_url redirect — checking the URL parameters on the success page to activate the subscription. This is fundamentally broken because:

  • The user could close the tab before the redirect completes
  • The user's browser could crash
  • The network could drop
  • Someone could manually navigate to your success URL without paying

Webhooks are server-to-server. Stripe sends the notification directly to your backend, completely independent of the user's browser. If your integration doesn't include a webhook handler for checkout.session.completed, it's incomplete.

🟡 Serious: Using request.json() Instead of request.text()

Webhook signature verification needs the exact raw bytes of the request body. If you parse it as JSON first (request.json()), JavaScript may re-format it — reordering keys, removing whitespace — and the signature check fails. You'll see errors like "Webhook signature verification failed" even though the webhook is legitimate.

Always use request.text() to get the raw body, pass it to constructEvent(), and only after verification do you work with the parsed event object.

🟠 Moderate: No Idempotency Handling

Stripe may send the same webhook event more than once — if your server was slow to respond, or returned an error, Stripe retries. If your handler runs "grant subscription" every time without checking if it already did, you might create duplicate database records or send multiple welcome emails.

The fix: store the Stripe event ID (event.id) after processing, and skip events you've already handled. Or use database upsert operations that are safe to run multiple times.

🟠 Moderate: Forgetting the Customer Portal

Users need to manage their subscriptions — update payment method, cancel, view invoices. AI often generates the "subscribe" flow but forgets the "manage" flow. Stripe provides a hosted Customer Portal for this. Add a "Manage Subscription" button that creates a portal session:

const portalSession = await stripe.billingPortal.sessions.create({
  customer: user.stripeCustomerId,
  return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard`,
});
// Redirect user to portalSession.url

How to Debug Stripe Integration

Stripe gives you excellent debugging tools. You don't need to guess what's happening.

1. Use the Stripe CLI for Local Webhook Testing

During development, Stripe can't reach localhost:3000. The Stripe CLI creates a tunnel:

# Install the Stripe CLI, then:
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# It will output a webhook signing secret like:
# whsec_abc123...
# Use THIS as your STRIPE_WEBHOOK_SECRET in .env.local

Now you can trigger test events:

stripe trigger checkout.session.completed

This sends a simulated event to your local webhook handler. If it works, you'll see the response in your terminal. If it fails, you'll see the error immediately.

2. Check the Stripe Dashboard

Every webhook attempt is logged in your Stripe Dashboard under Developers → Webhooks → your endpoint. You can see:

  • Every event that was sent
  • The HTTP status code your endpoint returned
  • The full request payload
  • How many times Stripe retried

If your endpoint returns anything other than 200, the event shows as failed with the error details. This is usually enough to diagnose the issue.

3. Use Stripe's Test Card Numbers

Card NumberBehavior
4242 4242 4242 4242Succeeds
4000 0000 0000 9995Declines (insufficient funds)
4000 0000 0000 3220Requires 3D Secure authentication
4000 0000 0000 0341Attaches to customer, but fails on charge

Use any future expiry date and any 3-digit CVC. These only work with test mode keys.

4. Ask AI to Debug

When Things Break

My Stripe webhook is returning a 400 error with "Invalid signature."
Here's my webhook handler code: [paste code]
Here's my .env.local: STRIPE_WEBHOOK_SECRET=whsec_...
I'm using the Stripe CLI with `stripe listen --forward-to localhost:3000/api/webhooks/stripe`.
What's wrong?

Common causes: using the wrong webhook secret (the Stripe CLI generates its own secret — use that one locally, not the one from the dashboard), or parsing the request body as JSON before verification.

5. The Pre-Launch Checklist

Before switching from test mode to live mode, verify these:

  1. Secret key is in environment variables — not in code, not in Git
  2. .env.local is in .gitignore — check with git status
  3. Webhook signature verification works — test with the Stripe CLI
  4. Webhook events are handled idempotently — safe to receive twice
  5. Production webhook endpoint is registered in Stripe Dashboard — different from your local CLI endpoint
  6. Production uses live keyssk_live_ and pk_live_ in production env vars only
  7. Customer portal is set up — users can cancel and manage billing
  8. Authentication protects your checkout endpoint — only logged-in users can create checkout sessions

Quick Glossary of Stripe Concepts

When you're reading AI-generated Stripe code, you'll see these terms. Here's what they mean in plain English:

  • Checkout Session — a temporary payment page Stripe creates for one transaction. Expires after 24 hours if not completed.
  • Price ID — Stripe's identifier for a specific price (e.g., "Basic plan, $9/month"). Created in the Stripe Dashboard or via the API. Looks like price_1abc2DEF3ghi.
  • Customer — a Stripe record representing a person who pays you. Stores their payment methods, subscription history, and invoices.
  • Subscription — an ongoing, recurring charge. Has statuses like active, past_due, canceled, trialing.
  • Webhook — an HTTP POST request Stripe sends to your server when something happens. See our full webhook guide.
  • Payment Intent — a lower-level object for one-time payments. Checkout Sessions use these under the hood.
  • Customer Portal — a Stripe-hosted page where your customers can update their payment method, view invoices, and cancel subscriptions.
  • Idempotency Key — a unique string that ensures a request only gets processed once, even if you accidentally send it twice.

What to Learn Next

Frequently Asked Questions

Stripe integration means connecting your app to Stripe's payment processing service using their API. Your app sends payment requests to Stripe, Stripe handles the actual credit card processing (so you never touch card numbers), and Stripe notifies your app when payments succeed or fail via webhooks. It's the most common way AI tools add payments to apps.

Stripe handles PCI compliance for you — as long as you use Stripe Checkout or Stripe Elements, credit card numbers never touch your server. Your main responsibility is keeping your Stripe API keys secret (in environment variables, never in code) and verifying webhook signatures. If you follow these two rules, Stripe handles the hard security work.

Test mode uses fake API keys (starting with sk_test_ and pk_test_) that process simulated payments with no real money. Live mode uses real keys (sk_live_ and pk_live_) that charge actual credit cards. You build and debug in test mode, then switch to live keys only when you're ready to accept real payments. The code stays exactly the same — only the keys change.

Stripe webhooks are automatic notifications Stripe sends to your server when events happen — a payment succeeds, a subscription renews, a charge is refunded. You need them because the browser redirect after checkout is unreliable (the user could close the tab). Webhooks are server-to-server, so your app gets notified even if the user disappears. They're essential for any real payment integration.

Yes — AI tools like Cursor and Claude Code are very good at generating Stripe integration code. They'll typically set up Stripe Checkout, create API routes, and build webhook handlers. However, you need to review the output for three things: (1) API keys must be in environment variables, never hardcoded, (2) webhook signature verification must be present, and (3) test mode keys should be used during development. AI gets the structure right but sometimes skips security details.