Nurture TechnologiesNurture Tech
Integrations17 min read·July 16, 2026

Stripe Payment Integration GuideArchitecture, Webhooks, Security, and Subscription Billing

StripeNode.jsNext.jsFastAPILaravel

Everything you need to integrate Stripe into a production application, from Checkout Sessions and Payment Intents to webhook handling, subscription lifecycle management, failed payment recovery, and PCI compliance.

Payment integration is one of the highest-stakes technical decisions a product team makes. Get it wrong and you lose revenue to failed transactions, expose customer data, or create a checkout experience that kills conversion. Get it right and your payment infrastructure becomes invisible to customers, it simply works, at every scale, in every country, across every card network.

Stripe is the most complete payment platform available to developers. It handles card processing, subscription billing, invoicing, customer portals, fraud prevention, tax calculation, and payouts, all through a single, well-documented API. But the breadth of the platform means there are many ways to integrate it, and several patterns that look correct on the surface but cause serious problems in production.

This guide covers the full integration lifecycle: choosing the right Stripe product for your use case, building checkout flows, handling webhooks correctly, managing subscriptions, recovering from failed payments, and securing the integration against fraud and misconfiguration. Code examples are provided for Node.js, NestJS, Laravel, and FastAPI.

Overview

Stripe's API is organized around a set of core objects: Customers, PaymentIntents, SetupIntents, Subscriptions, Invoices, and Checkout Sessions. For most integrations, you will interact with a subset of these. One-time payments use PaymentIntents or Checkout Sessions. Recurring billing uses Subscriptions, which automatically generate Invoices that attempt payment on a schedule. Webhooks deliver asynchronous event notifications for every state change across all these objects.

Stripe operates in two modes: test mode (all keys prefixed with sk_test_ and pk_test_) and live mode (sk_live_ and pk_live_). Test mode uses a set of predefined card numbers that simulate different outcomes, successful payments, declines, 3D Secure authentication, insufficient funds. Always build and test in test mode first; switch to live mode only when deploying to production.

  • One-time payments, a customer pays once for a product or service
  • Subscription billing, recurring monthly or annual charges for SaaS memberships
  • Usage-based billing, charge based on how much a customer consumes per billing period
  • Marketplaces, split payments between a platform and connected seller accounts (Stripe Connect)
  • Customer portals, let customers manage their own subscription, payment method, and invoices
  • Invoice automation, generate and send invoices automatically for B2B billing scenarios

Architecture Overview

The core principle in any Stripe integration: never trust the frontend to determine payment success. A user can manipulate JavaScript, intercept network requests, or modify URL parameters on a success redirect. Payment status must always be determined by your backend, either by calling the Stripe API directly or, better, by receiving a webhook event from Stripe.

stripe-architecture.mmd
flowchart LR
  Customer["Customer"] --> FE["Frontend\nReact / Next.js / Vue"]
  FE --> BE["Backend API\nNode.js / FastAPI"]
  BE --> Stripe["Stripe API"]
  Stripe --> Hook["Webhook\nPOST /stripe/webhook"]
  Hook --> BE
  BE --> DB["Database\nPostgreSQL"]
  BE --> Mail["Email Service\nSendGrid / Resend"]
  Stripe --> Portal["Customer Portal"]
  Portal --> Customer

Never provision access, ship orders, or activate subscriptions based on a successful redirect URL. The success URL fires even if the user closes the browser mid-payment. Always wait for the corresponding webhook event before fulfilling the order.

Request Flow, Checkout Session

  1. 1User clicks 'Subscribe' or 'Buy' on the frontend
  2. 2Frontend POSTs the selected price ID to your backend
  3. 3Backend creates a Checkout Session via the Stripe API and returns the hosted URL
  4. 4Frontend redirects the user to the Stripe-hosted checkout page
  5. 5User enters payment details on Stripe's secure page
  6. 6Stripe processes the payment and redirects to your success_url
  7. 7Stripe fires a checkout.session.completed webhook event to your server
  8. 8Your webhook handler fulfills the order and updates the database

Frontend Integration

The frontend's role in a Stripe integration is to initiate the payment flow and display the result. For Checkout Sessions, the frontend calls your backend to get a URL and redirects, Stripe hosts the entire payment page. For Payment Elements, Stripe's SDK renders the card fields directly in your UI.

React, Checkout Redirect

$npm install @stripe/stripe-js @stripe/react-stripe-js
components/CheckoutButton.tsx
import { useState } from 'react'

interface CheckoutButtonProps {
  priceId: string
  label?: string
}

export function CheckoutButton({ priceId, label = 'Subscribe' }: CheckoutButtonProps) {
  const [loading, setLoading] = useState(false)

  const handleCheckout = async () => {
    setLoading(true)
    try {
      const res = await fetch('/api/stripe/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ priceId }),
      })
      const { url, error } = await res.json()
      if (error) throw new Error(error)
      window.location.href = url
    } catch (err) {
      alert('Checkout failed. Please try again.')
      setLoading(false)
    }
  }

  return (
    <button onClick={handleCheckout} disabled={loading}>
      {loading ? 'Redirecting...' : label}
    </button>
  )
}

React, Embedded Payment Element

Use the Payment Element when you want to embed card fields directly in your UI instead of redirecting to Stripe's hosted page. The Payment Element automatically supports cards, wallets (Apple Pay, Google Pay), and regional payment methods.

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

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

