Skip to main content
Foundations progress
beginnerPage 44 of 50

Payments: Stripe, subscriptions, webhooks, PCI scope

How to actually take money on the internet — Stripe (and friends), the difference between charges/subscriptions/Connect, idempotent webhooks, dunning, PCI scope, and the failure modes that cost the most.

Payments: Stripe, subscriptions, webhooks, PCI scope

In one line: Payments is one of those domains where the framework (Stripe, mostly) handles the hard parts but the integration details — idempotency, webhook reliability, subscription lifecycle, refunds, disputes, PCI scope — are all yours, and getting any of them wrong shows up as missed revenue, angry customers, or a regulator on the phone.

In plain English

You almost certainly use Stripe (or Stripe-shaped competitors: Adyen, Paddle, Braintree, Lemon Squeezy). They handle the actual card processing, fraud detection, PCI compliance, bank routing. Your code does: collect payment method via their JS widget (Elements/Checkout), call their API to create charges or subscriptions, listen to their webhooks for state changes, store the minimum (Stripe customer ID, subscription ID, payment method ID — never raw card numbers). Get the webhook handling right and you can sleep at night. Get it wrong and you've quietly charged someone twice or failed to revoke a churned customer's access.

This page is the working developer's tour. The product choices (pricing tiers, free trials, dunning policy) live elsewhere; here we cover what the code has to do.

The actors

  • Customer's browser talks to Stripe directly to collect the card. Card data never touches your server.
  • Your server talks to Stripe's API to create charges, subscriptions, refunds.
  • Stripe sends webhooks back when state changes (charge succeeded, subscription canceled, dispute opened).
  • Your DB mirrors just enough Stripe state to answer "is this user subscribed?"

Two architectural rules:

  1. Stripe is the source of truth for payment state. Your DB is a cache. When in doubt, query Stripe.
  2. Webhooks are how Stripe tells you about async state changes — failed renewals, disputes, payouts. If you ignore them, your DB drifts from reality.

PCI scope: why the browser talks to Stripe directly

PCI DSS is the credit-card industry compliance standard. The cost of compliance scales with how much you touch raw card data:

Your codePCI scopeCompliance effort
Card number passes through your serversHighest (SAQ D)Quarterly scans, annual audits, network segmentation, big effort
Card number entered in iframe Stripe controlsLowest (SAQ A)Annual self-assessment, minimal effort

The whole point of Stripe Elements / Checkout / Payment Element is to keep raw card data out of your servers. Your browser code tokenizes the card with Stripe; Stripe gives you a pm_… payment method ID; that's all you store/handle. PCI scope stays at SAQ A.

Don't roll your own card form. Use Stripe Elements (Payment Element is the 2026 default — it auto-handles cards, Apple/Google Pay, BNPL, regional methods).

The basic charge flow

The simplest case: one-time payment.

// Step 1: server creates a PaymentIntent
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
const { amount, currency } = await req.json();
const intent = await stripe.paymentIntents.create({
amount, // in smallest unit — cents for USD, yen for JPY
currency,
automatic_payment_methods: { enabled: true },
metadata: { user_id: getUserId(req) },
}, {
idempotencyKey: req.headers.get('Idempotency-Key') ?? undefined,
});
return Response.json({ clientSecret: intent.client_secret });
}
// Step 2: browser confirms with Stripe directly (using clientSecret)
import { loadStripe } from '@stripe/stripe-js';
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';

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

function CheckoutForm({ clientSecret }) {
const stripe = useStripe();
const elements = useElements();

const handleSubmit = async () => {
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: 'https://example.com/order/complete' },
});
if (error) showError(error.message);
};

return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<PaymentElement />
<button onClick={handleSubmit}>Pay</button>
</Elements>
);
}
// Step 3: Stripe sends webhook when charge succeeds
// POST /api/webhooks/stripe
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature')!;
const body = await req.text();
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!,
);

