Skip to content

Accepting Payments with Stripe

Aman Kumar Singh8 min read
Part 25 of 40From the Full Stack SaaS Masterclass series
Accepting Payments with Stripe — article by Aman Kumar Singh

In Product Analytics and Event Tracking we wired up the pipeline that tells us what users actually do inside the product. That data only matters if the product eventually makes money. So it's time to build the piece that turns usage into revenue: payments.

This is part of the Full Stack SaaS Masterclass, the series where we build a multi-tenant SaaS from an empty repo to something running in production. Everything here assumes the NestJS backend and PostgreSQL setup from earlier articles in the series.

I'm going to scope this article tightly to one-time and single-charge payments through Stripe: Checkout, Payment Intents, and the webhook plumbing that makes both trustworthy. Recurring billing, plans, upgrades, and proration are a large enough topic that they get their own article next. Trying to cover subscriptions and one-off charges in the same piece tends to produce advice that's half-right for both.

Why Stripe, and why not build it yourself

Nobody should write their own card processing in 2023. Regulation makes that decision for you. Handling raw card numbers puts you in PCI DSS scope, which means audits, network segmentation requirements, and a compliance burden that has nothing to do with your product. Stripe (and its competitors like Braintree or Adyen) absorb that scope for you as long as you never let card data touch your own servers.

The tradeoff you're accepting is dependency on a third party for your revenue. That's a real cost. Stripe has outages, disputes take their side by default in ambiguous cases, and their fee (roughly 2.9% plus a fixed amount per transaction in the US, though this varies by region and volume) is a permanent tax on every sale. For a SaaS at any reasonable stage, that tax is cheaper than the engineering and compliance cost of doing it yourself. I've never seen a serious argument for rolling your own payment rail before you have leverage to negotiate directly with a bank, which is a scale problem most companies never reach.

Stripe over the alternatives comes down to developer experience and documentation. Braintree and Adyen are both legitimate, and Adyen in particular has an edge for certain international payment methods. But Stripe's API design, webhook model, and dashboard are the ones most engineers have already worked with, which matters for hiring and for the volume of Stack Overflow answers and community tooling available when something breaks at 11pm.

Choosing an integration shape: Checkout, Elements, or Payment Intents directly

Stripe gives you three levels of control, and picking the wrong one is the single most common source of wasted implementation time.

Stripe Checkout is a hosted page you redirect the user to. Stripe owns the entire form, the validation, 3D Secure challenges, and the styling within your branding constraints. This is the right default for most SaaS products. You give up pixel-level control over the payment form in exchange for never having to think about SCA (Strong Customer Authentication) flows, card validation edge cases, or keeping up with new payment methods as Stripe adds them.

Stripe Elements embeds pre-built form components directly on your page, so the user never leaves your domain, but Stripe still handles the sensitive fields inside an iframe so card data never touches your server. This is the right choice when checkout conversion matters enough that a redirect is a measurable loss, which is common for consumer products with high cart abandonment. For most B2B SaaS, the conversion difference doesn't justify the extra frontend work.

Payment Intents API directly means you build your own form and call Stripe's API for each state transition yourself. This gives you full control and is what powers both of the above under the hood, but you're now responsible for handling every intermediate state (requires_action, requires_confirmation, processing) correctly. I'd only reach for this if Checkout and Elements genuinely can't express a flow you need, because the failure modes here are almost always self-inflicted.

Default to Checkout until a specific product requirement forces you off it. Here's a minimal server-side session creation in a NestJS service:

// billing/checkout.service.ts
import { Injectable } from '@nestjs/common';
import Stripe from 'stripe';

@Injectable()
export class CheckoutService {
  private readonly stripe: Stripe;