function CheckoutForm() {
  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: stripeError } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: window.location.origin + '/payment/success',
      },
    })

    if (stripeError) {
      setError(stripeError.message ?? 'Payment failed.')
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      {error && <p className="text-red-500 text-sm mt-2">{error}</p>}
      <button type="submit" disabled={!stripe || loading}>
        {loading ? 'Processing...' : 'Pay now'}
      </button>
    </form>
  )
}

export function PaymentForm({ clientSecret }: { clientSecret: string }) {
  return (
    <Elements stripe={stripePromise} options={{ clientSecret }}>
      <CheckoutForm />
    </Elements>
  )
}

Next.js, Checkout API Route

app/api/stripe/checkout/route.ts
import Stripe from 'stripe'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'

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

export async function POST(req: NextRequest) {
  const session = await getServerSession()
  if (!session?.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { priceId } = await req.json()
  if (!priceId) return NextResponse.json({ error: 'Missing priceId' }, { status: 400 })

  const user = await db.users.findUnique({ where: { email: session.user.email! } })

  const checkoutSession = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer: user?.stripeCustomerId ?? undefined,
    customer_email: user?.stripeCustomerId ? undefined : session.user.email!,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: process.env.NEXT_PUBLIC_BASE_URL + '/dashboard?checkout=success',
    cancel_url: process.env.NEXT_PUBLIC_BASE_URL + '/pricing',
    metadata: { userId: user!.id },
    subscription_data: { metadata: { userId: user!.id } },
  })

  return NextResponse.json({ url: checkoutSession.url })
}

Vue.js, Checkout Composable

composables/useStripeCheckout.ts
import { ref } from 'vue'

export function useStripeCheckout() {
  const loading = ref(false)
  const error = ref<string | null>(null)

  const startCheckout = async (priceId: string) => {
    loading.value = true
    error.value = null

    try {
      const res = await fetch('/api/stripe/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ priceId }),
      })

      if (!res.ok) throw new Error('Server error')

      const { url } = await res.json()
      window.location.href = url
    } catch {
      error.value = 'Checkout failed. Please try again.'
      loading.value = false
    }
  }

  const openPortal = async () => {
    const res = await fetch('/api/stripe/portal', { method: 'POST' })
    const { url } = await res.json()
    window.location.href = url
  }

  return { loading, error, startCheckout, openPortal }
}

Angular

src/app/services/stripe.service.ts
import { Injectable } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { tap } from 'rxjs/operators'

@Injectable({ providedIn: 'root' })
export class StripeService {
  constructor(private http: HttpClient) {}

  startCheckout(priceId: string) {
    return this.http
      .post<{ url: string }>('/api/stripe/checkout', { priceId })
      .pipe(tap(({ url }) => (window.location.href = url)))
  }

  openPortal() {
    return this.http
      .post<{ url: string }>('/api/stripe/portal', {})
      .pipe(tap(({ url }) => (window.location.href = url)))
  }
}

Svelte

src/components/CheckoutButton.svelte
<script lang="ts">
  export let priceId: string
  export let label = 'Subscribe'
  let loading = false

  async function startCheckout() {
    loading = true
    try {
      const res = await fetch('/api/stripe/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ priceId }),
      })
      const { url } = await res.json()
      window.location.href = url
    } catch {
      loading = false
    }
  }
</script>

<button on:click={startCheckout} disabled={loading}>
  {loading ? 'Redirecting...' : label}
</button>

Solid.js

src/components/CheckoutButton.tsx
import { createSignal } from 'solid-js'

export function CheckoutButton({ priceId, label = 'Subscribe' }: { priceId: string; label?: string }) {
  const [loading, setLoading] = createSignal(false)

  const startCheckout = async () => {
    setLoading(true)
    const res = await fetch('/api/stripe/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ priceId }),
    })
    const { url } = await res.json()
    window.location.href = url
  }

  return (
    <button onClick={startCheckout} disabled={loading()}>
      {loading() ? 'Redirecting...' : label}
    </button>
  )
}

Qwik

src/components/CheckoutButton.tsx
import { component$, useSignal, $ } from '@builder.io/qwik'

export const CheckoutButton = component$<{ priceId: string; label?: string }>(
  ({ priceId, label = 'Subscribe' }) => {
    const loading = useSignal(false)

    const startCheckout = $(async () => {
      loading.value = true
      const res = await fetch('/api/stripe/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ priceId }),
      })
      const { url } = await res.json()
      window.location.href = url
    })

    return (
      <button onClick$={startCheckout} disabled={loading.value}>
        {loading.value ? 'Redirecting...' : label}
      </button>
    )
  }
)

Astro

src/components/CheckoutButton.astro
---
const { priceId, label = 'Subscribe' } = Astro.props
---
<button data-price-id={priceId} id="checkout-btn">{label}</button>

<script>
  document.getElementById('checkout-btn')?.addEventListener('click', async (e) => {
    const btn = e.currentTarget as HTMLButtonElement
    const priceId = btn.dataset.priceId
    btn.textContent = 'Redirecting...'
    btn.disabled = true
    const res = await fetch('/api/stripe/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ priceId }),
    })
    const { url } = await res.json()
    window.location.href = url
  })
</script>

Backend Integration

The backend creates Stripe objects, verifies webhooks, and syncs payment state to your database. The Stripe SDK handles all the HTTP communication, you only need to configure it with your secret key.

Node.js / Express

$npm install stripe
src/services/stripe.service.ts
import Stripe from 'stripe'

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

export async function getOrCreateCustomer(userId: string, email: string): Promise<string> {
  const existing = await db.users.findUnique({ where: { id: userId } })
  if (existing?.stripeCustomerId) return existing.stripeCustomerId

  const customer = await stripe.customers.create({ email, metadata: { userId } })

  await db.users.update({
    where: { id: userId },
    data: { stripeCustomerId: customer.id },
  })

  return customer.id
}

