import { httpRouter } from "convex/server"; import { httpAction } from "./_generated/server"; import { internal } from "./_generated/api"; import type { WebhookEvent } from "@clerk/backend"; import { Webhook } from "svix"; const http = httpRouter(); http.route({ path: "/clerk-users-webhook", method: "POST", handler: httpAction(async (ctx, request) => { const event = await validateRequest(request); if (!event) return new Response("Error", { status: 400 }); switch (event.type) { case "user.created": case "user.updated": await ctx.runMutation(internal.users.upsertFromClerk, { externalId: event.data.id, name: `${event.data.first_name ?? ""} ${event.data.last_name ?? ""}`.trim(), email: event.data.email_addresses[0]?.email_address ?? "", avatarUrl: event.data.image_url ?? undefined, }); break; case "user.deleted": if (event.data.id) { await ctx.runMutation(internal.users.deleteFromClerk, { externalId: event.data.id, }); } break; default: console.log("Ignored webhook event:", event.type); } return new Response(null, { status: 200 }); }), }); async function validateRequest( req: Request, ): Promise { const payload = await req.text(); const headers = { "svix-id": req.headers.get("svix-id")!, "svix-timestamp": req.headers.get("svix-timestamp")!, "svix-signature": req.headers.get("svix-signature")!, }; try { return new Webhook(process.env.CLERK_STOREFRONT_WEBHOOK_SECRET!).verify( payload, headers, ) as WebhookEvent; } catch { return null; } } http.route({ path: "/clerk-admin-webhook", method: "POST", handler: httpAction(async (ctx, request) => { const event = await validateAdminRequest(request); if (!event) return new Response("Error", { status: 400 }); switch (event.type) { case "user.created": case "user.updated": { const role = (event.data.public_metadata as Record) ?.role; await ctx.runMutation(internal.users.upsertFromClerk, { externalId: event.data.id, name: `${event.data.first_name ?? ""} ${event.data.last_name ?? ""}`.trim(), email: event.data.email_addresses[0]?.email_address ?? "", avatarUrl: event.data.image_url ?? undefined, role: role === "admin" || role === "super_admin" ? role : undefined, }); break; } case "user.deleted": if (event.data.id) { await ctx.runMutation(internal.users.deleteFromClerk, { externalId: event.data.id, }); } break; default: console.log("Ignored admin webhook event:", event.type); } return new Response(null, { status: 200 }); }), }); async function validateAdminRequest( req: Request, ): Promise { const payload = await req.text(); const headers = { "svix-id": req.headers.get("svix-id")!, "svix-timestamp": req.headers.get("svix-timestamp")!, "svix-signature": req.headers.get("svix-signature")!, }; try { return new Webhook(process.env.CLERK_ADMIN_WEBHOOK_SECRET!).verify( payload, headers, ) as WebhookEvent; } catch { return null; } } http.route({ path: "/stripe/webhook", method: "POST", handler: httpAction(async (ctx, request) => { const body = await request.text(); const signature = request.headers.get("stripe-signature"); if (!signature) { return new Response("Missing stripe-signature header", { status: 400 }); } const result = await ctx.runAction(internal.stripeActions.handleWebhook, { payload: body, signature, }); return new Response(null, { status: result.success ? 200 : 400 }); }), }); http.route({ path: "/shippo/webhook", method: "POST", handler: httpAction(async (ctx, request) => { const body = await request.text(); // Always respond 200 first — Shippo retries on non-2xx, so we must not // let internal errors cause retry storms. Errors are logged in the action. await ctx.runAction(internal.shippoWebhook.handleTrackUpdated, { body }); return new Response(null, { status: 200 }); }), }); export default http;