  constructor() {
    this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
      apiVersion: '2023-08-16',
    });
  }

  async createSessionForOneTimeCharge(params: {
    tenantId: string;
    amountCents: number;
    currency: string;
    successUrl: string;
    cancelUrl: string;
  }): Promise<Stripe.Checkout.Session> {
    return this.stripe.checkout.sessions.create({
      mode: 'payment',
      payment_method_types: ['card'],
      line_items: [
        {
          price_data: {
            currency: params.currency,
            unit_amount: params.amountCents,
            product_data: { name: 'One-time credit purchase' },
          },
          quantity: 1,
        },
      ],
      success_url: params.successUrl,
      cancel_url: params.cancelUrl,
      // Carries tenant context through to the webhook, since Stripe
      // has no idea what a "tenant" is.
      client_reference_id: params.tenantId,
      metadata: { tenantId: params.tenantId },
    });
  }
}

Notice client_reference_id and metadata. Stripe's objects know nothing about your data model, so anything you need back when a webhook fires has to be stamped onto the object at creation time. This is the single detail people forget, then wonder why their webhook handler has no idea which tenant just paid.

Webhooks are the source of truth, not the redirect

The temptation after building a Checkout flow is to trust the success_url redirect as proof of payment. Don't. A user can close the tab before the redirect fires, get redirected by a flaky network, or in rare cases have a payment that settles asynchronously after the redirect already happened (some payment methods, like certain bank debits, aren't instant). The redirect is a UX nicety, not a state transition.

The webhook is the only event you should treat as authoritative. Stripe sends a signed POST request to an endpoint you register, and you verify that signature before trusting anything in the payload. This is also the part of the integration where I've seen the most subtle bugs, almost always because the raw request body gets consumed by a body-parsing middleware before the signature check runs. Stripe's signature verification needs the exact raw bytes of the request; a re-serialized JSON body will fail verification even if the content looks identical.

// billing/stripe-webhook.controller.ts
import {
  Controller,
  Post,
  Req,
  Res,
  Headers,
  RawBodyRequest,
} from '@nestjs/common';
import { Request, Response } from 'express';
import Stripe from 'stripe';
import { BillingService } from './billing.service';

@Controller('webhooks/stripe')
export class StripeWebhookController {
  private readonly stripe: Stripe;

  constructor(private readonly billingService: BillingService) {
    this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
      apiVersion: '2023-08-16',
    });
  }

  @Post()
  async handle(
    @Req() req: RawBodyRequest<Request>,
    @Res() res: Response,
    @Headers('stripe-signature') signature: string,
  ): Promise<void> {
    let event: Stripe.Event;

    try {
      event = this.stripe.webhooks.constructEvent(
        req.rawBody as Buffer,
        signature,
        process.env.STRIPE_WEBHOOK_SECRET as string,
      );
    } catch (err) {
      res.status(400).send(`Webhook signature verification failed`);
      return;
    }

    // Idempotency: Stripe retries webhooks on any non-2xx response,
    // and can also send the same event more than once in normal operation.
    const alreadyProcessed = await this.billingService.hasProcessedEvent(
      event.id,
    );
    if (alreadyProcessed) {
      res.status(200).send();
      return;
    }

    switch (event.type) {
      case 'checkout.session.completed':
        await this.billingService.fulfillCheckoutSession(
          event.data.object as Stripe.Checkout.Session,
        );
        break;
      case 'payment_intent.payment_failed':
        await this.billingService.recordFailedPayment(
          event.data.object as Stripe.PaymentIntent,
        );
        break;
      default:
        break;
    }

    await this.billingService.markEventProcessed(event.id);
    res.status(200).send();
  }
}

Two things in that handler matter more than they look. First, the route needs raw body access, which in a NestJS app usually means excluding this specific path from the global JSON body parser or configuring the raw body flag Nest exposes, rather than trying to re-stringify a parsed object back into bytes. Second, the idempotency check against event.id is not optional. Stripe explicitly documents that webhooks can be delivered more than once, and your handler has to be safe to run twice with the same event.

Storing state your own database can reason about

Stripe is not your database. It's tempting to treat Stripe's dashboard as the record of truth and query it live when you need to know if a tenant paid, but that couples your application's control flow to a third-party API's uptime and latency on every request. Mirror the state you care about into your own tables, updated by webhooks, and query your own database in the request path.

