Stripe Payment Integration with NestJS: The Complete Production Guide (2026 Edition)

Why webhooks — not redirects — are the source of truth, and how to build a payment module that survives the real world.

"Taking a payment isn't just calling an API."

Most developers believe integrating Stripe means creating a Checkout Session, redirecting the user, and marking the order as paid.

It doesn't.

Behind every successful payment sits an ecosystem of authentication, idempotency, webhooks, retries, security, event handling, database design, reconciliation and production monitoring. An integration that works flawlessly on localhost can quietly lose money in production — double-charging customers, shipping unpaid orders, or silently dropping events at 3 AM.

This guide goes beyond the official documentation. It explains why each architectural decision matters, and how to build a production-grade payment module with NestJS.


Why Stripe

Stripe is one of the world's most widely adopted payment platforms. It handles:

  • Credit and debit cards
  • Apple Pay and Google Pay
  • Bank transfers and wallets
  • Subscriptions and invoicing
  • Marketplace and split payments
  • 135+ currencies

Instead of integrating directly with every bank and card network, your application talks to Stripe. Stripe talks to the financial institutions. That single layer of indirection is the entire value proposition.


The Stripe Ecosystem

Most developers only know Stripe Checkout. Stripe is actually dozens of products:

ProductSolves
PaymentsCore card and wallet processing
CheckoutHosted, PCI-simplified payment page
BillingSubscriptions, plans, proration
InvoicingOne-off and recurring invoices
TaxAutomatic tax calculation
RadarFraud detection and rules
ConnectMarketplaces and payouts to sellers
IdentityKYC and document verification
TerminalIn-person card readers
Treasury / IssuingEmbedded finance and card issuing

Choosing the right product first prevents months of unnecessary complexity later.


How a Payment Actually Flows

Customer
   ↓
Frontend
   ↓
NestJS Backend
   ↓
Stripe API
   ↓
Customer Bank  →  Authorization  →  Capture
   ↓
Webhook
   ↓
NestJS Backend
   ↓
Database Update
   ↓
Frontend Notification

Notice the critical detail: the frontend never confirms a payment. Your backend does — and only after Stripe tells it so through a verified webhook.


API Keys and Secrets

STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxx
  • The publishable key belongs in the frontend.
  • The secret key must never leave your backend. Never commit it. Never log it. Never send it to a browser.
  • The webhook secret is what makes incoming events trustworthy.

Rotate keys on a schedule, and use separate keys per environment — production keys in a staging environment is one of the most common (and most expensive) mistakes in this field.


NestJS Project Structure

Payments deserve their own bounded context. Do not scatter Stripe calls through OrderService.

src/
  modules/
    payment/
      controllers/
        payment.controller.ts
        webhook.controller.ts
      services/
        stripe.service.ts
        payment.service.ts
        webhook.service.ts
      dto/
      entities/
      interfaces/
      events/
      guards/
      payment.module.ts

The rest of the application should know about payments, not about Stripe.


The Stripe Client Provider

Initialise the SDK once and inject it everywhere.

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';

@Injectable()
export class StripeService {
  public readonly client: Stripe;

  constructor(private readonly config: ConfigService) {
    this.client = new Stripe(this.config.getOrThrow<string>('STRIPE_SECRET_KEY'), {
      apiVersion: '2026-01-31',
      maxNetworkRetries: 2,
      timeout: 15_000,
      telemetry: false,
    });
  }
}

maxNetworkRetries matters: Stripe's SDK retries with the same idempotency key, so retries are safe by design.


Checkout Sessions

Stripe Checkout is the fastest path to production. Stripe hosts the page, handles PCI scope, wallets, 3D Secure and mobile optimisation.

@Injectable()
export class PaymentService {
  constructor(
    private readonly stripe: StripeService,
    private readonly payments: PaymentRepository,
  ) {}