export async function createCheckoutSession(userId: string, email: string, priceId: string) {
  const customerId = await getOrCreateCustomer(userId, email)

  return stripe.checkout.sessions.create({
    mode: 'subscription',
    customer: customerId,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: process.env.BASE_URL + '/dashboard?checkout=success',
    cancel_url: process.env.BASE_URL + '/pricing',
    metadata: { userId },
    subscription_data: { metadata: { userId } },
    allow_promotion_codes: true,
  })
}

export async function createPortalSession(customerId: string) {
  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: process.env.BASE_URL + '/dashboard/billing',
  })
  return session.url
}

NestJS

src/stripe/stripe.service.ts
import { Injectable, RawBodyRequest } from '@nestjs/common'
import Stripe from 'stripe'
import type { Request } from 'express'

@Injectable()
export class StripeService {
  readonly stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
    apiVersion: '2024-06-20',
  })

  async createCheckoutSession(userId: string, email: string, priceId: string) {
    return this.stripe.checkout.sessions.create({
      mode: 'subscription',
      customer_email: email,
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: process.env.BASE_URL + '/dashboard?checkout=success',
      cancel_url: process.env.BASE_URL + '/pricing',
      metadata: { userId },
    })
  }

  constructWebhookEvent(req: RawBodyRequest<Request>): Stripe.Event {
    const sig = req.headers['stripe-signature'] as string
    return this.stripe.webhooks.constructEvent(
      req.rawBody!,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  }
}

Python FastAPI

$pip install stripe fastapi
routers/stripe.py
import os, stripe
from fastapi import APIRouter, Request, HTTPException

router = APIRouter()
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]

@router.post("/checkout")
async def create_checkout(request: Request):
    body = await request.json()
    session = stripe.checkout.Session.create(
        mode="subscription",
        customer_email=body["email"],
        line_items=[{"price": body["price_id"], "quantity": 1}],
        success_url=os.environ["BASE_URL"] + "/dashboard?checkout=success",
        cancel_url=os.environ["BASE_URL"] + "/pricing",
        metadata={"user_id": body["user_id"]},
        subscription_data={"metadata": {"user_id": body["user_id"]}},
    )
    return {"url": session.url}

@router.post("/webhook")
async def handle_webhook(request: Request):
    payload = await request.body()
    sig = request.headers.get("stripe-signature", "")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig, os.environ["STRIPE_WEBHOOK_SECRET"]
        )
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")

    event_type = event["type"]
    data = event["data"]["object"]

    if event_type == "checkout.session.completed":
        await handle_checkout_complete(data)
    elif event_type == "customer.subscription.updated":
        await handle_subscription_update(data)
    elif event_type == "invoice.payment_failed":
        await handle_failed_payment(data)

    return {"received": True}

Laravel

$composer require stripe/stripe-php laravel/cashier
app/Services/StripeService.php
<?php

namespace App\Services;

use Stripe\StripeClient;

class StripeService
{
    private StripeClient $stripe;

    public function __construct()
    {
        $this->stripe = new StripeClient(config('services.stripe.secret'));
    }

    public function createCheckoutSession(string $userId, string $email, string $priceId): string
    {
        $session = $this->stripe->checkout->sessions->create([
            'mode' => 'subscription',
            'customer_email' => $email,
            'line_items' => [['price' => $priceId, 'quantity' => 1]],
            'success_url' => config('app.url') . '/dashboard?checkout=success',
            'cancel_url' => config('app.url') . '/pricing',
            'metadata' => ['user_id' => $userId],
            'subscription_data' => ['metadata' => ['user_id' => $userId]],
        ]);

        return $session->url;
    }

    public function createPortalSession(string $customerId): string
    {
        $session = $this->stripe->billingPortal->sessions->create([
            'customer' => $customerId,
            'return_url' => config('app.url') . '/dashboard/billing',
        ]);

        return $session->url;
    }
}

Django

$pip install stripe django
payments/views.py
import os, json, stripe
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

stripe.api_key = os.environ['STRIPE_SECRET_KEY']

@csrf_exempt
@require_POST
def create_checkout(request):
    body = json.loads(request.body)
    session = stripe.checkout.Session.create(
        mode='subscription',
        customer_email=body['email'],
        line_items=[{'price': body['price_id'], 'quantity': 1}],
        success_url=os.environ['BASE_URL'] + '/dashboard?checkout=success',
        cancel_url=os.environ['BASE_URL'] + '/pricing',
        metadata={'user_id': body['user_id']},
    )
    return JsonResponse({'url': session.url})

@csrf_exempt
def stripe_webhook(request):
    try:
        event = stripe.Webhook.construct_event(
            request.body,
            request.META.get('HTTP_STRIPE_SIGNATURE', ''),
            os.environ['STRIPE_WEBHOOK_SECRET'],
        )
    except stripe.error.SignatureVerificationError:
        return JsonResponse({'error': 'Invalid signature'}, status=400)

    if event['type'] == 'checkout.session.completed':
        handle_checkout_complete(event['data']['object'])
    return JsonResponse({'received': True})

Go

$go get github.com/stripe/stripe-go/v78
handlers/stripe.go
package handlers

import (
    "encoding/json"
    "net/http"
    "os"

    "github.com/stripe/stripe-go/v78"
    "github.com/stripe/stripe-go/v78/checkout/session"
)

