Sincronización de usuarios Clerk ↔ Supabase en Next.js 14 App Router
// 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 }); }
// ── 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}`); }
| 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 |
# 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
30ac1b3d ·
Dominio NutriFlow SaaS ·
Next.js 14 + Supabase + Clerk