Webhooks Chargebee — NutriPaw SaaS

Implementación Next.js App Router · Autenticación Basic Auth · Eventos de suscripción y pago

Next.js 14 TypeScript Chargebee

Flujo de verificación Basic Auth

Ejecutado en cada request entrante
01
Recibir POST
Chargebee envía evento a /api/webhooks/chargebee con header Authorization
02
Extraer Basic Auth
Leer authorization header, verificar prefijo Basic
03
Decodificar Base64
Decodificar credenciales usuario:contraseña del payload Base64
04
Comparar env vars
Validar contra CHARGEBEE_WEBHOOK_USERNAME y PASSWORD
05
Procesar evento
Enrutar por event_type, responder 200 inmediato, async processing

app/api/webhooks/chargebee/route.ts

Next.js App Router · TypeScript · Producción
// 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;
}

Eventos manejados

NutriPaw — 4 eventos activos
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

Variables de entorno

.env.local / Vercel Dashboard
Credenciales webhook
CHARGEBEE_WEBHOOK_USERNAME nutripaw-webhook
CHARGEBEE_WEBHOOK_PASSWORD ••••••••••••••••••••
Chargebee API (para sync)
CHARGEBEE_SITE nutripaw
CHARGEBEE_API_KEY ••••••••••••••••••••
Nunca expongas credenciales en el cliente. Usa NEXT_PUBLIC_ solo para vars no sensibles.

Testing local con Hookdeck CLI

Sin cuenta, tunnel automático
1 — Iniciar túnel
# Expone localhost:3000 como webhook público
npx hookdeck-cli listen 3000 chargebee \
  --path /api/webhooks/chargebee
2 — Simular evento con curl
# 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"}}}'
3 — Inspección en web UI
Hookdeck abre automáticamente http://localhost:1313 con el inspector de requests, headers, body y respuestas.

Checklist de producción

Antes de activar en Chargebee dashboard
  • Variables de entorno en Vercel Dashboard (no en repo)
  • Endpoint responde 200 en < 5 segundos
  • Idempotencia implementada (Redis en producción)
  • Logging estructurado con prefijo [nutripaw/chargebee]
  • Basic Auth verificado antes de parsear body
  • Try/catch en handlers asíncronos
  • URL del webhook configurada en Chargebee Settings → Webhooks
  • Testeado con Hookdeck CLI en staging
  • Pendiente: alertas en Slack para payment_failed
  • Pendiente: Redis para idempotencia persistente