func CreateCheckout(w http.ResponseWriter, r *http.Request) {
    stripe.Key = os.Getenv("STRIPE_SECRET_KEY")

    var body struct {
        PriceID string `json:"price_id"`
        Email   string `json:"email"`
        UserID  string `json:"user_id"`
    }
    json.NewDecoder(r.Body).Decode(&body)

    base := os.Getenv("BASE_URL")
    params := &stripe.CheckoutSessionParams{
        Mode:          stripe.String(string(stripe.CheckoutSessionModeSubscription)),
        CustomerEmail: stripe.String(body.Email),
        LineItems: []*stripe.CheckoutSessionLineItemParams{
            {Price: stripe.String(body.PriceID), Quantity: stripe.Int64(1)},
        },
        SuccessURL: stripe.String(base + "/dashboard?checkout=success"),
        CancelURL:  stripe.String(base + "/pricing"),
    }
    params.AddMetadata("user_id", body.UserID)

    s, err := session.New(params)
    if err != nil {
        http.Error(w, "Stripe error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{"url": s.URL})
}

Spring Boot

$# pom.xml: com.stripe:stripe-java:25.1.0
StripeController.java
@RestController
@RequestMapping("/api/stripe")
public class StripeController {

    @PostMapping("/checkout")
    public ResponseEntity<Map<String, String>> createCheckout(@RequestBody Map<String, String> body) throws StripeException {
        Stripe.apiKey = System.getenv("STRIPE_SECRET_KEY");
        String base = System.getenv("BASE_URL");

        SessionCreateParams params = SessionCreateParams.builder()
            .setMode(SessionCreateParams.Mode.SUBSCRIPTION)
            .setCustomerEmail(body.get("email"))
            .addLineItem(SessionCreateParams.LineItem.builder()
                .setPrice(body.get("price_id")).setQuantity(1L).build())
            .setSuccessUrl(base + "/dashboard?checkout=success")
            .setCancelUrl(base + "/pricing")
            .putMetadata("user_id", body.get("user_id"))
            .build();

        Session session = Session.create(params);
        return ResponseEntity.ok(Map.of("url", session.getUrl()));
    }
}

ASP.NET Core

$dotnet add package Stripe.net
StripeController.cs
[ApiController]
[Route("api/stripe")]
[Authorize]
public class StripeController : ControllerBase
{
    [HttpPost("checkout")]
    public async Task<IActionResult> CreateCheckout([FromBody] CheckoutRequest request)
    {
        StripeConfiguration.ApiKey = Environment.GetEnvironmentVariable("STRIPE_SECRET_KEY");
        var baseUrl = Environment.GetEnvironmentVariable("BASE_URL");

        var options = new SessionCreateOptions
        {
            Mode = "subscription",
            CustomerEmail = request.Email,
            LineItems = new List<SessionLineItemOptions>
            {
                new SessionLineItemOptions { Price = request.PriceId, Quantity = 1 },
            },
            SuccessUrl = baseUrl + "/dashboard?checkout=success",
            CancelUrl = baseUrl + "/pricing",
            Metadata = new Dictionary<string, string> { { "user_id", request.UserId } },
        };

        var service = new SessionService();
        var session = await service.CreateAsync(options);
        return Ok(new { url = session.Url });
    }
}

Rust

$# Cargo.toml: axum = "0.7", reqwest = { features = ["json", "form"] }, serde_json = "1"
src/handlers/stripe.rs
use axum::{Json, extract::State};
use reqwest::Client;
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
pub struct CheckoutRequest {
    price_id: String,
    email: String,
    user_id: String,
}

#[derive(Serialize)]
pub struct CheckoutResponse {
    url: String,
}

pub async fn create_checkout(
    State(client): State<Client>,
    Json(body): Json<CheckoutRequest>,
) -> Json<CheckoutResponse> {
    let secret = std::env::var("STRIPE_SECRET_KEY").unwrap();
    let base = std::env::var("BASE_URL").unwrap();

    let res = client
        .post("https://api.stripe.com/v1/checkout/sessions")
        .basic_auth(&secret, Some(""))
        .form(&[
            ("mode", "subscription"),
            ("customer_email", &body.email),
            ("line_items[0][price]", &body.price_id),
            ("line_items[0][quantity]", "1"),
            ("success_url", &format!("{base}/dashboard?checkout=success")),
            ("cancel_url", &format!("{base}/pricing")),
            ("metadata[user_id]", &body.user_id),
        ])
        .send().await.unwrap();

    let data: serde_json::Value = res.json().await.unwrap();
    Json(CheckoutResponse { url: data["url"].as_str().unwrap_or("").to_string() })
}

Webhooks

Webhooks are the backbone of any Stripe integration. Every significant event in Stripe, payment succeeded, subscription renewed, invoice failed, customer updated, fires a webhook event to your endpoint. Your application reacts to these events rather than polling the Stripe API for state changes.

Webhook Handler, Node.js

The raw request body must reach the webhook verification function. If your framework parses the body as JSON before verification, the signature will not match. Configure your body-parser to preserve the raw buffer for the /stripe/webhook route specifically.

src/webhooks/stripe.ts
import Stripe from 'stripe'
import type { Request, Response } from 'express'
import { stripe } from '../services/stripe.service'

export async function handleStripeWebhook(req: Request, res: Response) {
  const sig = req.headers['stripe-signature'] as string
  let event: Stripe.Event

  try {
    // req.body must be the raw Buffer, not parsed JSON
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return res.status(400).send('Webhook signature verification failed')
  }

  // Respond immediately; process asynchronously
  res.status(200).json({ received: true })

  try {
    switch (event.type) {
      case 'checkout.session.completed':
        await onCheckoutComplete(event.data.object as Stripe.Checkout.Session)
        break
      case 'customer.subscription.created':
      case 'customer.subscription.updated':
        await onSubscriptionChange(event.data.object as Stripe.Subscription)
        break
      case 'customer.subscription.deleted':
        await onSubscriptionCanceled(event.data.object as Stripe.Subscription)
        break
      case 'invoice.payment_succeeded':
        await onInvoicePaid(event.data.object as Stripe.Invoice)
        break
      case 'invoice.payment_failed':
        await onPaymentFailed(event.data.object as Stripe.Invoice)
        break
      default:
        // Log unhandled events for visibility
        console.log('Unhandled Stripe event:', event.type)
    }
  } catch (err) {
    // Log processing errors, the 200 is already sent so Stripe won't retry
    console.error('Webhook handler error:', event.type, err)
  }
}

async function onCheckoutComplete(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId
  if (!userId) return

  await db.subscriptions.upsert({
    where: { userId },
    create: {
      userId,
      stripeCustomerId: session.customer as string,
      stripeSubscriptionId: session.subscription as string,
      status: 'active',
      plan: session.metadata?.plan ?? 'pro',
    },
    update: {
      status: 'active',
      stripeSubscriptionId: session.subscription as string,
    },
  })
}

async function onPaymentFailed(invoice: Stripe.Invoice) {
  const customerId = invoice.customer as string
  await db.subscriptions.update({
    where: { stripeCustomerId: customerId },
    data: { status: 'past_due' },
  })
  // Trigger email notification
  await sendPaymentFailedEmail(customerId, invoice)
}

Testing Webhooks Locally

$stripe listen --forward-to localhost:3000/stripe/webhook

The Stripe CLI forwards live webhook events from your Stripe account to your local server. Run the command above, copy the webhook signing secret it outputs, and set it as STRIPE_WEBHOOK_SECRET in your local .env. You can also trigger specific events manually with stripe trigger checkout.session.completed to test your handlers without completing a real checkout.

Subscription Management

Subscriptions in Stripe are living objects. They move through states, trialing, active, past_due, canceled, unpaid, and generate Invoices on each billing cycle. Your database should mirror the subscription state, updated by webhook events rather than direct API calls.

src/services/subscription.service.ts
import { stripe } from './stripe.service'

// Cancel at the end of the current billing period
export async function cancelSubscription(subscriptionId: string) {
  return stripe.subscriptions.update(subscriptionId, {
    cancel_at_period_end: true,
  })
}

// Reactivate a canceled subscription before period end
export async function reactivateSubscription(subscriptionId: string) {
  return stripe.subscriptions.update(subscriptionId, {
    cancel_at_period_end: false,
  })
}

// Upgrade or downgrade to a different price
export async function changeSubscriptionPlan(subscriptionId: string, newPriceId: string) {
  const sub = await stripe.subscriptions.retrieve(subscriptionId)
  return stripe.subscriptions.update(subscriptionId, {
    items: [{ id: sub.items.data[0].id, price: newPriceId }],
    proration_behavior: 'create_prorations',
  })
}

// Pause collection, subscription stays active, no charges
export async function pauseSubscription(subscriptionId: string) {
  return stripe.subscriptions.update(subscriptionId, {
    pause_collection: { behavior: 'void' },
  })
}

// Usage-based billing: report metered usage
export async function reportUsage(subscriptionItemId: string, quantity: number) {
  return stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
    quantity,
    timestamp: Math.floor(Date.now() / 1000),
    action: 'increment',
  })
}

