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:
| Product | Solves |
|---|---|
| Payments | Core card and wallet processing |
| Checkout | Hosted, PCI-simplified payment page |
| Billing | Subscriptions, plans, proration |
| Invoicing | One-off and recurring invoices |
| Tax | Automatic tax calculation |
| Radar | Fraud detection and rules |
| Connect | Marketplaces and payouts to sellers |
| Identity | KYC and document verification |
| Terminal | In-person card readers |
| Treasury / Issuing | Embedded 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.completedpayment_intent.succeededpayment_intent.payment_failedcharge.refundedinvoice.paidcustomer.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:
- Store money as integers in minor units. Floating point has no place in a ledger.
- 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 accessinvoice.payment_failed— enter dunning, warn the customercustomer.subscription.updated— plan change or prorationcustomer.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
| Failure | Correct response |
|---|---|
| Card declined | Ask for a different payment method |
| Authentication required | Surface the 3DS challenge |
| Network timeout | Retry with the same idempotency key |
| Duplicate webhook | Ignore silently |
Rate limited (429) | Exponential backoff |
| Unknown event type | Acknowledge 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
.envcommitted 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:
| Card | Behaviour |
|---|---|
4242 4242 4242 4242 | Success |
4000 0025 0000 3155 | Requires 3D Secure |
4000 0000 0000 9995 | Insufficient funds |
4000 0000 0000 0341 | Fails 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
- Updating orders before a verified webhook arrives.
- Assuming a successful redirect means a successful payment.
- Skipping idempotency on API calls and webhook handlers.
- Hardcoding Stripe logic into order management.
- Logging sensitive customer or card data.
- Not verifying webhook signatures.
- Ignoring asynchronous payment states like
processingandrequires_action. - Never testing duplicate or replayed events.
- Using live keys in development.
- 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
Be the first to comment.