Stripe Webhooks: Verificacion y Manejo de Eventos de Pago
Skill de referencia para implementar y verificar webhooks de Stripe en Node.js y Python, cubriendo verificacion de firma HMAC, tipos de eventos de pago/suscripcion y configuracion del entorno local.
Descarga abierta · sin registro · para Node.js, Python, Express
// Generated with: stripe-webhooks skill // https://github.com/hookdeck/webhook-skills // // CultivaSaaS — Stripe Webhook Handler (Express) // Maneja eventos de pago, suscripcion e invoice con verificacion HMAC-SHA256.
const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const { createClient } = require('@supabase/supabase-js');
const router = express.Router();
// ───────────────────────────────────────────── // Supabase client (server-side, service role) // ───────────────────────────────────────────── const supabase = createClient( process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY );
// ───────────────────────────────────────────── // Idempotency: set de IDs procesados en memoria // (produccion: usar Redis o tabla stripe_events) // ───────────────────────────────────────────── const processedEvents = new Set();
// ───────────────────────────────────────────── // POST /webhooks/stripe // ───────────────────────────────────────────── router.post( '/webhooks/stripe', express.raw({ type: 'application/json' }), // raw body ANTES de JSON.parse async (req, res) => { const sig = req.headers['stripe-signature'];
// 1. VERIFICAR FIRMA ─────────────────────
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer sin parsear
sig,
process.env.STRIPE_WEBHOOK_SECRET // whsec_… del dashboard
);
} catch (err) {
console.error(`[stripe] Signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// 2. IDEMPOTENCIA ────────────────────────
if (processedEvents.has(event.id)) {
console.log(`[stripe] Event ${event.id} already processed, skipping.`);
return res.status(200).json({ received: true, skipped: true });
}
processedEvents.add(event.id);
// 3. DISPATCH DE EVENTOS ─────────────────
try {
switch (event.type) {
// Checkout completado → activar cuenta + enviar bienvenida
case 'checkout.session.completed': {
const session = event.data.object;
const customerId = session.customer;
const email = session.customer_email ?? session.customer_details?.email;
const priceId = session.line_items?.data?.[0]?.price?.id ?? null;
await supabase.from('accounts').upsert({
stripe_customer_id: customerId,
email,
plan: resolvePlan(priceId),
status: 'active',
activated_at: new Date().toISOString(),
}, { onConflict: 'stripe_customer_id' });
// Disparar email de bienvenida (via Resend / cola interna)
await enqueueEmail({ type: 'welcome', email, plan: resolvePlan(priceId) });
console.log(`[stripe] checkout.session.completed → account ${customerId} activada`);
break;
}
// Nueva suscripcion iniciada
case 'customer.subscription.created': {
const sub = event.data.object;
await supabase.from('subscriptions').insert({
stripe_subscription_id: sub.id,
stripe_customer_id: sub.customer,
price_id: sub.items.data[0].price.id,
status: sub.status,
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
});
console.log(`[stripe] customer.subscription.created → sub ${sub.id}`);
break;
}
// Suscripcion cancelada → revocar acceso
case 'customer.subscription.deleted': {
const sub = event.data.object;
await supabase
.from('accounts')
.update({ status: 'canceled', canceled_at: new Date().toISOString() })
.eq('stripe_customer_id', sub.customer);
await supabase
.from('subscriptions')
.update({ status: 'canceled' })
.eq('stripe_subscription_id', sub.id);
await enqueueEmail({ type: 'cancellation', customerId: sub.customer });
console.log(`[stripe] customer.subscription.deleted → cuenta ${sub.customer} desactivada`);
break;
}
// Factura pagada → renovar acceso (renovacion mensual/anual)
case 'invoice.paid': {
const invoice = event.data.object;
await supabase
.from('accounts')
.update({ status: 'active', last_payment_at: new Date().toISOString() })
.eq('stripe_customer_id', invoice.customer);
await supabase.from('payments').insert({
stripe_invoice_id: invoice.id,
stripe_customer_id: invoice.customer,
amount_eur: invoice.amount_paid / 100,
currency: invoice.currency.toUpperCase(),
paid_at: new Date(invoice.status_transitions.paid_at * 1000).toISOString(),
});
console.log(`[stripe] invoice.paid → ${invoice.amount_paid / 100} EUR (cliente ${invoice.customer})`);
break;
}
// Pago fallido → notificar y marcar
case 'payment_intent.payment_failed': {
const pi = event.data.object;
await supabase
.from('accounts')
.update({ payment_failed: true, payment_failed_at: new Date().toISOString() })
.eq('stripe_customer_id', pi.customer);
await enqueueEmail({
type: 'payment_failed',
customerId: pi.customer,
errorCode: pi.last_payment_error?.code,
errorMessage: pi.last_payment_error?.message,
});
console.log(`[stripe] payment_intent.payment_failed → cliente ${pi.customer}: ${pi.last_payment_error?.message}`);
break;
}
default:
console.log(`[stripe] Evento no manejado: ${event.type}`);
}
} catch (handlerErr) {
// Loguear pero responder 200: Stripe NO debe reintentar por errores internos
console.error(`[stripe] Error en handler de ${event.type}:`, handlerErr);
// En produccion: enviar a Sentry / Dead Letter Queue
}
// 4. RESPUESTA OK ────────────────────────
return res.status(200).json({ received: true });
} );
// ───────────────────────────────────────────── // Helpers // ─────────────────────────────────────────────
/**
- Resuelve el nombre del plan a partir del Stripe Price ID.
- Mantener sincronizado con la tabla de precios en Stripe Dashboard. */ function resolvePlan(priceId) { const MAP = { 'price_starter_monthly': 'starter', 'price_growth_monthly': 'growth', 'price_agency_monthly': 'agency', 'price_addon_ia': 'addon_ia', }; return MAP[priceId] ?? 'unknown'; }
/**
- Encola un email transaccional.
- Sustituir por llamada directa a Resend o sistema de colas (BullMQ, etc.) */ async function enqueueEmail(payload) { // TODO: conectar con Resend / cola interna console.log('[email-queue] enqueued:', JSON.stringify(payload)); }
module.exports = router;
// ───────────────────────────────────────────── // Registro de eventos de Stripe para CultivaSaaS // ───────────────────────────────────────────── // // Evento | Accion en BD | Email // ------------------------------------|-------------------------------|------------------------ // checkout.session.completed | upsert accounts (active) | Bienvenida + plan // customer.subscription.created | insert subscriptions | — // customer.subscription.deleted | update accounts (canceled) | Cancelacion confirmada // invoice.paid | update accounts (active) | — // payment_intent.payment_failed | update accounts (failed) | Alerta de pago fallido // // Variables de entorno necesarias: // STRIPE_SECRET_KEY sk_live_xxxxx // STRIPE_WEBHOOK_SECRET whsec_xxxxx (del endpoint en Stripe Dashboard) // SUPABASE_URL https://xxxx.supabase.co // SUPABASE_SERVICE_ROLE_KEY eyJxxx... // // Desarrollo local (sin cuenta Hookdeck): // npx hookdeck-cli listen 3000 stripe --path /webhooks/stripe
// qué_hace
Proporciona patrones de codigo y referencia para recibir, verificar y manejar eventos webhook de Stripe (pagos, suscripciones, facturas).
// cómo_lo_hace
Usa el SDK oficial de Stripe para verificar la firma HMAC-SHA256 del header Stripe-Signature sobre el cuerpo HTTP sin parsear, con ejemplos en Node.js y Python.
// ejemplo_de_uso
Indispensable al integrar Stripe en tu plataforma para reaccionar a eventos de pago sin procesar duplicados ni perder transacciones. Ej.: implementas en Node.js el handler de checkout.session.completed con verificación de firma en 20 minutos usando el snippet del skill.
// plataformas
// opiniones_de_la_comunidad
Opiniones
Cargando opiniones…
// pase_cultiva_ia
Llévate todo el arsenal con el Pase
Todas las skills, prompts y automatizaciones del catálogo en un único archivo, listas para usar: un pago, acceso de por vida y las novedades que añadamos. Sin suscripción.
Pago único · IVA incluido · pago seguro con Stripe.
Acceso inmediato · si no es lo que esperabas, te devolvemos los 10 €.