Subscription States in Your Database

  • active, subscription is current, payment succeeded on last attempt
  • trialing, in a free trial period, no payment charged yet
  • past_due, latest invoice payment failed, Stripe will retry automatically
  • canceled, subscription was canceled, access should be revoked after period_end
  • unpaid, all retries exhausted, subscription access should be suspended
  • incomplete, subscription created but first payment not yet confirmed

Security

PCI Compliance

Stripe's hosted Checkout and Payment Elements architecture makes your integration SAQ A compliant, the lightest PCI compliance category. Your servers never touch raw card data. Stripe's servers handle card collection, storage, and transmission. If you process card data on your own servers in any way (even passing it through a proxy), your compliance scope expands significantly and requires a security audit.

Webhook Signature Verification

Every webhook event from Stripe is signed with your webhook secret using HMAC-SHA256. Call stripe.webhooks.constructEvent() with the raw request body, signature header, and your webhook secret on every incoming request. This function throws if the signature is invalid. Never process a webhook event before calling this verification step, an attacker who knows your webhook URL can fabricate events otherwise.

Secret Key Management

.env
# Never commit these, store in a secrets manager in production
STRIPE_SECRET_KEY=sk_live_...        # Server only, never expose to client
STRIPE_WEBHOOK_SECRET=whsec_...     # Server only
STRIPE_PUBLISHABLE_KEY=pk_live_...  # Safe for frontend, public by design

# Test mode equivalents for development
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...     # From: stripe listen --print-secret
STRIPE_PUBLISHABLE_KEY=pk_test_...

# Your application URLs
NEXT_PUBLIC_BASE_URL=https://yourapp.com
BASE_URL=https://yourapp.com

Fraud Prevention

Stripe Radar is an ML-based fraud detection system that runs on every transaction. It is enabled by default at no extra cost. Radar evaluates hundreds of signals, IP geolocation, card velocity, device fingerprint, BIN data, and blocks or flags suspicious transactions automatically. Configure Radar rules in the Stripe Dashboard to customize the thresholds for your business.

