Implementación Next.js App Router · Autenticación Basic Auth · Eventos de suscripción y pago
/api/webhooks/chargebee con header Authorizationauthorization header, verificar prefijo Basic usuario:contraseña del payload Base64CHARGEBEE_WEBHOOK_USERNAME y PASSWORDevent_type, responder 200 inmediato, async processing// NutriPaw — Chargebee Webhook Handler // app/api/webhooks/chargebee/route.ts import { NextRequest, NextResponse } from 'next/server'; // ─── Tipos de eventos Chargebee ─────────────────────────────────────────────── type ChargebeeEventType = | 'subscription_created' | 'subscription_changed' | 'subscription_cancelled' | 'subscription_reactivated' | 'payment_succeeded' | 'payment_failed' | 'invoice_generated' | 'customer_created'; interface ChargebeeWebhookEvent { id: string; event_type: ChargebeeEventType; api_version: string; occurred_at: number; content: Record<string, unknown>; } // ─── Set para idempotencia (producción: usar Redis/DB) ─────────────────────── const processedEventIds = new Set<string>(); // ─── Verificación Basic Auth ────────────────────────────────────────────────── function verifyBasicAuth(authHeader: string | null): boolean { if (!authHeader || !authHeader.startsWith('Basic ')) return false; const encoded = authHeader.substring(6); const decoded = Buffer.from(encoded, 'base64').toString('utf-8'); const colonIdx = decoded.indexOf(':'); if (colonIdx === -1) return false; const username = decoded.slice(0, colonIdx); const password = decoded.slice(colonIdx + 1); return ( username === process.env.CHARGEBEE_WEBHOOK_USERNAME && password === process.env.CHARGEBEE_WEBHOOK_PASSWORD ); } // ─── Handlers por tipo de evento ──────────────────────────────────────────── async function handleSubscriptionCreated(event: ChargebeeWebhookEvent) { const { subscription, customer } = event.content as any; console.log(`[nutripaw] Nueva suscripción: ${subscription.id} — cliente: ${customer.email}`); // → provisionar acceso a la plataforma // → enviar email de bienvenida // → crear registro en Supabase: clinics table } async function handleSubscriptionCancelled(event: ChargebeeWebhookEvent) { const { subscription } = event.content as any; console.log(`[nutripaw] Suscripción cancelada: ${subscription.id}`); // → revocar acceso: setClinicStatus('cancelled') // → activar flujo de retención (email D+1, D+3, D+7) } async function handlePaymentSucceeded(event: ChargebeeWebhookEvent) { const { payment } = event.content as any; console.log(`[nutripaw] Pago OK: ${payment.id} — ${(payment.amount / 100).toFixed(2)}€`); // → registrar en tabla payments, enviar recibo PDF } async function handlePaymentFailed(event: ChargebeeWebhookEvent) { const { payment } = event.content as any; console.log(`[nutripaw] Pago fallido: ${payment.id}`); // → notificar al cliente, iniciar retry workflow // → bloquear acceso tras 3 intentos fallidos } // ─── Handler principal ──────────────────────────────────────────────────────── export async function POST(req: NextRequest): Promise<NextResponse> { // 1. Verificar Basic Auth const authHeader = req.headers.get('authorization'); if (!verifyBasicAuth(authHeader)) { console.warn('[nutripaw/chargebee] Auth fallida'); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } // 2. Parsear evento let event: ChargebeeWebhookEvent; try { event = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } // 3. Idempotencia — evitar doble procesamiento if (processedEventIds.has(event.id)) { console.log(`[nutripaw/chargebee] Evento duplicado ignorado: ${event.id}`); return NextResponse.json({ status: 'already_processed' }, { status: 200 }); } // 4. Responder 200 inmediato (Chargebee espera < 30s) const response = NextResponse.json({ status: 'ok' }, { status: 200 }); // 5. Procesar asíncronamente setImmediate(async () => { try { switch (event.event_type) { case 'subscription_created': await handleSubscriptionCreated(event); break; case 'subscription_cancelled': await handleSubscriptionCancelled(event); break; case 'payment_succeeded': await handlePaymentSucceeded(event); break; case 'payment_failed': await handlePaymentFailed(event); break; default: console.log(`[nutripaw/chargebee] Evento no manejado: ${event.event_type}`); } processedEventIds.add(event.id); // marcar como procesado } catch (err) { console.error(`[nutripaw/chargebee] Error procesando ${event.id}:`, err); } }); return response; }
| Evento Chargebee | Acción NutriPaw | Estado |
|---|---|---|
| subscription_created | Provisionar acceso + email bienvenida | Activo |
| subscription_cancelled | Revocar acceso + flujo retención | Activo |
| payment_succeeded | Registrar pago + enviar recibo PDF | Activo |
| payment_failed | Notificar + retry workflow | Activo |
| subscription_changed | Actualizar plan/permisos | Pendiente |
| invoice_generated | Archivar factura en GDrive | Pendiente |
NEXT_PUBLIC_ solo para vars no sensibles.
# Expone localhost:3000 como webhook público npx hookdeck-cli listen 3000 chargebee \ --path /api/webhooks/chargebee
# Generar credencial Basic Auth CREDS=$(echo -n "nutripaw-webhook:test_pass_123" | base64) # Enviar evento subscription_created curl -X POST https://events.hookdeck.com/<tu-source-id> \ -H "Authorization: Basic $CREDS" \ -H "Content-Type: application/json" \ -d '{"id":"ev_test_001","event_type":"subscription_created", "api_version":"v2","occurred_at":1718323200, "content":{"subscription":{"id":"sub_NP_001","plan_id":"pro"}, "customer":{"email":"clinica@ejemplo.es"}}}'
[nutripaw/chargebee]