  async createCheckoutSession(orderId: string, userId: string) {
    const order = await this.orders.findOwnedOrFail(orderId, userId);

    // NEVER take the amount from the client.
    const session = await this.stripe.client.checkout.sessions.create(
      {
        mode: 'payment',
        client_reference_id: order.id,
        customer_email: order.customerEmail,
        line_items: order.items.map((item) => ({
          quantity: item.quantity,
          price_data: {
            currency: order.currency,
            unit_amount: item.unitAmountMinor, // integer, minor units
            product_data: { name: item.name },
          },
        })),
        metadata: { orderId: order.id, userId },
        success_url: `${this.config.get('APP_URL')}/orders/${order.id}?status=processing`,
        cancel_url: `${this.config.get('APP_URL')}/orders/${order.id}?status=cancelled`,
      },
      { idempotencyKey: `checkout:${order.id}` },
    );

    await this.payments.recordSessionCreated(order.id, session.id);
    return { url: session.url };
  }
}

Two details make this production-grade: the amount is derived server-side from the order, and the request carries a deterministic idempotency key.


Payment Intents

For custom, embedded checkout UIs, use Payment Intents. A Payment Intent models the entire lifecycle of a payment — not a single API call.

requires_payment_method
        ↓
requires_confirmation
        ↓
requires_action        (3D Secure / SCA)
        ↓
processing
        ↓
requires_capture       (if capture_method = manual)
        ↓
succeeded  |  canceled
const intent = await this.stripe.client.paymentIntents.create(
  {
    amount: order.totalMinor,
    currency: order.currency,
    automatic_payment_methods: { enabled: true },
    metadata: { orderId: order.id },
  },
  { idempotencyKey: `pi:${order.id}` },
);

return { clientSecret: intent.client_secret };

The frontend receives only the client_secret — never the secret key — and confirms the payment with Stripe.js. Understanding these states is what separates a five-minute debugging session from a five-hour one.


Never Trust the Redirect

A large share of broken integrations share one line of code: updating the order inside the success_url handler.

Users close the browser. Mobile networks drop. Apps get killed. Pages get refreshed twice. A redirect is a UX hint, not a financial fact.

// ❌ Wrong
@Get('success')
async success(@Query('orderId') orderId: string) {
  await this.orders.markPaid(orderId); // completely unverified
}

Mark orders paid in exactly one place: your verified webhook handler.


Webhooks and Signature Verification

Stripe delivers events such as:

  • checkout.session.completed
  • payment_intent.succeeded
  • payment_intent.payment_failed
  • charge.refunded
  • invoice.paid
  • customer.subscription.deleted

These events are your source of truth. And every one must be cryptographically verified — otherwise anyone can POST "payment successful" to your endpoint and receive free products.

NestJS needs the raw body for verification, so exclude the webhook route from the JSON parser:

// main.ts
const app = await NestFactory.create(AppModule, { rawBody: true });
app.use('/webhooks/stripe', express.raw({ type: 'application/json' }));
@Controller('webhooks/stripe')
export class WebhookController {
  constructor(
    private readonly stripe: StripeService,
    private readonly webhooks: WebhookService,
  ) {}

  @Post()
  @HttpCode(200)
  async handle(@Req() req: RawBodyRequest<Request>, @Headers('stripe-signature') sig: string) {
    let event: Stripe.Event;

    try {
      event = this.stripe.client.webhooks.constructEvent(
        req.rawBody,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET,
      );
    } catch (err) {
      throw new BadRequestException('Invalid signature');
    }

    await this.webhooks.process(event); // must be idempotent
    return { received: true };
  }
}

Return 2xx fast. Do the heavy lifting in a queue — if Stripe times out, it retries, and retries are only safe when your handler is idempotent.


Idempotency

Stripe will deliver the same event twice. Networks partition, ACKs get lost, and retries happen for days.

Should a duplicate payment_intent.succeeded ship a second product? Send a second invoice? Obviously not.