create table payments (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references tenants(id),
  stripe_checkout_session_id text unique,
  stripe_payment_intent_id text,
  amount_cents integer not null,
  currency text not null,
  status text not null check (status in ('pending', 'succeeded', 'failed')),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create table processed_stripe_events (
  event_id text primary key,
  processed_at timestamptz not null default now()
);

The processed_stripe_events table is the idempotency guard from the webhook handler above, backed by a real unique constraint rather than an in-memory check that disappears on deploy. It's a small table, cheap to keep, and it turns "did we already handle this" into a single indexed lookup instead of a race condition.

Production pitfalls worth knowing before they bite

A few failure modes show up often enough to call out directly. Use test mode keys and Stripe's test card numbers in every environment except production. Stripe makes it hard to accidentally charge a real card while in test mode, but not impossible if a live key leaks into a lower environment. Rotate and store the STRIPE_WEBHOOK_SECRET per environment, since each registered webhook endpoint gets its own secret. Also handle payment_intent.payment_failed explicitly: a checkout that starts and fails silently, with no record of the attempt, makes support tickets impossible to debug. And use the Stripe CLI's stripe listen --forward-to command during local development, so you're testing the actual webhook flow instead of assuming your handler works because the happy path in the dashboard looked fine.

Key takeaways

  • Let Stripe absorb PCI scope; never let raw card data touch your own servers, and default to Checkout unless a real product requirement forces Elements or a direct Payment Intents integration.
  • Webhooks, not the success redirect, are the authoritative signal that a payment completed. Treat the redirect as a UX nicety only.
  • Stamp `metadata` or `client_reference_id` onto Stripe objects at creation time; Stripe has no concept of your tenants, so you have to carry that context yourself.
  • Verify webhook signatures against the raw request body, before any JSON body-parsing middleware touches it.
  • Store an idempotency record per processed event ID; Stripe can and does deliver the same webhook more than once.
  • Mirror payment state into your own database via webhooks rather than querying Stripe live in the request path.

Frequently asked questions

Do I need to be PCI compliant to accept payments with Stripe?

Using Stripe Checkout or Elements keeps raw card data off your servers entirely, which qualifies you for the simplest PCI compliance tier (SAQ A), mostly a short self-assessment questionnaire rather than a full audit. Building your own card form without Elements changes that significantly.

Why did my Stripe webhook signature verification fail?

The most common cause is a body-parsing middleware consuming and re-serializing the request body before your webhook handler sees it. Stripe's signature is computed over the exact raw bytes sent, so the webhook route needs access to the unparsed body, not a JSON object reconstructed from it.

Should I trust the success_url redirect as proof of payment?

No. Treat it as a UX signal only. Some payment methods settle asynchronously, and a user can close the browser before the redirect completes. Only mark a payment as succeeded when the corresponding webhook event arrives and its signature verifies.

How do I test Stripe payments locally without a public URL?

Use the Stripe CLI's `stripe listen --forward-to localhost:3000/webhooks/stripe` command. It forwards real webhook events from your Stripe test account to your local server, so you're testing the actual event flow instead of assuming your handler works from the dashboard alone.

What's the difference between Stripe Checkout and Payment Intents?

Checkout is a hosted, Stripe-built page that handles the entire payment form, 3D Secure, and styling for you. Payment Intents is the lower-level API that Checkout itself is built on; using it directly means building your own form and manually handling every intermediate payment state.

Should I use Stripe for subscriptions too, or something else?

Stripe Billing handles subscriptions well and reuses the same Payment Intents and webhook infrastructure covered here, so there's little reason to introduce a second payment provider just for recurring charges. The next article in this series covers subscription plans and billing in detail.

Related articles

Security Best Practices — Aman Kumar Singh
API Documentation with Swagger — Aman Kumar Singh
Rate Limiting and Throttling — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.