switch (event.type) {
case 'payment_intent.succeeded': {
const intent = event.data.object;
await fulfillOrder(intent.metadata.user_id, intent.amount);
break;
}
case 'payment_intent.payment_failed': {
// log, notify, etc.
break;
}
}
return new Response('ok');
}

Three things to notice: signature verification on every webhook, automatic_payment_methods lets Stripe pick which methods to show, fulfillment happens on the webhook, not on the browser's success redirect (the redirect can be missed if the user closes the tab).

Subscriptions

The flow for recurring billing is similar but with Customer, Product, Price, and Subscription objects:

// 1. Create or find a Customer (one per user)
const customer = await stripe.customers.create({
email: user.email,
metadata: { user_id: user.id },
});
await db.users.update({ id: user.id, data: { stripe_customer_id: customer.id } });

// 2. Create a Subscription
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: 'price_PRO_MONTHLY' }],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent'],
});

// 3. Confirm payment in browser using the resulting payment intent's clientSecret
// (same as one-time)

// 4. Webhooks tell you about subscription lifecycle

Critical subscription events to handle in webhooks:

EventWhat it meansYour action
customer.subscription.createdNew subMark user as active, grant entitlements
customer.subscription.updatedPlan change, status changeUpdate entitlements
customer.subscription.deletedSub canceledRevoke entitlements (often at period end)
invoice.paidRenewal payment succeededUpdate "paid through" date
invoice.payment_failedRenewal failedStart dunning flow
invoice.payment_action_requiredSCA / 3DS challengeSend the user an email to confirm
customer.subscription.trial_will_end3 days before trial endSend a reminder

"Active" is more nuanced than you think

A subscription has a status field. Don't treat "subscribed user" as "user has a non-null subscription." Treat it as the subscription's status:

StatusActive?
trialingYes — full access
activeYes
past_dueUsually yes — still in dunning, give them time to pay
unpaidNo — dunning exhausted
canceledNo (after current period ends if cancel_at_period_end)
incompleteNo — payment never confirmed
incomplete_expiredNo
pausedNo (or partial — Stripe's new feature)

A common bug: revoking access immediately on cancel_at_period_end. Users paid for the rest of the month; they should keep access through current_period_end.

Webhooks: the most important integration detail

Webhooks are how Stripe tells you about asynchronous state. Get them right and your system is reliable. Get them wrong and you have ghost subscriptions, double-charges, and confused customers.

Verify the signature, always

const event = stripe.webhooks.constructEvent(
rawBody, // raw request body, NOT JSON-parsed
req.headers.get('stripe-signature'),
process.env.STRIPE_WEBHOOK_SECRET, // signing secret from dashboard
);

Without this, anyone can POST /api/webhooks/stripe with fake events. (See Secrets & API keys for the analogous attack on every webhook provider.)

The raw body matters: some frameworks parse JSON automatically and break signature verification. In Next.js App Router: await req.text() first, parse later if needed.

Handle idempotency on your side

Stripe redelivers webhooks if you don't respond 2xx within a few seconds. The same event ID may arrive multiple times.

async function handleEvent(event: Stripe.Event) {
// Atomic insert; if event was already processed, this fails the unique constraint
try {
await db.processedEvents.create({
data: { stripe_event_id: event.id, type: event.type },
});
} catch (e) {
if (isUniqueViolation(e)) return; // already processed
throw e;
}

// ...handle the event...
}

A processed_events table with a unique constraint on stripe_event_id is the standard pattern. Insert first, then process — guarantees no duplicate side effects.

Acknowledge fast, process async if slow

Stripe expects a 2xx within ~10s. If your handler takes longer (sending email, calling other services), enqueue the work and ACK immediately.

export async function POST(req: Request) {
const event = verifyAndParse(req);
await db.processedEvents.create({ data: { stripe_event_id: event.id } });
await queue.publish('stripe.event', event); // enqueue
return new Response('ok'); // ACK immediately
}

The worker reads from the queue, does the work, retries on failure (which is now its problem, not Stripe's).

Replay endpoint

Build a small admin endpoint that can replay any Stripe event ID through your handler. When something goes wrong in production ("we never processed the subscription.created webhook"), you fetch the event from Stripe's API and replay it. Saves manual SQL fixups.

Test locally

stripe listen --forward-to localhost:3000/api/webhooks/stripe — the Stripe CLI forwards real webhooks to your dev server. You can also stripe trigger payment_intent.succeeded to fire synthetic events.

Dunning: handling failed renewals

When a renewal fails (expired card, insufficient funds, fraud block), the subscription goes past_due. Stripe will retry the charge for ~3 weeks (configurable via the dashboard's "Smart Retries" or Schedule).

Your job: communicate with the user. Stripe sends emails by default, but a good integration:

  1. Send a custom email on first failure, with a link to Stripe's Customer Portal (or your own) to update payment method.
  2. In-app banner — "your payment failed; update card."
  3. Retry schedule — Stripe handles retries; you don't need to.
  4. After dunning exhausted (status: unpaid or canceled), revoke access and send a "your subscription has ended" email.
  5. Win-back flows — for users who churned, send promo offers, surveys, etc.

Don't immediately yank access on first failure. Give Stripe time to retry; give the user time to react. 7-21 days is typical.

Refunds and disputes

Refunds

await stripe.refunds.create({
payment_intent: 'pi_...',
// amount optional — defaults to full
});

Listen for charge.refunded webhook → revoke whatever was granted.

For subscriptions: a partial refund + canceling the subscription is the common "I want my money back" flow. Stripe doesn't auto-prorate refunds on cancellation; you script it.

Disputes (chargebacks)

Customer disputes a charge with their bank. Stripe takes the money back from your account, often charges you a $15 fee, and gives you a few days to respond with evidence.

Webhook: charge.dispute.created. Your handler:

  1. Revoke access (the user explicitly disputed; they don't want it).
  2. Notify your team.
  3. Gather evidence (logs of usage, IP addresses, receipts emailed, consent screenshots).
  4. Submit via Stripe dashboard or API.

Win rate on disputes is low (~30% across the industry). Prevention beats remediation: clear billing descriptors (COMPANY*PRODUCT), explicit consent screens, immediate receipts, prompt customer support response.

If your dispute rate exceeds ~1% of transactions, you're at risk of being put in monitoring programs (Visa's VAMP, Mastercard's MCP) which carry steep fines and can ultimately lose you payment processing.

Saving payment methods and SCA / 3DS

Strong Customer Authentication (SCA) is an EU rule (2019, broadened since). Most cards require a 3DS challenge — the user sees a bank-side authentication step ("enter the code from your bank app") on first charge.

Stripe handles this. Your code:

  • Use PaymentIntents (not deprecated Charges).
  • For saved-card-then-charge-later flows, use SetupIntent to attach the card (with SCA done up front), then create future PaymentIntents with off_session: true.
  • If off-session payment requires authentication (rare but happens), webhook invoice.payment_action_required fires — email the user to come back and confirm.

Saving a card for later

// One-time: collect with SetupIntent
const setupIntent = await stripe.setupIntents.create({
customer: customer.id,
payment_method_types: ['card'],
});
// browser: confirmSetup(...) — does SCA if needed

Later, charge:

const intent = await stripe.paymentIntents.create({
amount: 1000,
currency: 'usd',
customer: customer.id,
payment_method: 'pm_...', // saved
off_session: true,
confirm: true,
});
// If SCA required: catch error code, send "come back" email

Tax and invoicing

Stripe Tax handles automatic tax calculation (VAT in EU, GST in AU, sales tax in US states with nexus). Worth turning on once you sell internationally — manual tax handling is painful.

For B2B: customer-supplied VAT IDs (tax_ids) → reverse charge, no VAT collected. Stripe validates and applies automatically.

Invoices: Stripe generates them; you can customize logo, address, footer, and stack with your own invoice service if needed.

Marketplaces and Stripe Connect

If you're a marketplace (collect money from buyers, pay sellers minus a cut), you need Stripe Connect:

  • Standard accounts — sellers have their own Stripe accounts; you control payouts.
  • Express accounts — sellers onboarded via Stripe-hosted flow, you control most settings.
  • Custom accounts — you fully control UX; you're responsible for compliance.

Connect handles KYC (Know Your Customer) — sellers verify their identity to receive money. Required by law everywhere.

Connect is its own product surface. Block out a few weeks for serious integration; don't underestimate.

Alternative processors

ProcessorStrengths
StripeDefault. Best DX, broad coverage.
AdyenEnterprise, more direct rails to networks, often cheaper at scale.
Braintree (PayPal)Older, still solid; integrates PayPal natively.
Paddle / Lemon Squeezy"Merchant of record" — they handle taxes and VAT for you globally. Smaller integrators love them.
PayPal directlyFor PayPal/Venmo support if Stripe's PayPal isn't enough.
Adyen for Platforms / Stripe ConnectMarketplaces.
MercadoPago, RazorpayRegional (LatAm, India).
Crypto (Coinbase Commerce, BitPay)Niche; legal/tax complexity.

For a starter SaaS: Stripe. For "I don't want to deal with VAT and tax compliance globally": a Merchant of Record (Paddle, Lemon Squeezy) — they take a higher cut (~5%) but handle the tax mess.

Common mistakes

Where people commonly trip up
  • Fulfilling orders from the browser success redirect, not the webhook. User closes the tab during redirect → never fulfilled. Fulfillment must come from the payment_intent.succeeded webhook.
  • Not verifying webhook signatures. Anyone with the URL can POST fake events. Always constructEvent; always pass the raw body.
  • Webhook handler processes events twice. Stripe retries. Without an idempotency check (processed_events table), every retry is a duplicate side effect.
  • Storing raw card numbers / CVV. Massive PCI scope expansion + legal liability. Use Stripe Elements; never see the raw card.
  • NEXT_PUBLIC_STRIPE_SECRET_KEY or equivalent. Use the publishable key (pk_…) in the browser; the secret key (sk_…) only on the server.
  • Building a custom card form to match your design. PCI scope rockets up. Use Stripe's Payment Element; style it with their CSS hooks.
  • Treating subscription status === 'active' as the only "is subscribed" check. trialing and (often) past_due are also active for entitlements. Centralize "is user subscribed" logic.
  • Yanking access immediately on cancel. User paid for the rest of the period. Revoke at current_period_end, not on cancellation.
  • No dunning emails. Payment fails → Stripe quietly retries → user never knew until access disappears. Send emails on first failure with a "update card" link.
  • Stripe customer IDs not stored. Every operation requires a customer ID. Create it on user signup or first payment; persist it on the user row.
  • Manual invoice generation duplicating Stripe. Stripe creates invoices automatically for subscriptions. Don't build a parallel system; customize Stripe's invoice templates.
  • Testing against production. Use test mode keys + test cards (4242 4242 4242 4242). Wire your CI/staging to test mode; only prod talks to live mode.
  • No webhook replay tool. Something goes wrong, you want to re-run an event — build an admin endpoint that fetches an event by ID and runs it through your handler. Saves hours during incidents.
  • Charging different currencies without specifying amount in the smallest unit. USD: cents. JPY: yen (no fractional). EUR: cents. SAR: halala. Read each currency's amount rules in Stripe docs; mixing them = wildly wrong charges.
  • Free trial without payment method. Allowed (Stripe supports it), but increases fraud and trial abuse. Most B2B SaaS requires a payment method at trial start.
  • No "Customer Portal" link. Users can't update their card, see invoices, or cancel. Stripe's hosted Customer Portal is one config + one link.
  • Auto-renewal without clear UX. Users think they had a one-time charge, get billed again, dispute. EU and California laws require explicit auto-renewal disclosure.

Page checkpoint

Checkpoint Quiz

Did payments stick?

Required

What's next

→ Continue to Email deliverability.