For high-risk transactions, enable 3D Secure (3DS) authentication. Setting request_three_d_secure: 'automatic' on the PaymentIntent instructs Stripe to request 3DS when Radar recommends it. 3DS shifts chargeback liability to the issuing bank, reducing your fraud exposure on authenticated transactions.

src/services/paymentIntent.ts
export async function createPaymentIntent(amount: number, currency: string, customerId: string) {
  return stripe.paymentIntents.create({
    amount,                          // Always in smallest currency unit (cents for USD)
    currency,
    customer: customerId,
    payment_method_types: ['card'],
    payment_method_options: {
      card: {
        request_three_d_secure: 'automatic',
      },
    },
    // Idempotency key prevents double-charges on retry
    // Pass this in the request options, not the params
  })
}

Automation

Subscription Lifecycle Automation

Every subscription lifecycle event, created, renewed, upgraded, downgraded, canceled, expired, should trigger a corresponding action in your system. Map each Stripe event to a business outcome and implement the handler. The most important ones: customer.subscription.created (provision access), customer.subscription.deleted (revoke access), invoice.payment_succeeded (record revenue), invoice.payment_failed (begin recovery flow).

Failed Payment Recovery

Stripe's Smart Retries automatically retries failed subscription payments at times statistically likely to succeed, this alone recovers 20 to 30 percent of failed payments without any code. Enable it in Stripe Dashboard > Billing > Revenue Recovery. For the payments that still fail after retries, implement an in-app dunning flow.

automation/failedPayment.ts
import type Stripe from 'stripe'
import { stripe } from '../services/stripe.service'

export async function handleFailedPayment(invoice: Stripe.Invoice) {
  const customerId = invoice.customer as string
  const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer

  const userId = customer.metadata?.userId
  if (!userId) return

  // 1. Update subscription status
  await db.subscriptions.update({
    where: { stripeCustomerId: customerId },
    data: {
      status: 'past_due',
      lastFailedAt: new Date(),
      failureCount: { increment: 1 },
    },
  })

  // 2. Create a portal link for self-serve recovery
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: process.env.BASE_URL + '/dashboard/billing',
  })

  // 3. Send recovery email
  await sendEmail({
    to: customer.email!,
    template: 'payment_failed',
    data: {
      amount: (invoice.amount_due / 100).toFixed(2),
      currency: invoice.currency.toUpperCase(),
      retryDate: invoice.next_payment_attempt
        ? new Date(invoice.next_payment_attempt * 1000).toLocaleDateString()
        : null,
      portalUrl: portalSession.url,
    },
  })
}

Customer Onboarding After Checkout

automation/onboarding.ts
import type Stripe from 'stripe'

export async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId
  if (!userId) return

  // 1. Retrieve the full subscription to get the plan details
  const subscription = await stripe.subscriptions.retrieve(session.subscription as string)
  const priceId = subscription.items.data[0].price.id

  // 2. Provision access based on price ID
  const plan = getPlanFromPriceId(priceId)
  await db.users.update({
    where: { id: userId },
    data: {
      plan,
      planActivatedAt: new Date(),
      stripeCustomerId: session.customer as string,
    },
  })

  // 3. Create subscription record
  await db.subscriptions.create({
    data: {
      userId,
      stripeCustomerId: session.customer as string,
      stripeSubscriptionId: session.subscription as string,
      stripePriceId: priceId,
      status: 'active',
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  })

  // 4. Send welcome email and trigger onboarding sequence
  await sendWelcomeEmail(userId, plan)
  await triggerOnboardingSequence(userId)
}

Invoice Automation

Stripe automatically generates and sends invoices for subscription renewals. Configure this in the Stripe Dashboard under Billing > Invoice settings, set the invoice footer, logo, tax ID display, and whether to automatically finalize and send invoices. For custom invoicing (B2B with purchase orders, Net 30 terms, etc.), use the Stripe Invoices API to create manual invoices with custom line items, due dates, and automatic reminders.

Common Mistakes

Trusting the Success URL to Confirm Payment

The success_url fires when Stripe redirects the user after checkout, not when payment is confirmed. If the user closes the browser during redirect, or if the URL is modified, the success page fires but the payment may not have completed. Always fulfill orders from the checkout.session.completed webhook event, never from the success URL. Use the success URL only to show a friendly confirmation message.

Passing Parsed JSON to Webhook Verification

stripe.webhooks.constructEvent() requires the raw request body as a Buffer or string, exactly as received from Stripe. Most web frameworks parse the body as JSON by default, which transforms the raw bytes. This causes the HMAC signature to not match and every webhook to fail verification. For Express, configure body-parser to preserve raw bytes specifically for the /stripe/webhook route using the verify callback. For Next.js app router, disable body parsing by exporting config from the route file.

Incorrect Subscription Cancellation Logic

When a user cancels, set cancel_at_period_end: true rather than immediately deleting the subscription. This lets the user keep access until the end of their paid period, which is the correct behavior and what customers expect. Listen for the customer.subscription.deleted webhook (which fires at period end) to actually revoke access. Revoking access immediately on cancel is a common source of support tickets and chargebacks.

Not Using Idempotency Keys

Network errors can cause your backend to retry a Stripe API call that already succeeded on the first attempt. Without idempotency keys, this can create duplicate charges, duplicate customers, or duplicate subscriptions. Pass an idempotency key for every payment-creating API call, a string that is unique to the specific operation. Stripe deduplicates requests with the same key within a 24-hour window.