Record the event ID before processing, inside the same transaction as your business change:

async process(event: Stripe.Event) {
  const inserted = await this.events.insertIfNew(event.id, event.type, event.data);
  if (!inserted) return; // already processed — safely ignore

  await this.db.transaction(async (tx) => {
    switch (event.type) {
      case 'checkout.session.completed':
        await this.fulfil(tx, event.data.object as Stripe.Checkout.Session);
        break;
      case 'charge.refunded':
        await this.applyRefund(tx, event.data.object as Stripe.Charge);
        break;
      default:
        break; // unhandled types are not errors
    }
    await this.events.markProcessed(tx, event.id);
  });
}

A unique index on event_id turns a race condition into a no-op. That single constraint is worth more than a thousand lines of defensive code.


Database Design

CREATE TABLE payments (
  id                       UUID PRIMARY KEY,
  order_id                 UUID NOT NULL,
  customer_id              UUID NOT NULL,
  stripe_payment_intent_id TEXT UNIQUE,
  stripe_session_id        TEXT UNIQUE,
  status                   TEXT NOT NULL,
  currency                 CHAR(3) NOT NULL,
  amount_minor             BIGINT NOT NULL,
  provider                 TEXT NOT NULL DEFAULT 'stripe',
  created_at               TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at               TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE payment_events (
  event_id     TEXT PRIMARY KEY,   -- Stripe's evt_...
  event_type   TEXT NOT NULL,
  payload      JSONB NOT NULL,
  processed    BOOLEAN NOT NULL DEFAULT false,
  processed_at TIMESTAMPTZ
);

Two rules that never bend:

  1. Store money as integers in minor units. Floating point has no place in a ledger.
  2. Never delete payment history. Financial records are append-only; correct with new rows, not DELETE.

Refunds

Refunds are their own workflow, not a status flip.

Payment → Refund Requested → Stripe Refund → charge.refunded webhook → DB updated → Customer notified

Track refunds in a separate table with their own amounts and statuses. Partial refunds, multiple refunds against one charge, and disputes all become trivial when refunds are first-class records — and impossible when they are a mutated status column.


Subscriptions

Stripe Billing adds a recurring lifecycle:

Customer → Subscription → Invoice → Payment → Renewal → Webhook

React to events; never poll Stripe on a timer:

  • invoice.paid — extend access
  • invoice.payment_failed — enter dunning, warn the customer
  • customer.subscription.updated — plan change or proration
  • customer.subscription.deleted — revoke access at period end

Entitlements should be derived from your own database, updated by webhooks — so a Stripe outage never locks your paying customers out.


Error Handling

FailureCorrect response
Card declinedAsk for a different payment method
Authentication requiredSurface the 3DS challenge
Network timeoutRetry with the same idempotency key
Duplicate webhookIgnore silently
Rate limited (429)Exponential backoff
Unknown event typeAcknowledge with 200, do nothing
try {
  await this.stripe.client.paymentIntents.create(params, { idempotencyKey: key });
} catch (err) {
  if (err instanceof Stripe.errors.StripeCardError) {
    throw new BadRequestException(err.message); // safe to show the user
  }
  if (err instanceof Stripe.errors.StripeConnectionError) {
    throw new ServiceUnavailableException('Payment provider unreachable');
  }
  this.logger.error({ code: err.code, requestId: err.requestId }, 'stripe_error');
  throw new InternalServerErrorException();
}

Security Best Practices

Always

  • ✅ Verify webhook signatures
  • ✅ Serve every endpoint over HTTPS
  • ✅ Keep secrets in a secret manager, not in .env committed to Git
  • ✅ Rotate API keys regularly
  • ✅ Compute amounts server-side from your own records
  • ✅ Log identifiers (payment ID, event ID, request ID)

Never

  • ❌ Store raw card data
  • ❌ Trust a frontend-reported payment status
  • ❌ Expose the secret key
  • ❌ Ignore webhook retries
  • ❌ Log full webhook payloads containing customer PII

Monitoring and Observability

Log — with structure, not string concatenation:

  • Payment ID, Stripe event ID, request ID
  • Customer ID and order ID
  • Status transitions with timestamps
  • Webhook processing latency
  • Refunds and disputes
  • Every handler error

Alert on: webhook processing failures, events stuck unprocessed for more than a few minutes, and any divergence between Stripe's balance transactions and your database. Reconciliation is not optional at scale — it is how you discover the bugs your tests never caught.


Testing and the Stripe CLI

stripe login
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded

The CLI forwards, inspects, replays and triggers events — no tunnels, no public exposure of your laptop.

Test cards worth memorising:

CardBehaviour
4242 4242 4242 4242Success
4000 0025 0000 3155Requires 3D Secure
4000 0000 0000 9995Insufficient funds
4000 0000 0000 0341Fails after attaching

Test the failure paths as thoroughly as the happy path. Production is mostly failure paths.


Scaling: A Provider Abstraction

As your product grows — especially in markets like Ethiopia, where local rails matter — avoid coupling Stripe specifics into business logic.

PaymentProvider (interface)
 ├── StripeProvider
 ├── TelebirrProvider
 ├── ChapaProvider
 ├── SantimPayProvider
 └── FutureProvider
export interface PaymentProvider {
  readonly name: string;
  createCheckout(input: CheckoutInput): Promise<CheckoutResult>;
  verifyWebhook(raw: Buffer, signature: string): ProviderEvent;
  refund(input: RefundInput): Promise<RefundResult>;
}

Your OrderService depends on the interface. Adding a provider becomes a new class — not a refactor of the whole codebase.


Common Mistakes

  1. Updating orders before a verified webhook arrives.
  2. Assuming a successful redirect means a successful payment.
  3. Skipping idempotency on API calls and webhook handlers.
  4. Hardcoding Stripe logic into order management.
  5. Logging sensitive customer or card data.
  6. Not verifying webhook signatures.
  7. Ignoring asynchronous payment states like processing and requires_action.
  8. Never testing duplicate or replayed events.
  9. Using live keys in development.
  10. Treating payment processing as synchronous.

Production Checklist

  • Secrets stored in a manager, rotated on a schedule
  • HTTPS everywhere
  • Webhook signature verification enabled
  • Idempotency on payment creation and webhook processing
  • Structured logging and dashboards
  • Automated tests for success, decline, 3DS and duplicate events
  • Refund workflow implemented and tested
  • Alerting on failed webhook processing
  • Daily reconciliation against Stripe balance transactions
  • Runbook documented for the on-call engineer

Final Thoughts

Stripe is not a payment API. It is a platform for building reliable financial workflows.

The quality of your integration is not measured by how fast you can redirect a customer to a checkout page. It is measured by how your system behaves when networks fail, when customers double-click "Pay", when webhooks arrive twice, and when a payment needs asynchronous confirmation hours later.

Combine NestJS, idempotent APIs, verified webhooks, clear domain boundaries and real observability, and you get a payment system that is resilient, secure and maintainable.

The best payment integrations aren't the ones that work in a happy-path demo. They're the ones that keep working when the real world gets messy.


What's Next

  • Building a payment gateway abstraction in NestJS
  • Implementing idempotency end-to-end
  • The transactional outbox pattern
  • Webhook architecture at scale
  • CQRS and event sourcing for financial systems
  • Designing a double-entry ledger
  • Refunds, reversals and chargebacks explained
  • Observability for payment systems with OpenTelemetry
  • Multi-tenant payment platforms
  • Unifying Stripe, Telebirr, Chapa and SantimPay behind one interface

Master these, and you stop integrating payments — you start designing payment systems.

Comments · 0

Sign in to join the conversation.

Be the first to comment.