Implementación de facturación de Stripe: suscripciones, facturas y webhooks
Stripe es el estándar de facto para la infraestructura de facturación, pero implementarlo correctamente (manejar webhooks de manera idempotente, administrar las transiciones del estado de suscripción y recuperarse de pagos fallidos) requiere mucho más cuidado de lo que sugiere la documentación inicial. Un sistema de pago que funciona el 99% del tiempo y falla silenciosamente el 1% restante es peor que uno que falla ruidosamente el 95%.
Esta guía cubre una implementación de producción de Stripe con 6 tipos de eventos de webhook, gestión del ciclo de vida de la suscripción, aprovisionamiento automático de licencias y gestión de reembolsos. Los ejemplos de código utilizan NestJS 11, pero los patrones se aplican a cualquier marco de backend.
Conclusiones clave
- Nunca proporcione acceso según la redirección de pago; espere siempre el webhook
checkout.session.completed- Verificar las firmas del webhook con
stripe.webhooks.constructEvent()antes de procesar cualquier evento- Hacer que todos los controladores de webhooks sean idempotentes: los reintentos de Stripe fallaron en los webhooks durante hasta 72 horas
- Almacene los ID de clientes, los ID de suscripción y los ID de precios de Stripe en su base de datos; no vuelva a consultar Stripe para todo.
- Manejar
invoice.payment_failedpara activar correos electrónicos de reclamación y suspensión de suscripcióncharge.refundedycheckout.session.completedjuntos cubren el ciclo de vida completo de la compra- Utilice el campo
metadatade Stripe para vincular objetos de Stripe a sus registros internos- Nunca registres cargas útiles de webhooks de Stripe sin procesar: contienen datos confidenciales de la tarjeta
Configuración del proyecto
pnpm add stripe
// billing/stripe.client.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});
Almacene estas variables de entorno:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
Creación de sesión de pago
El flujo de pago: su backend crea una sesión de Stripe Checkout, el frontend redirige a Stripe, Stripe cobra el pago y luego llama a su webhook. Nunca gestiones el pago tú mismo; redirige siempre a Stripe Checkout.
// billing/billing.service.ts
import Stripe from 'stripe';
import { Injectable, BadRequestException } from '@nestjs/common';
import { stripe } from './stripe.client';
@Injectable()
export class BillingService {
async createCheckoutSession(
userId: string,
orgId: string,
priceId: string,
productId: string
) {
// Get or create Stripe customer
const customer = await this.getOrCreateCustomer(userId, orgId);
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'payment', // or 'subscription' for recurring
success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/services`,
metadata: {
userId,
organizationId: orgId,
productId,
},
allow_promotion_codes: true,
invoice_creation: {
enabled: true,
},
});
return { url: session.url };
}
async createSubscriptionCheckout(
userId: string,
orgId: string,
priceId: string,
productId: string
) {
const customer = await this.getOrCreateCustomer(userId, orgId);
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
subscription_data: {
metadata: {
userId,
organizationId: orgId,
productId,
},
trial_period_days: 14,
},
success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/pricing`,
metadata: {
userId,
organizationId: orgId,
productId,
},
});
return { url: session.url };
}
private async getOrCreateCustomer(userId: string, orgId: string) {
// Check if customer already exists in your DB
const existing = await db.query.billingCustomers.findFirst({
where: and(
eq(billingCustomers.userId, userId),
eq(billingCustomers.organizationId, orgId)
),
});
if (existing) return existing;
// Fetch user details for Stripe customer creation
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
});
const stripeCustomer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
userId,
organizationId: orgId,
},
});
const [customer] = await db
.insert(billingCustomers)
.values({
userId,
organizationId: orgId,
stripeCustomerId: stripeCustomer.id,
})
.returning();
return customer;
}
}
Controlador de webhook
El controlador de webhook es la parte más crítica de la integración de Stripe. Todo evento debe ser idempotente:
// billing/webhook.controller.ts
import {
Controller,
Post,
Req,
Headers,
RawBodyRequest,
HttpCode,
} from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
import { stripe } from './stripe.client';
import { Public } from '../auth/decorators/public.decorator';
import { BillingWebhookService } from './billing-webhook.service';
@Controller('billing/webhook')
export class WebhookController {
constructor(private webhookService: BillingWebhookService) {}
@Post()
@Public()
@HttpCode(200) // Always return 200 — Stripe retries on non-2xx
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string
) {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.rawBody!, // Requires raw body middleware
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
// Invalid signature — do not process
throw new Error(`Webhook signature verification failed: ${err.message}`);
}
// Route to specific handler
switch (event.type) {
case 'checkout.session.completed':
await this.webhookService.handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case 'invoice.paid':
await this.webhookService.handleInvoicePaid(
event.data.object as Stripe.Invoice
);
break;
case 'invoice.payment_failed':
await this.webhookService.handlePaymentFailed(
event.data.object as Stripe.Invoice
);
break;
case 'customer.subscription.deleted':
await this.webhookService.handleSubscriptionDeleted(
event.data.object as Stripe.Subscription
);
break;
case 'customer.subscription.updated':
await this.webhookService.handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
break;
case 'charge.refunded':
await this.webhookService.handleChargeRefunded(
event.data.object as Stripe.Charge
);
break;
default:
// Ignore unhandled event types
break;
}
return { received: true };
}
}
Importante: NestJS requiere rawBody: true en NestFactory.create() para la verificación de firma de Stripe:
// main.ts
const app = await NestFactory.create(AppModule, {
rawBody: true,
});
Controlador de pago completado
Este es el controlador de aprovisionamiento principal. Hazlo idempotente:
// billing/billing-webhook.service.ts
@Injectable()
export class BillingWebhookService {
async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
// Check if already processed (idempotency)
const existing = await db.query.orders.findFirst({
where: eq(orders.stripeSessionId, session.id),
});
if (existing) {
return; // Already processed — Stripe is retrying
}
const { userId, organizationId, productId } = session.metadata!;
// Create order in transaction
await db.transaction(async (tx) => {
// Create order
const [order] = await tx
.insert(orders)
.values({
userId,
organizationId,
productId,
stripeSessionId: session.id,
stripePaymentIntentId: session.payment_intent as string,
amount: session.amount_total! / 100, // Stripe uses cents
currency: session.currency!,
status: 'completed',
})
.returning();
// Generate license key
const licenseKey = generateLicenseKey(productId);
// Create license
await tx.insert(licenses).values({
orderId: order.id,
userId,
organizationId,
productId,
licenseKey,
status: 'active',
issuedAt: new Date(),
});
});
// Send confirmation email (non-blocking — don't fail the webhook)
this.emailService
.sendPurchaseConfirmation(userId, productId)
.catch((err) => this.logger.error('Email send failed', err));
}
async handleInvoicePaid(invoice: Stripe.Invoice) {
// For subscriptions — renewal
if (!invoice.subscription) return;
// Find existing subscription/license and extend it
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
});
if (!license) return;
await db
.update(licenses)
.set({
status: 'active',
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 days
})
.where(eq(licenses.id, license.id));
// Log renewal in audit table
await db.insert(licenseAuditLogs).values({
licenseId: license.id,
action: 'renewed',
details: { invoiceId: invoice.id },
});
}
async handlePaymentFailed(invoice: Stripe.Invoice) {
if (!invoice.subscription) return;
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
});
if (!license) return;
// Grace period before suspension (e.g., 3 days)
const gracePeriod = new Date();
gracePeriod.setDate(gracePeriod.getDate() + 3);
if (new Date() > gracePeriod) {
await db
.update(licenses)
.set({ status: 'suspended' })
.where(eq(licenses.id, license.id));
}
// Send dunning email
this.emailService
.sendPaymentFailedNotification(license.userId, invoice)
.catch(console.error);
}
async handleChargeRefunded(charge: Stripe.Charge) {
const order = await db.query.orders.findFirst({
where: eq(orders.stripePaymentIntentId, charge.payment_intent as string),
});
if (!order) return;
await db.transaction(async (tx) => {
// Update order status
await tx
.update(orders)
.set({ status: 'refunded' })
.where(eq(orders.id, order.id));
// Revoke associated licenses
await tx
.update(licenses)
.set({ status: 'revoked', revokedAt: new Date() })
.where(eq(licenses.orderId, order.id));
});
// Log audit trail
await db.insert(licenseAuditLogs).values({
licenseId: (await db.query.licenses.findFirst({
where: eq(licenses.orderId, order.id),
}))!.id,
action: 'revoked',
details: { reason: 'refund', chargeId: charge.id },
});
}
async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db
.update(licenses)
.set({ status: 'cancelled', cancelledAt: new Date() })
.where(eq(licenses.stripeSubscriptionId, subscription.id));
}
async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const status = subscription.status === 'active' ? 'active' : 'suspended';
await db
.update(licenses)
.set({ status })
.where(eq(licenses.stripeSubscriptionId, subscription.id));
}
}
Portal del cliente
Permita que los clientes administren sus propias suscripciones sin comunicarse con el soporte:
async createPortalSession(userId: string, orgId: string) {
const customer = await db.query.billingCustomers.findFirst({
where: and(
eq(billingCustomers.userId, userId),
eq(billingCustomers.organizationId, orgId)
),
});
if (!customer) {
throw new NotFoundException('No billing account found');
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: `${process.env.FRONTEND_URL}/dashboard/billing`,
});
return { url: portalSession.url };
}
El portal del cliente permite a los usuarios cancelar suscripciones, actualizar métodos de pago, descargar facturas y ver el historial de facturación, sin ningún trabajo de interfaz de usuario personalizado.
Integración frontal
// web: lib/billing.ts
'use client';
import { apiFetch } from '@/lib/api';
export async function initiateCheckout(priceId: string, productId: string) {
const { url } = await apiFetch<{ url: string }>('/billing/checkout', {
method: 'POST',
body: JSON.stringify({ priceId, productId }),
});
// Redirect to Stripe Checkout
window.location.href = url;
}
export async function openCustomerPortal() {
const { url } = await apiFetch<{ url: string }>('/billing/portal', {
method: 'POST',
});
window.open(url, '_blank');
}
Probar webhooks localmente
La CLI de Stripe es esencial para el desarrollo de webhooks locales:
# Install Stripe CLI
npm install -g stripe
# Listen and forward webhooks to localhost
stripe listen --forward-to localhost:3001/billing/webhook
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted
# View event logs
stripe events list
La CLI proporciona el secreto de su webhook local; utilícelo en su .env.local:
STRIPE_WEBHOOK_SECRET=whsec_test_... # From `stripe listen` output
Errores y soluciones comunes
Error 1: proporcionar acceso en la redirección de pago en lugar de en el webhook
La redirección exitosa del pago puede activarse mediante: avance/retroceso del navegador, pestañas duplicadas o reintento de red. Aprovisione siempre basándose en el webhook checkout.session.completed, no en la URL de redireccionamiento.
Error 2: no gestionar los reintentos de webhook (idempotencia)
Los reintentos de Stripe fallaron en los webhooks durante 72 horas. Sin comprobaciones de idempotencia, los reintentos crean pedidos, licencias y correos electrónicos duplicados. Almacene el ID de sesión de Stripe en su tabla de pedidos con una restricción única: las inserciones duplicadas fallan sin problemas.
Error 3: utilizar incorrectamente el middleware de cuerpo sin formato
La verificación de la firma de Stripe requiere el cuerpo de la solicitud sin procesar y sin analizar. Si el analizador de cuerpo JSON de Express se ejecuta primero, el cuerpo sin formato desaparece. Utilice rawBody: true en NestJS y acceda a req.rawBody, o configure el middleware JSON para conservar el cuerpo sin formato para la ruta del webhook específicamente.
Error 4: falta lógica de reintento para llamadas a la API de Stripe
Las llamadas a la API de Stripe pueden fallar transitoriamente. Agregue lógica de reintento para operaciones idempotentes:
import Stripe from 'stripe';
async function retryStripeOperation<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (err) {
if (err instanceof Stripe.errors.StripeConnectionError && attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000 * attempt));
continue;
}
throw err;
}
}
throw new Error('Max retries exceeded');
}
Preguntas frecuentes
¿Debo utilizar Stripe Checkout o formularios de pago personalizados?
Utilice siempre Stripe Checkout a menos que tenga requisitos específicos que lo hagan imposible (poco común). Checkout gestiona automáticamente el cumplimiento de PCI, la autenticación 3D Secure, Apple Pay, Google Pay, SEPA y docenas de otros métodos de pago. Los formularios de pago personalizados (utilizando Stripe Elements) requieren que usted mismo mantenga el cumplimiento de PCI e implemente cada método de pago manualmente. Para el 95% de los productos SaaS, Checkout es la opción correcta.
¿Cómo manejo el modo de prueba de Stripe frente al modo en vivo?
Utilice configuraciones de entorno independientes: STRIPE_SECRET_KEY=sk_test_... en desarrollo y STRIPE_SECRET_KEY=sk_live_... en producción. El modo de prueba Stripe está completamente aislado del modo en vivo: ningún cargo de prueba afecta el dinero real y no aparecen datos en vivo en el modo de prueba. Use stripe.mode en su código para verificar que está usando la clave correcta y agregue un indicador destacado en su interfaz de usuario de administrador que muestre en qué modo se encuentra.
¿Qué sucede si mi servidor de webhook no funciona y Stripe no puede realizar la entrega?
Stripe reintenta webhooks fallidos en un cronograma de retraso exponencial de hasta 72 horas: 5 minutos, 30 minutos, 1 hora, 2 horas e intervalos crecientes. Pasadas las 72 horas, el evento se considera fallido. Utilice el panel de eventos de Stripe para volver a enviar eventos fallidos manualmente o consulte stripe.events.list() y reprodúzcalos. Diseñe sus controladores para que sean idempotentes, de modo que la reenvío sea segura.
¿Cómo implemento la facturación medida/basada en el uso?
Stripe Billing admite el uso medido a través de stripe.subscriptionItems.createUsageRecord(). Informar el uso al final de cada período de facturación con la cantidad consumida. Planes de precio medido en su panel de Stripe como precios "medidos". El detalle clave de la implementación: informar el uso antes de que finalice el período de facturación, no después: Stripe finaliza la factura al final del período.
¿Cómo debo almacenar los ID de Stripe en mi base de datos?
Almacene todos los ID de Stripe como columnas text (no numéricas). Cree índices en stripe_customer_id, stripe_subscription_id y stripe_payment_intent_id; buscará registros según estos con frecuencia. Agregue restricciones únicas cuando corresponda (un registro de cliente por usuario). Nunca almacene números de tarjetas de crédito ni datos de pago sin procesar en su propia base de datos.
Próximos pasos
Implementar correctamente la facturación de Stripe (con idempotencia de webhook, gestión del ciclo de vida de la suscripción, aprovisionamiento automático y gestión adecuada de reembolsos) es una tarea de ingeniería no trivial. ECOSIRE maneja 6 tipos de eventos de webhook de Stripe en producción con aprovisionamiento automático de licencias e integración del portal del cliente.
Si necesita integración de Stripe, infraestructura de facturación de suscripciones o soporte de desarrollo de SaaS completo, explore nuestros servicios de desarrollo.
Escrito por
ECOSIRE Research and Development Team
Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.
Artículos relacionados
ERP for Law Firms: Case Management, Billing, and Client Portal
Complete guide to ERP for law firms — case management, legal billing, trust accounting, IOLTA compliance, client portal, and matter management for 2026.
Media Company ERP Implementation: Editorial, Ad Sales, and Subscriptions
Step-by-step guide to implementing ERP in media companies, covering editorial workflow integration, advertising system migration, subscription billing setup, and data migration.
Professional Services ERP Implementation: Time, Billing, and Projects
Step-by-step professional services ERP implementation guide covering time tracking, billing configuration, project accounting, and resource management setup.