Hardcoding Price IDs or Amount Values

Never hardcode price IDs or currency amounts in frontend code. Price IDs are environment-specific (test vs live) and change when you update pricing. Amounts should always originate from your backend and be verified server-side. Fetch available plans from an API endpoint that reads from your database or environment variables, do not embed them directly in the client bundle.

Production Deployment Checklist

Before processing live payments, verify every item on this list.

  • ✓ STRIPE_SECRET_KEY is a live key (sk_live_...) stored in a secrets manager, not in code
  • ✓ STRIPE_PUBLISHABLE_KEY is a live key (pk_live_...), safe to expose in frontend
  • ✓ STRIPE_WEBHOOK_SECRET is set from the live webhook endpoint, not the CLI test secret
  • ✓ Webhook endpoint registered in Stripe Dashboard with HTTPS URL and correct event subscriptions
  • ✓ Webhook signature verified using stripe.webhooks.constructEvent() on every request
  • ✓ Raw request body preserved for webhook route (body-parser rawBody, or equivalent)
  • ✓ Success URL only shows a confirmation message, fulfillment is handled in webhook handler
  • ✓ checkout.session.completed handler tested with stripe trigger checkout.session.completed
  • ✓ invoice.payment_failed handler implemented and sends recovery email with portal link
  • ✓ customer.subscription.deleted handler revokes access correctly
  • ✓ Stripe Customer ID stored per user in the database (never create duplicate customers)
  • ✓ Stripe Customer Portal enabled in Dashboard and linked from billing settings page
  • ✓ Smart Retries enabled in Dashboard under Billing > Revenue Recovery
  • ✓ Stripe Radar enabled (it is by default, confirm it has not been disabled)
  • ✓ 3D Secure set to automatic on PaymentIntents for fraud liability shift
  • ✓ Idempotency keys used for all payment-creating API calls
  • ✓ HTTPS enforced on all endpoints (Stripe requires HTTPS for live mode webhooks)
  • ✓ Subscription status synced from webhooks to database, not from API polling
  • ✓ All Stripe API calls wrapped in try/catch with user-friendly error messages
  • ✓ Full end-to-end test completed in live mode with a real card before launch
Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

What is the Stripe API and how does it work?+

The Stripe API is an HTTP REST API that lets developers accept payments, manage subscriptions, issue refunds, and handle billing workflows programmatically. You send JSON-formatted requests to Stripe's servers using your secret API key, and Stripe processes the payment through the appropriate card networks. Stripe provides official SDKs for Node.js, Python, PHP, Ruby, Java, Go, and .NET that wrap the HTTP calls. The core flow is: your backend creates a PaymentIntent or Checkout Session, your frontend collects payment details using Stripe's JavaScript library, Stripe processes the payment and fires webhook events, and your backend fulfills the order in response to those events.

Does Stripe handle PCI compliance for my business?+

Yes, when you use Stripe's hosted Checkout or the Payment Element, Stripe's embeddable card fields. In these integrations, card data is entered directly on Stripe-hosted infrastructure and never touches your servers. This makes your integration SAQ A compliant, which requires only a self-assessment questionnaire rather than a full security audit. If you use the deprecated Charges API where card details are posted to your server before being forwarded to Stripe, your compliance scope expands to SAQ D or SAQ B-IP, which require significantly more work. Use Checkout Sessions or the Payment Element for all new integrations.

How much does Stripe charge per transaction?+

Stripe's standard pricing is 2.9% + $0.30 per successful card transaction in the United States. International cards carry an additional 1.5% surcharge. ACH bank transfers cost 0.8% capped at $5. Subscription billing adds no extra fee on top of standard transaction fees. Stripe Radar (fraud detection) is included. Advanced Radar features cost an additional $0.02 per transaction. There are no monthly fees, setup fees, or fees for refunds (though the original transaction fee is not returned). Stripe offers custom pricing for businesses processing over $1 million per year, contact their sales team.

What is the difference between a Checkout Session and a Payment Intent?+

A Checkout Session is a Stripe-hosted payment page that handles the full checkout experience, card collection, 3D Secure, discount codes, tax calculation, and more. Your backend creates a session and redirects the user to Stripe's URL. It is the fastest way to accept payments and requires the least code. A PaymentIntent is a lower-level object representing a payment attempt that your code manages directly. You create the PaymentIntent on the server, pass the client_secret to the frontend, and use the Payment Element or CardElement to collect card details in your own UI. Use Checkout Sessions to move fast; use PaymentIntents when you need full control over the checkout UI and experience.

Why should I use webhooks instead of checking payment status on the success URL?+

The success_url fires when Stripe redirects the user after checkout, not when payment actually succeeds. Multiple problems arise if you fulfill from the success URL: the user can navigate directly to the success URL without paying, the URL parameters can be modified, the page fires before the payment settles in some cases, and the event can be missed entirely if the user closes the browser mid-redirect. Webhooks are server-to-server calls that Stripe sends directly to your backend when an event occurs. They cannot be faked without your webhook secret, they are retried if your server is down, and they are the authoritative source of payment status. Always fulfill from webhook events.

How do I handle subscription upgrades and downgrades?+

Use stripe.subscriptions.update() with the new price ID and proration_behavior set to 'create_prorations'. Stripe automatically calculates the prorated credit for the unused portion of the current plan and debits the cost of the new plan, settling the difference on the next invoice. For immediate upgrades, you can set billing_cycle_anchor to 'now' to start a fresh billing cycle at the new price immediately. For downgrades, some businesses prefer to set the new price to take effect at the end of the current period, use proration_behavior: 'none' and schedule the item change. Listen for the customer.subscription.updated webhook to update your database and UI accordingly.

