🔗

NutriFlow — Clerk Webhooks

Sincronización de usuarios Clerk ↔ Supabase en Next.js 14 App Router

🛠 clerk-webhooks skill · Generated by CULTIVA IA
Eventos cubiertos
6
user.created/updated/deleted
session.created/ended
organization.created
Verificación de firma
HMAC
Standard Webhooks (svix-*)
SHA-256 · whsec_ prefix
Window ≤ 300 s anti-replay
Stack objetivo
Next.js 14
App Router · Route Handler
Supabase (PostgreSQL)
Hookdeck CLI (local dev)
⚡ Flujo de un evento webhook
user.created → Supabase profiles + subscriptions
👤
Clerk Auth
Usuario se registra
POST HTTPS
🔐
Verificación
svix-* headers HMAC
si válido
🔀
Switch evento
user.created handler
INSERT
🗄
Supabase
profiles + subs
HTTP 200
Respuesta
{ success: true }
📄 Route Handler — Next.js App Router
app/api/webhooks/clerk/route.ts TypeScript
// Generated with: clerk-webhooks skill
// https://github.com/hookdeck/webhook-skills

import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { Webhook } from 'standardwebhooks';
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

// ⚠  App Router necesita que el body se lea como text, NO json()
export async function POST(req: Request) {
  const secret =
    process.env.CLERK_WEBHOOK_SIGNING_SECRET ??
    process.env.CLERK_WEBHOOK_SECRET;

  if (!secret || !secret.startsWith('whsec_')) {
    return NextResponse.json(
      { error: 'Missing webhook secret' }, { status: 500 }
    );
  }

  // Leer cabeceras Standard Webhooks (Clerk usa svix-*)
  const hdrs = headers();
  const webhookHeaders = {
    'webhook-id':        hdrs.get('svix-id') ?? '',
    'webhook-timestamp': hdrs.get('svix-timestamp') ?? '',
    'webhook-signature': hdrs.get('svix-signature') ?? '',
  };

  if (!webhookHeaders['webhook-id']) {
    return NextResponse.json(
      { error: 'Missing svix headers' }, { status: 400 }
    );
  }

  const rawBody = await req.text();

  let event: ClerkWebhookEvent;
  try {
    const wh = new Webhook(secret);
    event = wh.verify(rawBody, webhookHeaders) as ClerkWebhookEvent;
  } catch (err) {
    console.error('[clerk-webhook] Verificación fallida:', err);
    return NextResponse.json(
      { error: 'Invalid signature' }, { status: 400 }
    );
  }

  console.log(`[clerk-webhook] Evento recibido: ${event.type}`);

  try {
    switch (event.type) {
      case 'user.created':
        await handleUserCreated(event.data); break;
      case 'user.updated':
        await handleUserUpdated(event.data); break;
      case 'user.deleted':
        await handleUserDeleted(event.data); break;
      case 'session.created':
        await handleSessionCreated(event.data); break;
      case 'session.ended':
        await handleSessionEnded(event.data); break;
      case 'organization.created':
        await handleOrgCreated(event.data); break;
      default:
        console.log(`[clerk-webhook] Evento no manejado: ${event.type}`);
    }
  } catch (err) {
    console.error(`[clerk-webhook] Error procesando ${event.type}:`, err);
    return NextResponse.json(
      { error: 'Handler error' }, { status: 500 }
    );
  }

  return NextResponse.json({ success: true }, { status: 200 });
}
🔀 Handlers de evento — Supabase sync
app/api/webhooks/clerk/handlers.ts TypeScript
// ── user.created ─────────────────────────────
async function handleUserCreated(data: ClerkUser) {
  const email =
    data.email_addresses[0]?.email_address ?? null;

  // 1. Crear perfil
  const { error: profileErr } = await supabase
    .from('profiles')
    .insert({
      clerk_id:   data.id,
      email,
      nombre:     `${data.first_name} ${data.last_name}`.trim(),
      avatar_url: data.image_url,
      created_at: new Date(data.created_at).toISOString(),
    });

  if (profileErr) throw profileErr;

  // 2. Suscripción FREE por defecto
  const { error: subErr } = await supabase
    .from('subscriptions')
    .insert({
      clerk_id: data.id,
      plan:     'free',
      status:   'active',
      started_at: new Date().toISOString(),
    });

  if (subErr) throw subErr;
  console.log(`✅ Perfil creado: ${data.id} (${email})`);
}