What happens when a subscription payment fails?+

Stripe automatically retries the payment according to the schedule you configure in Dashboard > Billing > Revenue Recovery (Smart Retries are recommended). During the retry period, the subscription status changes to past_due. Stripe sends an invoice.payment_failed webhook event each time a payment attempt fails. If all retries are exhausted without a successful payment, the subscription status moves to unpaid or canceled depending on your dunning settings. Your webhook handler for invoice.payment_failed should update the user's subscription status in your database, restrict access if appropriate, and send a recovery email with a link to the Customer Portal where the user can update their payment method.

How do I test a Stripe integration?+

Use test mode, your test API keys (sk_test_ and pk_test_) work identically to live keys but process no real money. Stripe provides a set of test card numbers: 4242 4242 4242 4242 succeeds, 4000 0000 0000 0002 declines, and 4000 0025 0000 3155 triggers 3D Secure. For webhook testing, use the Stripe CLI: run 'stripe listen --forward-to localhost:3000/stripe/webhook' to receive live test events, and 'stripe trigger checkout.session.completed' to fire specific events. Test the complete lifecycle: successful checkout, subscription renewal, payment failure, recovery, upgrade, downgrade, and cancellation.

What is the Stripe Customer Portal?+

The Stripe Customer Portal is a Stripe-hosted self-service page where your customers can manage their own subscription, update payment methods, view invoice history, download receipts, change plans, and cancel. You create a portal session by calling stripe.billingPortal.sessions.create() with the customer ID and a return URL, then redirect the user to the resulting URL. The portal is customizable in the Stripe Dashboard, you can configure which actions customers can take, set your branding, and enable or disable specific features. Using the Customer Portal eliminates the need to build billing management UIs and ensures customers always have a secure way to update their payment details.

How do I implement usage-based billing?+

Create a Price object in Stripe with billing_scheme: 'tiered' or a metered price (recurring.usage_type: 'metered'). When creating a subscription with a metered price, Stripe creates a SubscriptionItem. Each billing period, report usage by calling stripe.subscriptionItems.createUsageRecord() with the SubscriptionItem ID, quantity, and timestamp. Stripe aggregates the usage records and calculates the invoice amount at the end of the billing period. Common patterns: report usage in real time as events occur, or batch-report usage daily/hourly. For SaaS products, common metered metrics include API calls, active seats, data processed, or messages sent.

How do I handle refunds programmatically?+

Call stripe.refunds.create() with the payment intent ID and optionally an amount (for partial refunds). Stripe immediately initiates the refund and the funds typically reach the customer's account in 5 to 10 business days. Stripe fires a charge.refunded event when the refund is created. Update your database and send a confirmation email in the refund webhook handler. For subscription refunds, cancel the subscription first (if not already canceled), then refund the most recent invoice's payment intent. Note that Stripe does not return the original processing fee on refunds, factor this into your refund policy for high-volume scenarios.

Can Stripe handle multiple currencies?+

Yes. Stripe supports 135+ currencies. When creating a PaymentIntent or Checkout Session, specify the currency as a three-letter ISO code (usd, eur, gbp, etc.). Stripe automatically displays the correct currency symbol and formatting. For subscriptions, the currency is set on the Price object and cannot be changed after creation. If you sell internationally, create separate Price objects for each currency rather than doing currency conversion in your application. Stripe Billing's Multi-currency Prices feature lets a single product have prices in multiple currencies. Your Stripe balance is maintained separately per currency and paid out in each currency to your bank account.

What is Stripe Connect and when do I need it?+

Stripe Connect is Stripe's platform product for marketplaces, SaaS platforms, and any business that processes payments on behalf of others. With Standard Connect, your sellers have their own full Stripe accounts and you transfer funds to them. With Express Connect, you create simplified Stripe accounts for your sellers that you manage. With Custom Connect, you fully control the user experience and the seller never interacts with Stripe directly. You need Connect if you are building a marketplace (Uber, Airbnb, Etsy model), a SaaS platform that processes payments on behalf of customers (Shopify, Squarespace model), or any scenario where money flows to third-party accounts that you manage.

How do I prevent fraudulent transactions?+

Stripe Radar runs on every transaction by default and blocks the most common fraud patterns. For additional protection: enable 3D Secure by setting payment_method_options.card.request_three_d_secure to 'automatic' on PaymentIntents, this shifts chargeback liability to the issuing bank for authenticated transactions. Add CAPTCHA to your checkout flow to prevent automated card testing. Use Radar rules in the Dashboard to block or flag transactions from high-risk countries, unusually high amounts, or repeated declines. Monitor your dispute rate, above 0.9% puts your account in Visa's dispute monitoring program. Respond to disputes promptly with evidence to recover revenue.

How do I migrate existing subscribers to Stripe?+

Migration depends on whether you are moving from another payment processor or importing existing subscribers. For payment method migration, Stripe has migration programs with major processors (Braintree, Authorize.net, PayPal) that transfer encrypted card data directly, customers do not re-enter their card details. Request this through Stripe support. For subscription data migration, create Stripe Customer and Subscription objects for each existing subscriber, setting the billing cycle anchor to match their existing renewal date. Use trial periods strategically to align billing cycles. Import historical invoice data using the Stripe API if needed for customer-facing billing history. Test the migration with a subset of subscribers first and monitor for issues before full rollout.