// ── user.updated ─────────────────────────────
async function handleUserUpdated(data: ClerkUser) {
  const email =
    data.email_addresses[0]?.email_address ?? null;

  const { error } = await supabase
    .from('profiles')
    .update({
      email,
      nombre:     `${data.first_name} ${data.last_name}`.trim(),
      avatar_url: data.image_url,
      updated_at: new Date().toISOString(),
    })
    .eq('clerk_id', data.id);

  if (error) throw error;
  console.log(`✅ Perfil actualizado: ${data.id}`);
}

// ── user.deleted ─────────────────────────────
async function handleUserDeleted(
  data: { id: string; deleted: boolean }
) {
  const { error } = await supabase
    .from('profiles')
    .delete()
    .eq('clerk_id', data.id);
  // ON DELETE CASCADE elimina subscriptions + activity_log

  if (error) throw error;
  console.log(`🗑 Perfil eliminado: ${data.id}`);
}

// ── session.created ───────────────────────────
async function handleSessionCreated(data: ClerkSession) {
  const { error } = await supabase
    .from('activity_log')
    .insert({
      clerk_id:   data.user_id,
      event_type: 'login',
      session_id: data.id,
      occurred_at: new Date(data.created_at).toISOString(),
    });

  if (error) throw error;
}

// ── session.ended ─────────────────────────────
async function handleSessionEnded(data: ClerkSession) {
  const { error } = await supabase
    .from('activity_log')
    .update({ ended_at: new Date().toISOString() })
    .eq('session_id', data.id);

  if (error) throw error;
}

// ── organization.created ──────────────────────
async function handleOrgCreated(data: ClerkOrg) {
  const { error } = await supabase
    .from('clinics')
    .insert({
      clerk_org_id: data.id,
      nombre:       data.name,
      slug:         data.slug,
      plan:         'free',
      created_at: new Date(data.created_at).toISOString(),
    });

  if (error) throw error;
  console.log(`🏥 Clínica registrada: ${data.name}`);
}
📋 Eventos Clerk manejados
  • 👤
    user.created
    Nuevo usuario registrado en Clerk
    INSERT profiles + subs
  • ✏️
    user.updated
    Cambio de email, nombre o avatar
    UPDATE profiles
  • 🗑
    user.deleted
    Cuenta eliminada en Clerk
    DELETE CASCADE
  • 🔑
    session.created
    Login exitoso del usuario
    INSERT activity_log
  • 🚪
    session.ended
    Logout o expiración de sesión
    UPDATE ended_at
  • 🏥
    organization.created
    Nueva clínica/organización creada
    INSERT clinics
🔒 Variables de entorno
.env.local
Variable Valor Estado
CLERK_WEBHOOK_SIGNING_SECRET whsec_xK3m… required
NEXT_PUBLIC_SUPABASE_URL https://xxx.supabase.co required
SUPABASE_SERVICE_ROLE_KEY eyJhbGciOiJ… required
HOOKDECK_CLI_ADDR localhost:3000 dev only
🖥 Log simulado — producción
LIVE
12:04:01 INFO POST /api/webhooks/clerk svix-id=msg_01JX…
12:04:01 OK Firma HMAC-SHA256 verificada
12:04:01 INFO Evento recibido: user.created
12:04:01 OK ✅ Perfil creado: user_2aQm… (maria@nutriflow.es)
12:04:01 OK Suscripción FREE activada
12:04:01 OK HTTP 200 { success: true } +42ms
12:08:33 INFO Evento: session.created user_2aQm…
12:08:33 OK activity_log INSERT ✓ +18ms
12:09:11 WARN Evento no manejado: email.created — ignorado
🧪 Dev local con Hookdeck CLI
Terminal bash
# 1. Levantar Next.js
$ pnpm dev

# 2. Abrir túnel sin cuenta
$ npx hookdeck-cli listen 3000 clerk \
    --path /api/webhooks/clerk

# Output:
Source URL → https://hkd.io/src/xxxxx
Forwarding → http://localhost:3000/api/webhooks/clerk

# 3. Pegar la Source URL en:
#    Clerk Dashboard → Webhooks → Add Endpoint
# 4. Copiar Signing Secret → .env.local
Skill clerk-webhooks-integracion · ID 30ac1b3d · Dominio NutriFlow SaaS · Next.js 14 + Supabase + Clerk
standardwebhooks svix-* HMAC-SHA256 Next.js App Router Supabase Hookdeck CLI TypeScript