From 5309d67d132d45965eca780401b484753c1f31f6 Mon Sep 17 00:00:00 2001 From: Franco Jalil Date: Tue, 16 Jun 2026 16:34:02 -0300 Subject: [PATCH 1/7] feat(examples): add webhooks template (BIL-1332) Next.js SaaS template that listens to Commet webhooks and reacts: provisions plan entitlements, sends a welcome email, notifies the team, and gates premium features on past-due. Includes a live /dashboard/events feed. --- examples/webhooks/.dockerignore | 10 + examples/webhooks/.env.example | 22 +++ examples/webhooks/.gitignore | 42 +++++ examples/webhooks/actions/auth.ts | 161 ++++++++++++++++ examples/webhooks/actions/billing.ts | 61 ++++++ examples/webhooks/actions/plans.ts | 34 ++++ examples/webhooks/app/(auth)/layout.tsx | 11 ++ examples/webhooks/app/(auth)/sign-in/page.tsx | 116 ++++++++++++ examples/webhooks/app/(auth)/sign-up/page.tsx | 127 +++++++++++++ .../(dashboard)/dashboard/billing/page.tsx | 94 ++++++++++ .../app/(dashboard)/dashboard/events/page.tsx | 96 ++++++++++ .../app/(dashboard)/dashboard/layout.tsx | 77 ++++++++ .../app/(dashboard)/dashboard/page.tsx | 174 ++++++++++++++++++ .../(dashboard)/dashboard/security/page.tsx | 153 +++++++++++++++ examples/webhooks/app/(dashboard)/layout.tsx | 16 ++ .../webhooks/app/api/auth/[...all]/route.ts | 6 + .../webhooks/app/api/commet/portal/route.ts | 10 + .../webhooks/app/api/webhooks/commet/route.ts | 58 ++++++ examples/webhooks/app/checkout/page.tsx | 35 ++++ examples/webhooks/app/globals.css | 132 +++++++++++++ examples/webhooks/app/layout.tsx | 50 +++++ examples/webhooks/app/not-found.tsx | 14 ++ examples/webhooks/app/page.tsx | 74 ++++++++ examples/webhooks/app/pricing.md/route.ts | 8 + examples/webhooks/app/pricing/page.tsx | 141 ++++++++++++++ examples/webhooks/components.json | 25 +++ examples/webhooks/components/auto-refresh.tsx | 15 ++ .../webhooks/components/past-due-banner.tsx | 27 +++ .../components/shared/submit-button.tsx | 40 ++++ .../webhooks/components/template-header.tsx | 69 +++++++ examples/webhooks/components/ui/avatar.tsx | 56 ++++++ examples/webhooks/components/ui/button.tsx | 60 ++++++ examples/webhooks/components/ui/card.tsx | 103 +++++++++++ .../webhooks/components/ui/form-field.tsx | 41 +++++ examples/webhooks/components/ui/input.tsx | 20 ++ examples/webhooks/components/ui/label.tsx | 21 +++ examples/webhooks/components/ui/separator.tsx | 25 +++ examples/webhooks/components/ui/skeleton.tsx | 13 ++ examples/webhooks/docker-compose.yml | 22 +++ examples/webhooks/drizzle.config.ts | 13 ++ examples/webhooks/hooks/use-form-toast.ts | 26 +++ examples/webhooks/lib/auth/auth-client.ts | 9 + examples/webhooks/lib/auth/auth.ts | 44 +++++ examples/webhooks/lib/auth/session.ts | 13 ++ examples/webhooks/lib/billing/entitlements.ts | 27 +++ examples/webhooks/lib/billing/sync.ts | 135 ++++++++++++++ .../webhooks/lib/billing/webhook-events.ts | 25 +++ examples/webhooks/lib/commet.ts | 7 + examples/webhooks/lib/db/drizzle.ts | 13 ++ examples/webhooks/lib/db/queries.ts | 76 ++++++++ examples/webhooks/lib/db/schema.ts | 150 +++++++++++++++ examples/webhooks/lib/notifications/email.ts | 53 ++++++ examples/webhooks/lib/notifications/team.ts | 11 ++ examples/webhooks/lib/payments/actions.ts | 58 ++++++ examples/webhooks/lib/payments/commet.ts | 91 +++++++++ examples/webhooks/lib/utils.ts | 6 + examples/webhooks/lib/validations/auth.ts | 39 ++++ examples/webhooks/next.config.ts | 5 + examples/webhooks/package.json | 51 +++++ examples/webhooks/postcss.config.mjs | 5 + examples/webhooks/public/favicon-dark.svg | 7 + examples/webhooks/public/favicon-light.svg | 7 + examples/webhooks/tsconfig.json | 35 ++++ examples/webhooks/vercel.json | 8 + pnpm-lock.yaml | 134 ++++++++++++++ 65 files changed, 3307 insertions(+) create mode 100644 examples/webhooks/.dockerignore create mode 100644 examples/webhooks/.env.example create mode 100644 examples/webhooks/.gitignore create mode 100644 examples/webhooks/actions/auth.ts create mode 100644 examples/webhooks/actions/billing.ts create mode 100644 examples/webhooks/actions/plans.ts create mode 100644 examples/webhooks/app/(auth)/layout.tsx create mode 100644 examples/webhooks/app/(auth)/sign-in/page.tsx create mode 100644 examples/webhooks/app/(auth)/sign-up/page.tsx create mode 100644 examples/webhooks/app/(dashboard)/dashboard/billing/page.tsx create mode 100644 examples/webhooks/app/(dashboard)/dashboard/events/page.tsx create mode 100644 examples/webhooks/app/(dashboard)/dashboard/layout.tsx create mode 100644 examples/webhooks/app/(dashboard)/dashboard/page.tsx create mode 100644 examples/webhooks/app/(dashboard)/dashboard/security/page.tsx create mode 100644 examples/webhooks/app/(dashboard)/layout.tsx create mode 100644 examples/webhooks/app/api/auth/[...all]/route.ts create mode 100644 examples/webhooks/app/api/commet/portal/route.ts create mode 100644 examples/webhooks/app/api/webhooks/commet/route.ts create mode 100644 examples/webhooks/app/checkout/page.tsx create mode 100644 examples/webhooks/app/globals.css create mode 100644 examples/webhooks/app/layout.tsx create mode 100644 examples/webhooks/app/not-found.tsx create mode 100644 examples/webhooks/app/page.tsx create mode 100644 examples/webhooks/app/pricing.md/route.ts create mode 100644 examples/webhooks/app/pricing/page.tsx create mode 100644 examples/webhooks/components.json create mode 100644 examples/webhooks/components/auto-refresh.tsx create mode 100644 examples/webhooks/components/past-due-banner.tsx create mode 100644 examples/webhooks/components/shared/submit-button.tsx create mode 100644 examples/webhooks/components/template-header.tsx create mode 100644 examples/webhooks/components/ui/avatar.tsx create mode 100644 examples/webhooks/components/ui/button.tsx create mode 100644 examples/webhooks/components/ui/card.tsx create mode 100644 examples/webhooks/components/ui/form-field.tsx create mode 100644 examples/webhooks/components/ui/input.tsx create mode 100644 examples/webhooks/components/ui/label.tsx create mode 100644 examples/webhooks/components/ui/separator.tsx create mode 100644 examples/webhooks/components/ui/skeleton.tsx create mode 100644 examples/webhooks/docker-compose.yml create mode 100644 examples/webhooks/drizzle.config.ts create mode 100644 examples/webhooks/hooks/use-form-toast.ts create mode 100644 examples/webhooks/lib/auth/auth-client.ts create mode 100644 examples/webhooks/lib/auth/auth.ts create mode 100644 examples/webhooks/lib/auth/session.ts create mode 100644 examples/webhooks/lib/billing/entitlements.ts create mode 100644 examples/webhooks/lib/billing/sync.ts create mode 100644 examples/webhooks/lib/billing/webhook-events.ts create mode 100644 examples/webhooks/lib/commet.ts create mode 100644 examples/webhooks/lib/db/drizzle.ts create mode 100644 examples/webhooks/lib/db/queries.ts create mode 100644 examples/webhooks/lib/db/schema.ts create mode 100644 examples/webhooks/lib/notifications/email.ts create mode 100644 examples/webhooks/lib/notifications/team.ts create mode 100644 examples/webhooks/lib/payments/actions.ts create mode 100644 examples/webhooks/lib/payments/commet.ts create mode 100644 examples/webhooks/lib/utils.ts create mode 100644 examples/webhooks/lib/validations/auth.ts create mode 100644 examples/webhooks/next.config.ts create mode 100644 examples/webhooks/package.json create mode 100644 examples/webhooks/postcss.config.mjs create mode 100644 examples/webhooks/public/favicon-dark.svg create mode 100644 examples/webhooks/public/favicon-light.svg create mode 100644 examples/webhooks/tsconfig.json create mode 100644 examples/webhooks/vercel.json diff --git a/examples/webhooks/.dockerignore b/examples/webhooks/.dockerignore new file mode 100644 index 00000000..9088a282 --- /dev/null +++ b/examples/webhooks/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.next +.env +.env.local +.git +.gitignore +README.md +drizzle +dist + diff --git a/examples/webhooks/.env.example b/examples/webhooks/.env.example new file mode 100644 index 00000000..1933a3ad --- /dev/null +++ b/examples/webhooks/.env.example @@ -0,0 +1,22 @@ +# Database +POSTGRES_URL=postgresql://postgres:postgres@localhost:54328/webhooks_saas + +# Better Auth +BETTER_AUTH_SECRET=your-secret-key-min-32-chars-long +BETTER_AUTH_URL=http://localhost:3008 +NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3008 + +# Commet +COMMET_API_KEY=your_api_key_here + +# Webhook signing secret. +# Local dev: printed by `commet listen localhost:3008/api/webhooks/commet`. +# Production: shown when you create the webhook endpoint in the Commet dashboard. +COMMET_WEBHOOK_SECRET=your_webhook_secret_here + +# Welcome email (Resend). Optional in local dev: without it the welcome email is skipped. +RESEND_API_KEY= +EMAIL_FROM=Acme + +# App +NEXT_PUBLIC_APP_URL=http://localhost:3008 diff --git a/examples/webhooks/.gitignore b/examples/webhooks/.gitignore new file mode 100644 index 00000000..8740b603 --- /dev/null +++ b/examples/webhooks/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz +pnpm-lock.yaml + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Database +drizzle/ +.commet/ diff --git a/examples/webhooks/actions/auth.ts b/examples/webhooks/actions/auth.ts new file mode 100644 index 00000000..ccb200a6 --- /dev/null +++ b/examples/webhooks/actions/auth.ts @@ -0,0 +1,161 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth/auth"; +import { getUser } from "@/lib/auth/session"; +import { db } from "@/lib/db/drizzle"; +import { + ActivityType, + activityLogs, + type NewActivityLog, + user, +} from "@/lib/db/schema"; +import { + deleteAccountSchema, + updateAccountSchema, + updatePasswordSchema, +} from "@/lib/validations/auth"; + +type ActionState = { + error?: string; + success?: string; + fieldErrors?: Record; + [key: string]: + | string + | string[] + | Record + | undefined; +}; + +async function logActivity( + userId: string, + type: ActivityType, + ipAddress?: string, +) { + const newActivity: NewActivityLog = { + userId, + action: type, + ipAddress: ipAddress || "", + }; + await db.insert(activityLogs).values(newActivity); +} + +export async function signOut() { + const currentUser = await getUser(); + if (currentUser) { + await logActivity(currentUser.id, ActivityType.SIGN_OUT); + } + redirect("/sign-in"); +} + +export async function updateAccount( + _prevState: ActionState, + formData: FormData, +): Promise { + const currentUser = await getUser(); + if (!currentUser) { + return { error: "Not authenticated" }; + } + + const rawData = { + name: formData.get("name") as string, + email: formData.get("email") as string, + }; + + const result = updateAccountSchema.safeParse(rawData); + if (!result.success) { + return { + error: "Validation failed", + fieldErrors: result.error.flatten().fieldErrors, + }; + } + + const { name, email } = result.data; + + await db + .update(user) + .set({ name, email, updatedAt: new Date() }) + .where(eq(user.id, currentUser.id)); + + await logActivity(currentUser.id, ActivityType.UPDATE_ACCOUNT); + + return { success: "Account updated successfully." }; +} + +export async function updatePassword( + _prevState: ActionState, + formData: FormData, +): Promise { + const currentUser = await getUser(); + if (!currentUser) { + return { error: "Not authenticated" }; + } + + const rawData = { + currentPassword: formData.get("currentPassword") as string, + newPassword: formData.get("newPassword") as string, + confirmPassword: formData.get("confirmPassword") as string, + }; + + const result = updatePasswordSchema.safeParse(rawData); + if (!result.success) { + return { + error: "Validation failed", + fieldErrors: result.error.flatten().fieldErrors, + }; + } + + const { currentPassword, newPassword } = result.data; + + try { + await auth.api.changePassword({ + body: { + currentPassword, + newPassword, + revokeOtherSessions: false, + }, + headers: await headers(), + }); + + await logActivity(currentUser.id, ActivityType.UPDATE_PASSWORD); + + return { success: "Password updated successfully." }; + } catch (error: unknown) { + if (error instanceof Error) { + return { error: error.message || "Failed to update password" }; + } + return { error: "Failed to update password" }; + } +} + +export async function deleteAccount( + _prevState: ActionState, + formData: FormData, +): Promise { + const currentUser = await getUser(); + if (!currentUser) { + return { error: "Not authenticated" }; + } + + const rawData = { + password: formData.get("password") as string, + }; + + const result = deleteAccountSchema.safeParse(rawData); + if (!result.success) { + return { + error: "Validation failed", + fieldErrors: result.error.flatten().fieldErrors, + }; + } + + // Log activity before deletion + await logActivity(currentUser.id, ActivityType.DELETE_ACCOUNT); + + // Delete user (cascade will handle related records) + await db.delete(user).where(eq(user.id, currentUser.id)); + + redirect("/sign-in"); +} diff --git a/examples/webhooks/actions/billing.ts b/examples/webhooks/actions/billing.ts new file mode 100644 index 00000000..50d05017 --- /dev/null +++ b/examples/webhooks/actions/billing.ts @@ -0,0 +1,61 @@ +"use server"; + +import type { BillingInterval } from "@commet/node"; +import { getUser } from "@/lib/auth/session"; +import { commet } from "@/lib/commet"; + +export interface BillingSubscription { + id: string; + planName: string; + planPrice?: number; + billingInterval: BillingInterval | null; + status: string; +} + +export interface BillingData { + subscription: BillingSubscription | null; +} + +export async function getBillingDataAction(): Promise<{ + success: boolean; + data?: BillingData; + error?: string; +}> { + try { + const user = await getUser(); + if (!user) { + return { success: false, error: "Please sign in to view billing." }; + } + + // Get subscription from Commet + const subscriptionResult = await commet.subscriptions.getActive({ + customerId: user.id, + }); + + let subscription: BillingSubscription | null = null; + + if (subscriptionResult.success && subscriptionResult.data) { + const sub = subscriptionResult.data; + subscription = { + id: sub.id, + planName: sub.plan.name, + planPrice: sub.plan.basePrice, + billingInterval: sub.billingInterval, + status: sub.status, + }; + } + + return { + success: true, + data: { + subscription, + }, + }; + } catch (error) { + console.error("Error fetching billing data:", error); + return { + success: false, + error: "Unable to load billing information. Please try again.", + }; + } +} diff --git a/examples/webhooks/actions/plans.ts b/examples/webhooks/actions/plans.ts new file mode 100644 index 00000000..6429d6c8 --- /dev/null +++ b/examples/webhooks/actions/plans.ts @@ -0,0 +1,34 @@ +"use server"; + +import type { Plan } from "@commet/node"; +import { unstable_cache } from "next/cache"; +import { commet } from "@/lib/commet"; + +const getCachedPlans = unstable_cache( + async () => { + const result = await commet.plans.list(); + if (!result.success || !result.data) { + throw new Error("Unable to load plans"); + } + return result.data; + }, + ["plans"], + { revalidate: 3600, tags: ["plans"] }, +); + +export async function getPlansAction(): Promise<{ + success: boolean; + data?: Plan[]; + error?: string; +}> { + try { + const plans = await getCachedPlans(); + return { + success: true, + data: plans, + }; + } catch (error) { + console.error("Error fetching plans:", error); + return { success: false, error: "Unable to load plans. Please try again." }; + } +} diff --git a/examples/webhooks/app/(auth)/layout.tsx b/examples/webhooks/app/(auth)/layout.tsx new file mode 100644 index 00000000..4a9cd3d5 --- /dev/null +++ b/examples/webhooks/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/examples/webhooks/app/(auth)/sign-in/page.tsx b/examples/webhooks/app/(auth)/sign-in/page.tsx new file mode 100644 index 00000000..246825c9 --- /dev/null +++ b/examples/webhooks/app/(auth)/sign-in/page.tsx @@ -0,0 +1,116 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { signIn } from "@/lib/auth/auth-client"; +import { handlePostSignupCheckout } from "@/lib/payments/actions"; + +function SignInContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const redirect = searchParams.get("redirect") || "/dashboard"; + const planCode = searchParams.get("planCode") || undefined; + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + setError(""); + + const formData = new FormData(event.currentTarget); + const email = formData.get("email") as string; + const password = formData.get("password") as string; + + const result = await signIn.email({ email, password }); + + if (result.error) { + setError(result.error.message || "Invalid credentials"); + setLoading(false); + return; + } + + if (planCode) { + const checkoutResult = await handlePostSignupCheckout(planCode); + if (checkoutResult.success && checkoutResult.checkoutUrl) { + window.location.href = checkoutResult.checkoutUrl; + return; + } + setError( + checkoutResult.error || + "We couldn't continue with checkout. Please try again.", + ); + setLoading(false); + return; + } + + router.push(redirect); + } + + const signUpHref = planCode + ? `/sign-up?planCode=${planCode}${redirect !== "/dashboard" ? `&redirect=${redirect}` : ""}` + : "/sign-up"; + + return ( + + + Sign in + Enter your credentials to continue. + + + {planCode && ( +
+ Selected plan + + Sign in to continue with {planCode}. + +
+ )} +
+
+ + +
+
+ + +
+ {error && ( +

{error}

+ )} + +

+ Don't have an account?{" "} + + Sign up + +

+
+
+
+ ); +} + +export default function SignInPage() { + return ( + + + + ); +} diff --git a/examples/webhooks/app/(auth)/sign-up/page.tsx b/examples/webhooks/app/(auth)/sign-up/page.tsx new file mode 100644 index 00000000..01a5dc5c --- /dev/null +++ b/examples/webhooks/app/(auth)/sign-up/page.tsx @@ -0,0 +1,127 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { signUp } from "@/lib/auth/auth-client"; +import { handlePostSignupCheckout } from "@/lib/payments/actions"; + +function SignUpContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const redirect = searchParams.get("redirect") || "/dashboard"; + const planCode = searchParams.get("planCode") || undefined; + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + setError(""); + + const formData = new FormData(event.currentTarget); + const name = formData.get("name") as string; + const email = formData.get("email") as string; + const password = formData.get("password") as string; + + const result = await signUp.email({ name, email, password }); + + if (result.error) { + setError(result.error.message || "Something went wrong"); + setLoading(false); + return; + } + + if (planCode) { + const checkoutResult = await handlePostSignupCheckout(planCode); + if (checkoutResult.success && checkoutResult.checkoutUrl) { + window.location.href = checkoutResult.checkoutUrl; + return; + } + setError( + checkoutResult.error || + "We couldn't continue with checkout. Please try again.", + ); + setLoading(false); + return; + } + + router.push(redirect); + } + + const signInHref = planCode + ? `/sign-in?planCode=${planCode}${redirect !== "/dashboard" ? `&redirect=${redirect}` : ""}` + : "/sign-in"; + + return ( + + + Create an account + Get started with your subscription. + + + {planCode && ( +
+ Selected plan + + Complete your signup to continue with {planCode}. + +
+ )} +
+
+ + +
+
+ + +
+
+ + +
+ {error && ( +

{error}

+ )} + +

+ Already have an account?{" "} + + Sign in + +

+
+
+
+ ); +} + +export default function SignUpPage() { + return ( + + + + ); +} diff --git a/examples/webhooks/app/(dashboard)/dashboard/billing/page.tsx b/examples/webhooks/app/(dashboard)/dashboard/billing/page.tsx new file mode 100644 index 00000000..b201e36e --- /dev/null +++ b/examples/webhooks/app/(dashboard)/dashboard/billing/page.tsx @@ -0,0 +1,94 @@ +import { ExternalLink } from "lucide-react"; +import { + type BillingSubscription, + getBillingDataAction, +} from "@/actions/billing"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +function formatPrice(cents: number | undefined): string { + if (cents === undefined) return "—"; + return `$${(cents / 100).toFixed(2)}`; +} + +function formatBillingInterval( + interval: BillingSubscription["billingInterval"], +): string { + if (interval === null) return "free"; + if (interval === "weekly") return "week"; + if (interval === "monthly") return "month"; + if (interval === "quarterly") return "quarter"; + return "year"; +} + +export default async function BillingPage() { + const billingResult = await getBillingDataAction(); + const subscription = billingResult.data?.subscription; + + return ( +
+
+

Billing

+

+ Your subscription and payment details. +

+
+ + + +
+ Subscription + Your current plan and status. +
+ {subscription && ( + + )} +
+ + {subscription ? ( + <> +
+ Plan + + {subscription.planName} + +
+
+ Status + + {subscription.status} + +
+
+ Price + + {formatPrice(subscription.planPrice)} /{" "} + {formatBillingInterval(subscription.billingInterval)} + +
+ + ) : ( +

+ No active subscription. +

+ )} +
+
+
+ ); +} diff --git a/examples/webhooks/app/(dashboard)/dashboard/events/page.tsx b/examples/webhooks/app/(dashboard)/dashboard/events/page.tsx new file mode 100644 index 00000000..2a85bfcc --- /dev/null +++ b/examples/webhooks/app/(dashboard)/dashboard/events/page.tsx @@ -0,0 +1,96 @@ +import { AutoRefresh } from "@/components/auto-refresh"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { getRecentWebhookEvents } from "@/lib/db/queries"; +import { cn } from "@/lib/utils"; + +function eventBadgeClasses(event: string): string { + if (event.startsWith("payment.")) { + return "border-amber-600/30 bg-amber-600/10 text-amber-700 dark:text-amber-500"; + } + if (event.startsWith("invoice.")) { + return "border-border bg-muted text-muted-foreground"; + } + return "border-primary/30 bg-primary/10 text-primary"; +} + +function formatRelativeTime(receivedAt: Date): string { + const elapsedSeconds = Math.round((Date.now() - receivedAt.getTime()) / 1000); + if (elapsedSeconds < 60) return `${elapsedSeconds}s ago`; + if (elapsedSeconds < 3600) return `${Math.floor(elapsedSeconds / 60)}m ago`; + if (elapsedSeconds < 86400) + return `${Math.floor(elapsedSeconds / 3600)}h ago`; + return receivedAt.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +export default async function EventsPage() { + const events = await getRecentWebhookEvents(); + + return ( +
+ +
+

Events

+

+ Webhooks received from Commet for your account, newest first. + Refreshes every few seconds. +

+
+ + + + Webhook feed + + Last {events.length} events recorded by /api/webhooks/commet. + + + + {events.length === 0 ? ( +

+ No events yet. Run{" "} + + commet listen localhost:3008/api/webhooks/commet + {" "} + and subscribe to a plan to see webhooks arrive here. +

+ ) : ( + events.map((webhookEvent) => ( +
+ + + {webhookEvent.event} + + + {webhookEvent.commetCustomerId ?? "—"} + + + {formatRelativeTime(webhookEvent.receivedAt)} + + +
+                  {JSON.stringify(webhookEvent.payload, null, 2)}
+                
+
+ )) + )} +
+
+
+ ); +} diff --git a/examples/webhooks/app/(dashboard)/dashboard/layout.tsx b/examples/webhooks/app/(dashboard)/dashboard/layout.tsx new file mode 100644 index 00000000..a7745615 --- /dev/null +++ b/examples/webhooks/app/(dashboard)/dashboard/layout.tsx @@ -0,0 +1,77 @@ +import { CreditCard, Home, Lock, LogOut, Webhook } from "lucide-react"; +import Link from "next/link"; +import { PastDueBanner } from "@/components/past-due-banner"; +import { Separator } from "@/components/ui/separator"; +import { getUser } from "@/lib/auth/session"; +import { getBillingStateForUser } from "@/lib/db/queries"; +import { cn } from "@/lib/utils"; + +function NavLink({ + href, + children, + className, +}: { + href: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +export default async function DashboardInnerLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getUser(); + const billing = await getBillingStateForUser(user!.id); + + return ( +
+ {billing.isPastDue && } +
+ +
{children}
+
+
+ ); +} diff --git a/examples/webhooks/app/(dashboard)/dashboard/page.tsx b/examples/webhooks/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 00000000..4de77181 --- /dev/null +++ b/examples/webhooks/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,174 @@ +import { Lock } from "lucide-react"; +import Link from "next/link"; +import { AutoRefresh } from "@/components/auto-refresh"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { getUser } from "@/lib/auth/session"; +import { resolveEntitlementsForPlan } from "@/lib/billing/entitlements"; +import { getBillingStateForUser } from "@/lib/db/queries"; + +const MONTHLY_CHART_BARS = [ + { month: "Jan", height: 40 }, + { month: "Feb", height: 65 }, + { month: "Mar", height: 30 }, + { month: "Apr", height: 80 }, + { month: "May", height: 55 }, + { month: "Jun", height: 70 }, + { month: "Jul", height: 45 }, + { month: "Aug", height: 90 }, + { month: "Sep", height: 60 }, + { month: "Oct", height: 75 }, + { month: "Nov", height: 35 }, + { month: "Dec", height: 85 }, +]; + +function formatPeriodEnd(periodEnd: Date | null): string { + if (!periodEnd) return "—"; + return periodEnd.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export default async function DashboardPage() { + const user = await getUser(); + const billing = await getBillingStateForUser(user!.id); + const entitlements = resolveEntitlementsForPlan(billing.planKey); + const analyticsUnlocked = + entitlements.advancedAnalytics && !billing.isPastDue; + + return ( +
+ +
+

Dashboard

+

+ Everything below reads local state synced by webhooks — no Commet API + call on this page. +

+
+ +
+ + + Current plan + + Updated by subscription.activated, plan_changed and canceled. + + + +
+ Plan + + {billing.planName ?? "Free"} + +
+
+ Status + + {billing.isPastDue + ? "Past due" + : (billing.subscriptionStatus ?? "No subscription")} + +
+
+ + Current period ends + + + {formatPeriodEnd(billing.currentPeriodEnd)} + +
+
+
+ + + + Project limit + + Local entitlement derived from your plan. + + + + + {entitlements.projectLimit} + + + {entitlements.projectLimit === 1 ? "project" : "projects"} + + + +
+ + + + Advanced analytics + + A premium feature gated by your local billing state. + + + +
+
+ {MONTHLY_CHART_BARS.map((bar) => ( +
+ ))} +
+ {!analyticsUnlocked && ( +
+ + {billing.isPastDue ? ( + <> +

+ Paused — payment failed +

+ + + ) : ( + <> +

+ Upgrade to Pro to unlock +

+ + + )} +
+ )} +
+ + +
+ ); +} diff --git a/examples/webhooks/app/(dashboard)/dashboard/security/page.tsx b/examples/webhooks/app/(dashboard)/dashboard/security/page.tsx new file mode 100644 index 00000000..0ca90554 --- /dev/null +++ b/examples/webhooks/app/(dashboard)/dashboard/security/page.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useActionState } from "react"; +import { deleteAccount, updatePassword } from "@/actions/auth"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useFormToast } from "@/hooks/use-form-toast"; + +type PasswordState = { + currentPassword?: string; + newPassword?: string; + confirmPassword?: string; + error?: string; + success?: string; + fieldErrors?: Record; +}; + +type DeleteState = { + password?: string; + error?: string; + success?: string; + fieldErrors?: Record; +}; + +export default function SecurityPage() { + const [passwordState, passwordAction, isPasswordPending] = useActionState< + PasswordState, + FormData + >(updatePassword, {}); + + const [deleteState, deleteAction, isDeletePending] = useActionState< + DeleteState, + FormData + >(deleteAccount, {}); + + useFormToast(passwordState); + useFormToast(deleteState); + + return ( +
+
+

Security

+

+ Manage your password and account security. +

+
+ + + + Change Password + Update your account password. + + +
+
+ + + {passwordState.fieldErrors?.currentPassword?.[0] && ( +

+ {passwordState.fieldErrors.currentPassword[0]} +

+ )} +
+
+ + + {passwordState.fieldErrors?.newPassword?.[0] && ( +

+ {passwordState.fieldErrors.newPassword[0]} +

+ )} +
+
+ + + {passwordState.fieldErrors?.confirmPassword?.[0] && ( +

+ {passwordState.fieldErrors.confirmPassword[0]} +

+ )} +
+ +
+
+
+ + + + Delete Account + + Permanently delete your account. This action cannot be undone. + + + +
+
+ + + {deleteState.fieldErrors?.password?.[0] && ( +

+ {deleteState.fieldErrors.password[0]} +

+ )} +
+ +
+
+
+
+ ); +} diff --git a/examples/webhooks/app/(dashboard)/layout.tsx b/examples/webhooks/app/(dashboard)/layout.tsx new file mode 100644 index 00000000..4e68f562 --- /dev/null +++ b/examples/webhooks/app/(dashboard)/layout.tsx @@ -0,0 +1,16 @@ +import { redirect } from "next/navigation"; +import { getUser } from "@/lib/auth/session"; + +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getUser(); + + if (!user) { + redirect("/sign-in"); + } + + return <>{children}; +} diff --git a/examples/webhooks/app/api/auth/[...all]/route.ts b/examples/webhooks/app/api/auth/[...all]/route.ts new file mode 100644 index 00000000..57d84926 --- /dev/null +++ b/examples/webhooks/app/api/auth/[...all]/route.ts @@ -0,0 +1,6 @@ +import { toNextJsHandler } from "better-auth/next-js"; +import { auth } from "@/lib/auth/auth"; + +const { GET, POST } = toNextJsHandler(auth); + +export { GET, POST }; diff --git a/examples/webhooks/app/api/commet/portal/route.ts b/examples/webhooks/app/api/commet/portal/route.ts new file mode 100644 index 00000000..bca4f23b --- /dev/null +++ b/examples/webhooks/app/api/commet/portal/route.ts @@ -0,0 +1,10 @@ +import { CustomerPortal } from "@commet/next"; +import { auth } from "@/lib/auth/auth"; + +export const GET = CustomerPortal({ + apiKey: process.env.COMMET_API_KEY!, + getCustomerId: async (req) => { + const session = await auth.api.getSession({ headers: req.headers }); + return session?.user.id ?? null; + }, +}); diff --git a/examples/webhooks/app/api/webhooks/commet/route.ts b/examples/webhooks/app/api/webhooks/commet/route.ts new file mode 100644 index 00000000..010040a2 --- /dev/null +++ b/examples/webhooks/app/api/webhooks/commet/route.ts @@ -0,0 +1,58 @@ +import { Webhooks } from "@commet/next"; +import { type NextRequest, NextResponse } from "next/server"; +import { + activateSubscriptionForUser, + applyPlanChangeForUser, + clearPastDueForUser, + deactivateSubscriptionForUser, + markPaymentFailedForUser, +} from "@/lib/billing/sync"; +import { recordWebhookEvent } from "@/lib/billing/webhook-events"; +import { sendWelcomeEmail } from "@/lib/notifications/email"; +import { notifyTeamOfNewSubscription } from "@/lib/notifications/team"; + +export async function POST(request: NextRequest) { + const webhookSecret = process.env.COMMET_WEBHOOK_SECRET; + if (!webhookSecret) { + return NextResponse.json( + { received: false, error: "COMMET_WEBHOOK_SECRET is not set" }, + { status: 500 }, + ); + } + + const handleCommetWebhook = Webhooks({ + webhookSecret, + + onPayload: recordWebhookEvent, + + onSubscriptionActivated: async (payload) => { + const activatedUser = await activateSubscriptionForUser(payload.data); + await Promise.all([ + sendWelcomeEmail(activatedUser), + notifyTeamOfNewSubscription(activatedUser), + ]); + }, + + onSubscriptionCanceled: async (payload) => { + await deactivateSubscriptionForUser(payload.data); + }, + + onSubscriptionPlanChanged: async (payload) => { + await applyPlanChangeForUser(payload.data); + }, + + onPaymentFailed: async (payload) => { + await markPaymentFailedForUser(payload.data); + }, + + onPaymentReceived: async (payload) => { + await clearPastDueForUser(payload.data); + }, + + onError: async (error, payload) => { + console.error("[commet-webhook] handler failed", { error, payload }); + }, + }); + + return handleCommetWebhook(request); +} diff --git a/examples/webhooks/app/checkout/page.tsx b/examples/webhooks/app/checkout/page.tsx new file mode 100644 index 00000000..f6190a73 --- /dev/null +++ b/examples/webhooks/app/checkout/page.tsx @@ -0,0 +1,35 @@ +import { redirect } from "next/navigation"; +import { createCheckoutSession } from "@/lib/payments/commet"; + +type CheckoutPageProps = { + searchParams: Promise<{ + planCode?: string | string[]; + }>; +}; + +function normalizePlanCode( + planCode: string | string[] | undefined, +): string | null { + if (!planCode) return null; + if (Array.isArray(planCode)) { + return planCode[0] ?? null; + } + return planCode.trim() || null; +} + +export default async function CheckoutPage({ + searchParams, +}: CheckoutPageProps) { + const params = await searchParams; + const planCode = normalizePlanCode(params?.planCode); + + if (!planCode) { + redirect("/pricing?error=missing_plan"); + } + + // Build success URL for post-checkout redirect + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3008"; + const successUrl = `${baseUrl}/dashboard`; + + await createCheckoutSession({ planCode, successUrl }); +} diff --git a/examples/webhooks/app/globals.css b/examples/webhooks/app/globals.css new file mode 100644 index 00000000..e37ed8d9 --- /dev/null +++ b/examples/webhooks/app/globals.css @@ -0,0 +1,132 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(0.98 0.012 65); + --foreground: oklch(0.17 0.018 52); + --card: oklch(0.98 0.012 65); + --card-foreground: oklch(0.17 0.018 52); + --popover: oklch(0.98 0.012 65); + --popover-foreground: oklch(0.17 0.018 52); + --primary: oklch(0.2 0.018 52); + --primary-foreground: oklch(0.97 0.008 65); + --secondary: oklch(0.95 0.014 65); + --secondary-foreground: oklch(0.28 0.028 52); + --muted: oklch(0.95 0.014 65); + --muted-foreground: oklch(0.52 0.022 57); + --accent: oklch(0.95 0.014 65); + --accent-foreground: oklch(0.28 0.028 52); + --destructive: oklch(0.55 0.22 27); + --destructive-foreground: oklch(0.98 0.01 27); + --border: oklch(0.89 0.017 62); + --input: oklch(0.89 0.017 62); + --ring: oklch(0.2 0.018 52); + --chart-1: oklch(0.6 0.16 45); + --chart-2: oklch(0.6 0.1 175); + --chart-3: oklch(0.75 0.15 80); + --chart-4: oklch(0.45 0.12 35); + --chart-5: oklch(0.65 0.12 15); + --radius: 0px; + --sidebar-background: oklch(0.97 0.014 65); + --sidebar-foreground: oklch(0.17 0.018 52); + --sidebar-primary: oklch(0.2 0.018 52); + --sidebar-primary-foreground: oklch(0.97 0.008 65); + --sidebar-accent: oklch(0.95 0.014 65); + --sidebar-accent-foreground: oklch(0.28 0.028 52); + --sidebar-border: oklch(0.89 0.017 62); + --sidebar-ring: oklch(0.2 0.018 52); +} + +.dark { + --background: oklch(0.155 0.015 55); + --foreground: oklch(0.93 0.01 75); + --card: oklch(0.155 0.015 55); + --card-foreground: oklch(0.93 0.01 75); + --popover: oklch(0.155 0.015 55); + --popover-foreground: oklch(0.93 0.01 75); + --primary: oklch(0.93 0.015 60); + --primary-foreground: oklch(0.155 0.015 55); + --secondary: oklch(0.24 0.015 55); + --secondary-foreground: oklch(0.93 0.01 75); + --muted: oklch(0.24 0.015 55); + --muted-foreground: oklch(0.65 0.015 60); + --accent: oklch(0.24 0.015 55); + --accent-foreground: oklch(0.93 0.01 75); + --destructive: oklch(0.4 0.14 27); + --destructive-foreground: oklch(0.65 0.22 27); + --border: oklch(0.28 0.015 55); + --input: oklch(0.28 0.015 55); + --ring: oklch(0.6 0.12 50); + --chart-1: oklch(0.65 0.16 45); + --chart-2: oklch(0.65 0.1 175); + --chart-3: oklch(0.72 0.14 80); + --chart-4: oklch(0.55 0.12 35); + --chart-5: oklch(0.62 0.14 15); + --sidebar-background: oklch(0.185 0.015 55); + --sidebar-foreground: oklch(0.93 0.01 75); + --sidebar-primary: oklch(0.93 0.015 60); + --sidebar-primary-foreground: oklch(0.155 0.015 55); + --sidebar-accent: oklch(0.24 0.015 55); + --sidebar-accent-foreground: oklch(0.93 0.01 75); + --sidebar-border: oklch(0.28 0.015 55); + --sidebar-ring: oklch(0.5 0.1 50); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar-background); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} diff --git a/examples/webhooks/app/layout.tsx b/examples/webhooks/app/layout.tsx new file mode 100644 index 00000000..445e30df --- /dev/null +++ b/examples/webhooks/app/layout.tsx @@ -0,0 +1,50 @@ +import { Analytics } from "@vercel/analytics/next"; +import "./globals.css"; +import type { Metadata, Viewport } from "next"; +import { IBM_Plex_Sans } from "next/font/google"; +import { Toaster } from "sonner"; +import { TemplateHeader } from "@/components/template-header"; + +export const metadata: Metadata = { + title: "Webhooks SaaS", + description: + "Webhook-driven SaaS template with subscription billing via Commet.", + icons: { + icon: [ + { + media: "(prefers-color-scheme: light)", + url: "/favicon-light.svg", + }, + { + media: "(prefers-color-scheme: dark)", + url: "/favicon-dark.svg", + }, + ], + }, +}; + +export const viewport: Viewport = { + maximumScale: 1, +}; + +const ibmPlexSans = IBM_Plex_Sans({ + subsets: ["latin"], + weight: ["300", "400", "500", "600"], +}); + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + + ); +} diff --git a/examples/webhooks/app/not-found.tsx b/examples/webhooks/app/not-found.tsx new file mode 100644 index 00000000..c7822c97 --- /dev/null +++ b/examples/webhooks/app/not-found.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export default function NotFound() { + return ( +
+

404

+

Page not found.

+ +
+ ); +} diff --git a/examples/webhooks/app/page.tsx b/examples/webhooks/app/page.tsx new file mode 100644 index 00000000..ce3bc51a --- /dev/null +++ b/examples/webhooks/app/page.tsx @@ -0,0 +1,74 @@ +import { ArrowRight, Bell, Mail, Webhook } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +function FeatureCard({ + icon: Icon, + title, + description, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + description: string; +}) { + return ( +
+ +

{title}

+

{description}

+
+ ); +} + +export default function HomePage() { + return ( +
+
+
+

+ Webhooks Template +

+

+ Commet notifies, your app reacts. Provisioning, dunning UX, and + local entitlements kept in sync by webhook events. +

+
+ + +
+
+ +
+ + + +
+
+ +
+ Built with Commet, Better Auth, and Next.js. +
+
+ ); +} diff --git a/examples/webhooks/app/pricing.md/route.ts b/examples/webhooks/app/pricing.md/route.ts new file mode 100644 index 00000000..47b2fc6e --- /dev/null +++ b/examples/webhooks/app/pricing.md/route.ts @@ -0,0 +1,8 @@ +import { PricingMarkdown } from "@commet/next"; + +export const GET = PricingMarkdown({ + apiKey: process.env.COMMET_API_KEY!, + title: "Webhooks SaaS Pricing", + description: + "Simple subscription pricing. Choose the plan that works for you. No hidden fees, no surprises.", +}); diff --git a/examples/webhooks/app/pricing/page.tsx b/examples/webhooks/app/pricing/page.tsx new file mode 100644 index 00000000..5f8baca1 --- /dev/null +++ b/examples/webhooks/app/pricing/page.tsx @@ -0,0 +1,141 @@ +import type { BillingInterval, Plan } from "@commet/node"; + +type PlanFeatureItem = NonNullable[number]; + +import { Check } from "lucide-react"; +import { redirect } from "next/navigation"; +import { getPlansAction } from "@/actions/plans"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { getUser } from "@/lib/auth/session"; +import { commet } from "@/lib/commet"; +import { checkoutAction } from "@/lib/payments/actions"; + +function formatPrice(cents: number) { + return `$${(cents / 100).toFixed(0)}`; +} + +function formatBillingInterval(interval: BillingInterval): string { + if (interval === "weekly") return "wk"; + if (interval === "monthly") return "mo"; + if (interval === "yearly") return "yr"; + if (interval === "quarterly") return "qtr"; + return interval; +} + +function formatFeature(feature: PlanFeatureItem): string { + if (feature.type === "boolean" && feature.enabled) { + return feature.name; + } + if (feature.type === "usage" && feature.includedAmount !== null) { + return `${feature.includedAmount.toLocaleString()} ${feature.name} included`; + } + if (feature.type === "seats" && feature.includedAmount !== null) { + return `${feature.includedAmount} ${feature.name}`; + } + return feature.name; +} + +export default async function PricingPage() { + const user = await getUser(); + if (user) { + const subscriptionResult = await commet.subscriptions.getActive({ + customerId: user.id, + }); + if (subscriptionResult.success && subscriptionResult.data) { + const subscription = subscriptionResult.data; + if ( + subscription.status === "pending_payment" && + subscription.checkoutUrl + ) { + redirect(subscription.checkoutUrl); + } + if ( + subscription.status === "active" || + subscription.status === "trialing" + ) { + redirect("/api/commet/portal"); + } + } + } + + const plansResult = await getPlansAction(); + const plans = plansResult.data || []; + + return ( +
+
+

Pricing

+

+ Simple, fixed pricing. Choose the plan that works for you. No hidden + fees, no surprises. +

+
+ + {plans.length === 0 ? ( +

+ No plans available yet. Configure plans in your Commet dashboard. +

+ ) : ( +
+ {plans.map((plan) => { + const defaultPrice = + plan.prices?.find((p) => p.isDefault) || plan.prices?.[0]; + + return ( + + + {plan.name} + {plan.description && ( + {plan.description} + )} + + +
+ + {defaultPrice ? formatPrice(defaultPrice.price) : "Free"} + + {defaultPrice && ( + + / + {formatBillingInterval( + defaultPrice.billingInterval || "monthly", + )} + + )} +
+
    + {plan.features?.map((feature) => ( +
  • + + {formatFeature(feature)} +
  • + ))} +
+
+ +
+ + +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/examples/webhooks/components.json b/examples/webhooks/components.json new file mode 100644 index 00000000..f382eb79 --- /dev/null +++ b/examples/webhooks/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/examples/webhooks/components/auto-refresh.tsx b/examples/webhooks/components/auto-refresh.tsx new file mode 100644 index 00000000..744334cf --- /dev/null +++ b/examples/webhooks/components/auto-refresh.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export function AutoRefresh({ seconds }: { seconds: number }) { + const router = useRouter(); + + useEffect(() => { + const interval = setInterval(() => router.refresh(), seconds * 1000); + return () => clearInterval(interval); + }, [router, seconds]); + + return null; +} diff --git a/examples/webhooks/components/past-due-banner.tsx b/examples/webhooks/components/past-due-banner.tsx new file mode 100644 index 00000000..a0236f2f --- /dev/null +++ b/examples/webhooks/components/past-due-banner.tsx @@ -0,0 +1,27 @@ +import { AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export function PastDueBanner() { + return ( +
+
+ +

+ Your last payment failed.{" "} + + Premium features are paused until payment succeeds. + +

+
+ +
+ ); +} diff --git a/examples/webhooks/components/shared/submit-button.tsx b/examples/webhooks/components/shared/submit-button.tsx new file mode 100644 index 00000000..312f05e5 --- /dev/null +++ b/examples/webhooks/components/shared/submit-button.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { Button as ButtonPrimitive } from "@base-ui/react/button"; +import type { VariantProps } from "class-variance-authority"; +import { Loader2 } from "lucide-react"; +import type { ReactNode } from "react"; +import type { buttonVariants } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; + +interface SubmitButtonProps + extends ButtonPrimitive.Props, + VariantProps { + isPending: boolean; + pendingText?: string; + icon?: ReactNode; +} + +export function SubmitButton({ + children, + isPending, + pendingText = "Saving...", + icon, + ...props +}: SubmitButtonProps) { + return ( + + ); +} diff --git a/examples/webhooks/components/template-header.tsx b/examples/webhooks/components/template-header.tsx new file mode 100644 index 00000000..e3a520b0 --- /dev/null +++ b/examples/webhooks/components/template-header.tsx @@ -0,0 +1,69 @@ +import { ExternalLink, Github } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; + +function CommetLogo() { + return ( + + + + + + ); +} + +export function TemplateHeader({ templateName }: { templateName: string }) { + return ( +
+
+ + + + + + {templateName} + +
+
+ + +
+
+ ); +} diff --git a/examples/webhooks/components/ui/avatar.tsx b/examples/webhooks/components/ui/avatar.tsx new file mode 100644 index 00000000..173070bb --- /dev/null +++ b/examples/webhooks/components/ui/avatar.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg"; +}) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ); +} + +export { Avatar, AvatarFallback, AvatarImage }; diff --git a/examples/webhooks/components/ui/button.tsx b/examples/webhooks/components/ui/button.tsx new file mode 100644 index 00000000..75f141da --- /dev/null +++ b/examples/webhooks/components/ui/button.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Button as ButtonPrimitive } from "@base-ui/react/button"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/examples/webhooks/components/ui/card.tsx b/examples/webhooks/components/ui/card.tsx new file mode 100644 index 00000000..29fa9097 --- /dev/null +++ b/examples/webhooks/components/ui/card.tsx @@ -0,0 +1,103 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +}; diff --git a/examples/webhooks/components/ui/form-field.tsx b/examples/webhooks/components/ui/form-field.tsx new file mode 100644 index 00000000..00285884 --- /dev/null +++ b/examples/webhooks/components/ui/form-field.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +interface FormFieldProps extends ComponentProps<"input"> { + label: string; + error?: string; + description?: string; +} + +export function FormField({ + label, + error, + description, + id, + className, + ...props +}: FormFieldProps) { + return ( +
+ + + {description && !error && ( +

{description}

+ )} + {error &&

{error}

} +
+ ); +} diff --git a/examples/webhooks/components/ui/input.tsx b/examples/webhooks/components/ui/input.tsx new file mode 100644 index 00000000..3f9abd53 --- /dev/null +++ b/examples/webhooks/components/ui/input.tsx @@ -0,0 +1,20 @@ +import { Input as InputPrimitive } from "@base-ui/react/input"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/examples/webhooks/components/ui/label.tsx b/examples/webhooks/components/ui/label.tsx new file mode 100644 index 00000000..bad18134 --- /dev/null +++ b/examples/webhooks/components/ui/label.tsx @@ -0,0 +1,21 @@ +"use client"; + +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( + // biome-ignore lint/a11y/noLabelWithoutControl: htmlFor passed via props +
); } diff --git a/examples/webhooks/lib/billing/sync.ts b/examples/webhooks/lib/billing/sync.ts index 4e2afe39..06fb411c 100644 --- a/examples/webhooks/lib/billing/sync.ts +++ b/examples/webhooks/lib/billing/sync.ts @@ -11,8 +11,7 @@ export function readExternalUserId(data: WebhookData): string | null { ]; return ( candidates.find( - (value): value is string => - typeof value === "string" && value.length > 0, + (value): value is string => typeof value === "string" && value.length > 0, ) ?? null ); } diff --git a/examples/webhooks/public/favicon-dark.svg b/examples/webhooks/public/favicon-dark.svg index 6d40c389..595dc670 100644 --- a/examples/webhooks/public/favicon-dark.svg +++ b/examples/webhooks/public/favicon-dark.svg @@ -4,4 +4,3 @@ - diff --git a/examples/webhooks/public/favicon-light.svg b/examples/webhooks/public/favicon-light.svg index 36280bfd..9459b8b6 100644 --- a/examples/webhooks/public/favicon-light.svg +++ b/examples/webhooks/public/favicon-light.svg @@ -4,4 +4,3 @@ - From faa405bb4b9b908ebbaeab348f12a1b0407b8201 Mon Sep 17 00:00:00 2001 From: Franco Jalil Date: Mon, 22 Jun 2026 13:09:59 -0300 Subject: [PATCH 6/7] fix(webhooks): harden example review issues --- examples/webhooks/actions/auth.ts | 39 +--- examples/webhooks/app/(auth)/sign-in/page.tsx | 11 +- examples/webhooks/app/(auth)/sign-up/page.tsx | 11 +- .../(dashboard)/dashboard/security/page.tsx | 50 +---- .../webhooks/app/api/commet/portal/route.ts | 3 +- .../webhooks/app/api/webhooks/commet/route.ts | 75 ++++---- examples/webhooks/app/checkout/page.tsx | 16 +- examples/webhooks/app/pricing.md/route.ts | 3 +- examples/webhooks/drizzle.config.ts | 7 +- examples/webhooks/hooks/use-form-toast.ts | 1 - examples/webhooks/lib/auth/auth-client.ts | 1 - examples/webhooks/lib/auth/auth.ts | 12 +- .../webhooks/lib/billing/webhook-events.ts | 77 +++++++- examples/webhooks/lib/commet.ts | 5 +- examples/webhooks/lib/db/drizzle.ts | 7 +- examples/webhooks/lib/db/schema.ts | 26 ++- examples/webhooks/lib/env.ts | 25 +++ examples/webhooks/lib/notifications/email.ts | 15 +- examples/webhooks/lib/payments/actions.ts | 16 +- examples/webhooks/lib/payments/commet.ts | 23 ++- examples/webhooks/lib/plans.ts | 14 ++ examples/webhooks/lib/redirects.ts | 29 +++ examples/webhooks/lib/validations/auth.ts | 4 - packages/next/src/types.ts | 180 ------------------ packages/next/src/webhooks.test.ts | 11 +- packages/next/src/webhooks.ts | 16 +- 26 files changed, 289 insertions(+), 388 deletions(-) create mode 100644 examples/webhooks/lib/env.ts create mode 100644 examples/webhooks/lib/plans.ts create mode 100644 examples/webhooks/lib/redirects.ts diff --git a/examples/webhooks/actions/auth.ts b/examples/webhooks/actions/auth.ts index a5f84554..0b01fff4 100644 --- a/examples/webhooks/actions/auth.ts +++ b/examples/webhooks/actions/auth.ts @@ -14,8 +14,8 @@ import { type NewActivityLog, user, } from "@/lib/db/schema"; +import { safeRedirectPath } from "@/lib/redirects"; import { - deleteAccountSchema, updateAccountSchema, updatePasswordSchema, } from "@/lib/validations/auth"; @@ -39,19 +39,20 @@ async function logActivity( const newActivity: NewActivityLog = { userId, action: type, - ipAddress: ipAddress || "", + ipAddress, }; await db.insert(activityLogs).values(newActivity); } export async function getPostAuthRedirect(fallback = "/dashboard") { + const redirectPath = safeRedirectPath(fallback); const currentUser = await getUser(); if (!currentUser) { return "/sign-in"; } - if (fallback !== "/dashboard") { - return fallback; + if (redirectPath !== "/dashboard") { + return redirectPath; } const billing = await getBillingStateForUser(currentUser.id); @@ -147,33 +148,3 @@ export async function updatePassword( return { error: "Failed to update password" }; } } - -export async function deleteAccount( - _prevState: ActionState, - formData: FormData, -): Promise { - const currentUser = await getUser(); - if (!currentUser) { - return { error: "Not authenticated" }; - } - - const rawData = { - password: formData.get("password") as string, - }; - - const result = deleteAccountSchema.safeParse(rawData); - if (!result.success) { - return { - error: "Validation failed", - fieldErrors: result.error.flatten().fieldErrors, - }; - } - - // Log activity before deletion - await logActivity(currentUser.id, ActivityType.DELETE_ACCOUNT); - - // Delete user (cascade will handle related records) - await db.delete(user).where(eq(user.id, currentUser.id)); - - redirect("/sign-in"); -} diff --git a/examples/webhooks/app/(auth)/sign-in/page.tsx b/examples/webhooks/app/(auth)/sign-in/page.tsx index ab6f430c..055a9268 100644 --- a/examples/webhooks/app/(auth)/sign-in/page.tsx +++ b/examples/webhooks/app/(auth)/sign-in/page.tsx @@ -16,12 +16,14 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { signIn } from "@/lib/auth/auth-client"; import { handlePostSignupCheckout } from "@/lib/payments/actions"; +import { normalizePlanCode } from "@/lib/plans"; +import { buildInternalHref, safeRedirectPath } from "@/lib/redirects"; function SignInContent() { const router = useRouter(); const searchParams = useSearchParams(); - const redirect = searchParams.get("redirect") || "/dashboard"; - const planCode = searchParams.get("planCode") || undefined; + const redirect = safeRedirectPath(searchParams.get("redirect")); + const planCode = normalizePlanCode(searchParams.get("planCode")); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -60,7 +62,10 @@ function SignInContent() { } const signUpHref = planCode - ? `/sign-up?planCode=${planCode}${redirect !== "/dashboard" ? `&redirect=${redirect}` : ""}` + ? buildInternalHref("/sign-up", { + planCode, + redirect: redirect !== "/dashboard" ? redirect : null, + }) : "/sign-up"; return ( diff --git a/examples/webhooks/app/(auth)/sign-up/page.tsx b/examples/webhooks/app/(auth)/sign-up/page.tsx index 97586948..0b98397b 100644 --- a/examples/webhooks/app/(auth)/sign-up/page.tsx +++ b/examples/webhooks/app/(auth)/sign-up/page.tsx @@ -16,12 +16,14 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { signUp } from "@/lib/auth/auth-client"; import { handlePostSignupCheckout } from "@/lib/payments/actions"; +import { normalizePlanCode } from "@/lib/plans"; +import { buildInternalHref, safeRedirectPath } from "@/lib/redirects"; function SignUpContent() { const router = useRouter(); const searchParams = useSearchParams(); - const redirect = searchParams.get("redirect") || "/dashboard"; - const planCode = searchParams.get("planCode") || undefined; + const redirect = safeRedirectPath(searchParams.get("redirect")); + const planCode = normalizePlanCode(searchParams.get("planCode")); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -61,7 +63,10 @@ function SignUpContent() { } const signInHref = planCode - ? `/sign-in?planCode=${planCode}${redirect !== "/dashboard" ? `&redirect=${redirect}` : ""}` + ? buildInternalHref("/sign-in", { + planCode, + redirect: redirect !== "/dashboard" ? redirect : null, + }) : "/sign-in"; return ( diff --git a/examples/webhooks/app/(dashboard)/dashboard/security/page.tsx b/examples/webhooks/app/(dashboard)/dashboard/security/page.tsx index 0ca90554..00dbbfc2 100644 --- a/examples/webhooks/app/(dashboard)/dashboard/security/page.tsx +++ b/examples/webhooks/app/(dashboard)/dashboard/security/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useActionState } from "react"; -import { deleteAccount, updatePassword } from "@/actions/auth"; +import { updatePassword } from "@/actions/auth"; import { Button } from "@/components/ui/button"; import { Card, @@ -23,26 +23,13 @@ type PasswordState = { fieldErrors?: Record; }; -type DeleteState = { - password?: string; - error?: string; - success?: string; - fieldErrors?: Record; -}; - export default function SecurityPage() { const [passwordState, passwordAction, isPasswordPending] = useActionState< PasswordState, FormData >(updatePassword, {}); - const [deleteState, deleteAction, isDeletePending] = useActionState< - DeleteState, - FormData - >(deleteAccount, {}); - useFormToast(passwordState); - useFormToast(deleteState); return (
@@ -113,41 +100,6 @@ export default function SecurityPage() { - - - - Delete Account - - Permanently delete your account. This action cannot be undone. - - - -
-
- - - {deleteState.fieldErrors?.password?.[0] && ( -

- {deleteState.fieldErrors.password[0]} -

- )} -
- -
-
-
); } diff --git a/examples/webhooks/app/api/commet/portal/route.ts b/examples/webhooks/app/api/commet/portal/route.ts index bca4f23b..d3db0a03 100644 --- a/examples/webhooks/app/api/commet/portal/route.ts +++ b/examples/webhooks/app/api/commet/portal/route.ts @@ -1,8 +1,9 @@ import { CustomerPortal } from "@commet/next"; import { auth } from "@/lib/auth/auth"; +import { env } from "@/lib/env"; export const GET = CustomerPortal({ - apiKey: process.env.COMMET_API_KEY!, + apiKey: env.COMMET_API_KEY, getCustomerId: async (req) => { const session = await auth.api.getSession({ headers: req.headers }); return session?.user.id ?? null; diff --git a/examples/webhooks/app/api/webhooks/commet/route.ts b/examples/webhooks/app/api/webhooks/commet/route.ts index 5dea18ed..22a8d8c1 100644 --- a/examples/webhooks/app/api/webhooks/commet/route.ts +++ b/examples/webhooks/app/api/webhooks/commet/route.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { type WebhookPayload, Webhooks } from "@commet/node"; import { type NextRequest, NextResponse } from "next/server"; import { @@ -7,19 +8,16 @@ import { restoreAccessAfterPayment, syncBillingStateFromSnapshot, } from "@/lib/billing/sync"; -import { recordWebhookEvent } from "@/lib/billing/webhook-events"; +import { + markWebhookEventCompleted, + markWebhookEventFailed, + recordWebhookEvent, +} from "@/lib/billing/webhook-events"; +import { env } from "@/lib/env"; import { sendWelcomeEmail } from "@/lib/notifications/email"; import { notifyTeamOfNewSubscription } from "@/lib/notifications/team"; export async function POST(request: NextRequest) { - const webhookSecret = process.env.COMMET_WEBHOOK_SECRET; - if (!webhookSecret) { - return NextResponse.json( - { received: false, error: "COMMET_WEBHOOK_SECRET is not set" }, - { status: 500 }, - ); - } - const webhooks = new Webhooks(); const rawBody = await request.text(); const signature = request.headers.get("x-commet-signature"); @@ -27,7 +25,7 @@ export async function POST(request: NextRequest) { const isValid = webhooks.verify({ payload: rawBody, signature, - secret: webhookSecret, + secret: env.COMMET_WEBHOOK_SECRET, }); if (!isValid) { @@ -48,51 +46,64 @@ export async function POST(request: NextRequest) { ); } - const handlers: Promise[] = [recordWebhookEvent(payload)]; + const eventId = createHash("sha256").update(rawBody).digest("hex"); + const insertResult = await recordWebhookEvent({ eventId, payload }); + if (insertResult === "completed") { + return NextResponse.json({ received: true, duplicate: true }); + } + if (insertResult === "processing") { + return NextResponse.json( + { received: false, error: "Event is already processing" }, + { status: 500 }, + ); + } + + const criticalHandlers: Promise[] = []; + const notificationHandlers: Promise[] = []; switch (payload.event) { case "customer.state_changed": - handlers.push( - syncBillingStateFromSnapshot(payload.data).then(async () => { - if (payload.data.trigger !== "subscription_activated") { - return; - } - - const activatedUser = await resolveActivatedUserForWelcome( - payload.data, - ); - await Promise.all([ - sendWelcomeEmail(activatedUser), - notifyTeamOfNewSubscription(activatedUser), - ]); - }), - ); + criticalHandlers.push(syncBillingStateFromSnapshot(payload.data)); + if (payload.data.trigger === "subscription_activated") { + notificationHandlers.push( + resolveActivatedUserForWelcome(payload.data).then((activatedUser) => + Promise.all([ + sendWelcomeEmail(activatedUser), + notifyTeamOfNewSubscription(activatedUser), + ]).then(() => undefined), + ), + ); + } break; case "subscription.activated": case "subscription.reactivated": - handlers.push(recordCurrentPeriod(payload.data)); + criticalHandlers.push(recordCurrentPeriod(payload.data)); break; case "payment.received": case "payment.recovered": - handlers.push(restoreAccessAfterPayment(payload.data)); + criticalHandlers.push(restoreAccessAfterPayment(payload.data)); break; case "usage.recorded": - handlers.push(recordUsageFromEvent(payload.data)); + criticalHandlers.push(recordUsageFromEvent(payload.data)); break; } try { - await Promise.all(handlers); + await Promise.all(criticalHandlers); } catch (error) { + await markWebhookEventFailed(eventId); console.error("[commet-webhook] handler failed", { error, payload }); return NextResponse.json( - { received: true, warning: "Handler failed" }, - { status: 200 }, + { received: false, error: "Handler failed" }, + { status: 500 }, ); } + await markWebhookEventCompleted(eventId); + await Promise.allSettled(notificationHandlers); + return NextResponse.json({ received: true }, { status: 200 }); } diff --git a/examples/webhooks/app/checkout/page.tsx b/examples/webhooks/app/checkout/page.tsx index f6190a73..aa3e2367 100644 --- a/examples/webhooks/app/checkout/page.tsx +++ b/examples/webhooks/app/checkout/page.tsx @@ -1,5 +1,7 @@ import { redirect } from "next/navigation"; +import { env } from "@/lib/env"; import { createCheckoutSession } from "@/lib/payments/commet"; +import { normalizePlanCode } from "@/lib/plans"; type CheckoutPageProps = { searchParams: Promise<{ @@ -7,29 +9,25 @@ type CheckoutPageProps = { }>; }; -function normalizePlanCode( - planCode: string | string[] | undefined, -): string | null { +function readPlanCode(planCode: string | string[] | undefined): string | null { if (!planCode) return null; if (Array.isArray(planCode)) { - return planCode[0] ?? null; + return normalizePlanCode(planCode[0] ?? null); } - return planCode.trim() || null; + return normalizePlanCode(planCode); } export default async function CheckoutPage({ searchParams, }: CheckoutPageProps) { const params = await searchParams; - const planCode = normalizePlanCode(params?.planCode); + const planCode = readPlanCode(params?.planCode); if (!planCode) { redirect("/pricing?error=missing_plan"); } - // Build success URL for post-checkout redirect - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3008"; - const successUrl = `${baseUrl}/dashboard`; + const successUrl = `${env.NEXT_PUBLIC_APP_URL}/dashboard`; await createCheckoutSession({ planCode, successUrl }); } diff --git a/examples/webhooks/app/pricing.md/route.ts b/examples/webhooks/app/pricing.md/route.ts index 47b2fc6e..119500f8 100644 --- a/examples/webhooks/app/pricing.md/route.ts +++ b/examples/webhooks/app/pricing.md/route.ts @@ -1,7 +1,8 @@ import { PricingMarkdown } from "@commet/next"; +import { env } from "@/lib/env"; export const GET = PricingMarkdown({ - apiKey: process.env.COMMET_API_KEY!, + apiKey: env.COMMET_API_KEY, title: "Webhooks SaaS Pricing", description: "Simple subscription pricing. Choose the plan that works for you. No hidden fees, no surprises.", diff --git a/examples/webhooks/drizzle.config.ts b/examples/webhooks/drizzle.config.ts index c8aa7974..9172f5af 100644 --- a/examples/webhooks/drizzle.config.ts +++ b/examples/webhooks/drizzle.config.ts @@ -3,11 +3,16 @@ import { defineConfig } from "drizzle-kit"; dotenv.config(); +const postgresUrl = process.env.POSTGRES_URL; +if (!postgresUrl) { + throw new Error("POSTGRES_URL is required"); +} + export default defineConfig({ schema: "./lib/db/schema.ts", out: "./drizzle", dialect: "postgresql", dbCredentials: { - url: process.env.POSTGRES_URL || "", + url: postgresUrl, }, }); diff --git a/examples/webhooks/hooks/use-form-toast.ts b/examples/webhooks/hooks/use-form-toast.ts index 13f54dab..a53c225a 100644 --- a/examples/webhooks/hooks/use-form-toast.ts +++ b/examples/webhooks/hooks/use-form-toast.ts @@ -13,7 +13,6 @@ export function useFormToast(state: ActionState) { const prevStateRef = useRef(state); useEffect(() => { - // Only show toast when state changes if (state === prevStateRef.current) return; prevStateRef.current = state; diff --git a/examples/webhooks/lib/auth/auth-client.ts b/examples/webhooks/lib/auth/auth-client.ts index 81486427..1b83ca41 100644 --- a/examples/webhooks/lib/auth/auth-client.ts +++ b/examples/webhooks/lib/auth/auth-client.ts @@ -2,7 +2,6 @@ import { commetClient } from "@commet/better-auth/client"; import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ - baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3008", plugins: [commetClient()], }); diff --git a/examples/webhooks/lib/auth/auth.ts b/examples/webhooks/lib/auth/auth.ts index 87dfa3e0..221e34cf 100644 --- a/examples/webhooks/lib/auth/auth.ts +++ b/examples/webhooks/lib/auth/auth.ts @@ -9,11 +9,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { commet } from "../commet"; import { db } from "../db/drizzle"; import * as schema from "../db/schema"; - -const authSecret = - process.env.BETTER_AUTH_SECRET || - "build_time_placeholder_secret_change_in_production"; -const authUrl = process.env.BETTER_AUTH_URL || "http://localhost:3008"; +import { env } from "../env"; export const auth = betterAuth({ database: drizzleAdapter(db, { @@ -24,9 +20,9 @@ export const auth = betterAuth({ enabled: true, requireEmailVerification: false, }, - secret: authSecret, - baseURL: authUrl, - trustedOrigins: [authUrl], + secret: env.BETTER_AUTH_SECRET, + baseURL: env.BETTER_AUTH_URL, + trustedOrigins: [env.BETTER_AUTH_URL], plugins: [ commetPlugin({ client: commet, diff --git a/examples/webhooks/lib/billing/webhook-events.ts b/examples/webhooks/lib/billing/webhook-events.ts index 8fb74268..a857bb5c 100644 --- a/examples/webhooks/lib/billing/webhook-events.ts +++ b/examples/webhooks/lib/billing/webhook-events.ts @@ -4,7 +4,15 @@ import { db } from "@/lib/db/drizzle"; import { user, webhookEvents } from "@/lib/db/schema"; import { readExternalUserId } from "./sync"; -export async function recordWebhookEvent(payload: WebhookPayload) { +const staleProcessingWindowMs = 5 * 60 * 1000; + +export async function recordWebhookEvent({ + eventId, + payload, +}: { + eventId: string; + payload: WebhookPayload; +}): Promise<"inserted" | "completed" | "processing" | "retry"> { const externalUserId = readExternalUserId(payload.data); let localUserId: string | null = null; @@ -17,10 +25,65 @@ export async function recordWebhookEvent(payload: WebhookPayload) { localUserId = localUser?.id ?? null; } - await db.insert(webhookEvents).values({ - event: payload.event, - commetCustomerId: payload.data.customerId ?? null, - userId: localUserId, - payload, - }); + const [inserted] = await db + .insert(webhookEvents) + .values({ + eventId, + event: payload.event, + commetCustomerId: payload.data.customerId ?? null, + userId: localUserId, + payload, + }) + .onConflictDoNothing({ target: webhookEvents.eventId }) + .returning({ id: webhookEvents.id, status: webhookEvents.status }); + + if (inserted) { + return "inserted"; + } + + const [existing] = await db + .select({ + status: webhookEvents.status, + updatedAt: webhookEvents.updatedAt, + }) + .from(webhookEvents) + .where(eq(webhookEvents.eventId, eventId)) + .limit(1); + + if (existing?.status === "completed") { + return "completed"; + } + + const updatedAt = existing?.updatedAt; + if ( + existing?.status === "processing" && + updatedAt && + Date.now() - updatedAt.getTime() < staleProcessingWindowMs + ) { + return "processing"; + } + + await db + .update(webhookEvents) + .set({ status: "processing", updatedAt: new Date() }) + .where(eq(webhookEvents.eventId, eventId)); + return "retry"; +} + +export async function markWebhookEventCompleted(eventId: string) { + await db + .update(webhookEvents) + .set({ + status: "completed", + processedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(webhookEvents.eventId, eventId)); +} + +export async function markWebhookEventFailed(eventId: string) { + await db + .update(webhookEvents) + .set({ status: "failed", updatedAt: new Date() }) + .where(eq(webhookEvents.eventId, eventId)); } diff --git a/examples/webhooks/lib/commet.ts b/examples/webhooks/lib/commet.ts index e15e3944..b1b7f3e6 100644 --- a/examples/webhooks/lib/commet.ts +++ b/examples/webhooks/lib/commet.ts @@ -1,7 +1,6 @@ import { Commet } from "@commet/node"; - -const apiKey = process.env.COMMET_API_KEY || "ck_build_placeholder"; +import { env } from "@/lib/env"; export const commet = new Commet({ - apiKey, + apiKey: env.COMMET_API_KEY, }); diff --git a/examples/webhooks/lib/db/drizzle.ts b/examples/webhooks/lib/db/drizzle.ts index e5172240..f5846685 100644 --- a/examples/webhooks/lib/db/drizzle.ts +++ b/examples/webhooks/lib/db/drizzle.ts @@ -1,13 +1,10 @@ import dotenv from "dotenv"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; +import { env } from "@/lib/env"; import * as schema from "./schema"; dotenv.config(); -const url = - process.env.POSTGRES_URL || - "postgresql://postgres:postgres@localhost:54328/webhooks_saas"; - -export const client = postgres(url); +export const client = postgres(env.POSTGRES_URL); export const db = drizzle(client, { schema }); diff --git a/examples/webhooks/lib/db/schema.ts b/examples/webhooks/lib/db/schema.ts index a8df555c..24349be9 100644 --- a/examples/webhooks/lib/db/schema.ts +++ b/examples/webhooks/lib/db/schema.ts @@ -6,6 +6,7 @@ import { serial, text, timestamp, + uniqueIndex, varchar, } from "drizzle-orm/pg-core"; @@ -96,14 +97,22 @@ export const billingState = pgTable("billing_state", { updatedAt: timestamp("updated_at").notNull().defaultNow(), }); -export const webhookEvents = pgTable("webhook_events", { - id: serial("id").primaryKey(), - event: text("event").notNull(), - commetCustomerId: text("commet_customer_id"), - userId: text("user_id").references(() => user.id, { onDelete: "set null" }), - payload: jsonb("payload").notNull(), - receivedAt: timestamp("received_at").notNull().defaultNow(), -}); +export const webhookEvents = pgTable( + "webhook_events", + { + id: serial("id").primaryKey(), + eventId: text("event_id"), + event: text("event").notNull(), + status: text("status").notNull().default("processing"), + commetCustomerId: text("commet_customer_id"), + userId: text("user_id").references(() => user.id, { onDelete: "set null" }), + payload: jsonb("payload").notNull(), + processedAt: timestamp("processed_at"), + receivedAt: timestamp("received_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (table) => [uniqueIndex("webhook_events_event_id_idx").on(table.eventId)], +); export const userRelations = relations(user, ({ many, one }) => ({ sessions: many(session), @@ -161,6 +170,5 @@ export enum ActivityType { SIGN_IN = "SIGN_IN", SIGN_OUT = "SIGN_OUT", UPDATE_PASSWORD = "UPDATE_PASSWORD", - DELETE_ACCOUNT = "DELETE_ACCOUNT", UPDATE_ACCOUNT = "UPDATE_ACCOUNT", } diff --git a/examples/webhooks/lib/env.ts b/examples/webhooks/lib/env.ts new file mode 100644 index 00000000..6a8d9a5c --- /dev/null +++ b/examples/webhooks/lib/env.ts @@ -0,0 +1,25 @@ +import "server-only"; + +import { z } from "zod"; + +const optionalNonEmptyString = z.preprocess( + (value) => (value === "" ? undefined : value), + z.string().min(1).optional(), +); + +const envSchema = z.object({ + POSTGRES_URL: z.string().min(1, "POSTGRES_URL is required"), + BETTER_AUTH_SECRET: z + .string() + .min(32, "BETTER_AUTH_SECRET must be at least 32 characters"), + BETTER_AUTH_URL: z.string().url("BETTER_AUTH_URL must be a valid URL"), + COMMET_API_KEY: z.string().min(1, "COMMET_API_KEY is required"), + COMMET_WEBHOOK_SECRET: z.string().min(1, "COMMET_WEBHOOK_SECRET is required"), + NEXT_PUBLIC_APP_URL: z + .string() + .url("NEXT_PUBLIC_APP_URL must be a valid URL"), + RESEND_API_KEY: optionalNonEmptyString, + EMAIL_FROM: optionalNonEmptyString, +}); + +export const env = envSchema.parse(process.env); diff --git a/examples/webhooks/lib/notifications/email.ts b/examples/webhooks/lib/notifications/email.ts index a93550d1..76381380 100644 --- a/examples/webhooks/lib/notifications/email.ts +++ b/examples/webhooks/lib/notifications/email.ts @@ -1,4 +1,5 @@ import { Resend } from "resend"; +import { env } from "@/lib/env"; interface WelcomeEmailParams { userId: string; @@ -13,21 +14,17 @@ export async function sendWelcomeEmail({ userName, planName, }: WelcomeEmailParams) { - const resendApiKey = process.env.RESEND_API_KEY; - const emailFrom = process.env.EMAIL_FROM; - const appUrl = process.env.NEXT_PUBLIC_APP_URL; - - if (!resendApiKey || !emailFrom || !appUrl) { + if (!env.RESEND_API_KEY || !env.EMAIL_FROM) { console.warn( - "[email] RESEND_API_KEY, EMAIL_FROM or NEXT_PUBLIC_APP_URL not set — skipping welcome email", + "[email] RESEND_API_KEY or EMAIL_FROM not set; skipping welcome email", ); return; } - const resend = new Resend(resendApiKey); + const resend = new Resend(env.RESEND_API_KEY); const { data, error } = await resend.emails.send( { - from: emailFrom, + from: env.EMAIL_FROM, to: [userEmail], subject: `Welcome to ${planName}`, html: ` @@ -36,7 +33,7 @@ export async function sendWelcomeEmail({

Your subscription is active. Advanced analytics and your new project limits are already unlocked in the dashboard.

- + Go to dashboard
diff --git a/examples/webhooks/lib/payments/actions.ts b/examples/webhooks/lib/payments/actions.ts index b143932c..c752c8ee 100644 --- a/examples/webhooks/lib/payments/actions.ts +++ b/examples/webhooks/lib/payments/actions.ts @@ -2,16 +2,23 @@ import { redirect } from "next/navigation"; import { getUser } from "@/lib/auth/session"; +import { env } from "@/lib/env"; import { getCheckoutUrl } from "@/lib/payments/commet"; +import { normalizePlanCode } from "@/lib/plans"; +import { buildInternalHref } from "@/lib/redirects"; export async function checkoutAction(formData: FormData) { - const planCode = formData.get("planCode") as string; + const planCode = normalizePlanCode(formData.get("planCode")); + if (!planCode) { + redirect("/pricing?error=missing_plan"); + } + const user = await getUser(); if (!user) { - redirect(`/sign-up?planCode=${planCode}`); + redirect(buildInternalHref("/sign-up", { planCode })); } - redirect(`/checkout?planCode=${planCode}`); + redirect(buildInternalHref("/checkout", { planCode })); } export async function handlePostSignupCheckout(planCode: string) { @@ -23,8 +30,7 @@ export async function handlePostSignupCheckout(planCode: string) { return { success: false, error: "User not authenticated" } as const; } - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3008"; - const successUrl = `${baseUrl}/dashboard`; + const successUrl = `${env.NEXT_PUBLIC_APP_URL}/dashboard`; try { const checkoutUrl = await getCheckoutUrl({ planCode, successUrl }); diff --git a/examples/webhooks/lib/payments/commet.ts b/examples/webhooks/lib/payments/commet.ts index 7ed87699..145964c0 100644 --- a/examples/webhooks/lib/payments/commet.ts +++ b/examples/webhooks/lib/payments/commet.ts @@ -1,6 +1,8 @@ import { redirect } from "next/navigation"; import { getUser } from "@/lib/auth/session"; import { commet } from "@/lib/commet"; +import { normalizePlanCode } from "@/lib/plans"; +import { buildInternalHref } from "@/lib/redirects"; export async function createCheckoutSession({ planCode, @@ -9,10 +11,20 @@ export async function createCheckoutSession({ planCode: string; successUrl?: string; }) { + const normalizedPlanCode = normalizePlanCode(planCode); + if (!normalizedPlanCode) { + redirect("/pricing?error=missing_plan"); + } + const user = await getUser(); if (!user) { - redirect(`/sign-up?redirect=checkout&planCode=${planCode}`); + redirect( + buildInternalHref("/sign-up", { + redirect: "/checkout", + planCode: normalizedPlanCode, + }), + ); } const existing = await commet.subscriptions.getActive({ @@ -33,7 +45,7 @@ export async function createCheckoutSession({ const result = await commet.subscriptions.create({ customerId: user.id, - planCode, + planCode: normalizedPlanCode, successUrl, }); @@ -53,6 +65,11 @@ export async function getCheckoutUrl({ planCode: string; successUrl?: string; }): Promise { + const normalizedPlanCode = normalizePlanCode(planCode); + if (!normalizedPlanCode) { + throw new Error("Missing plan code"); + } + const user = await getUser(); if (!user) { @@ -77,7 +94,7 @@ export async function getCheckoutUrl({ const result = await commet.subscriptions.create({ customerId: user.id, - planCode, + planCode: normalizedPlanCode, successUrl, }); diff --git a/examples/webhooks/lib/plans.ts b/examples/webhooks/lib/plans.ts new file mode 100644 index 00000000..e4cdf4c5 --- /dev/null +++ b/examples/webhooks/lib/plans.ts @@ -0,0 +1,14 @@ +const planCodePattern = /^[a-z0-9_]+$/; + +export function normalizePlanCode(value: FormDataEntryValue | string | null) { + if (typeof value !== "string") { + return null; + } + + const planCode = value.trim(); + if (!planCodePattern.test(planCode)) { + return null; + } + + return planCode; +} diff --git a/examples/webhooks/lib/redirects.ts b/examples/webhooks/lib/redirects.ts new file mode 100644 index 00000000..3df7ce7d --- /dev/null +++ b/examples/webhooks/lib/redirects.ts @@ -0,0 +1,29 @@ +const allowedRedirectPaths = new Set(["/dashboard", "/pricing", "/checkout"]); + +export function safeRedirectPath(value: string | null): string { + if (!value) { + return "/dashboard"; + } + + const pathname = value.trim(); + if (!allowedRedirectPaths.has(pathname)) { + return "/dashboard"; + } + + return pathname; +} + +export function buildInternalHref( + pathname: string, + params: Record, +) { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value) { + searchParams.set(key, value); + } + } + + const query = searchParams.toString(); + return query ? `${pathname}?${query}` : pathname; +} diff --git a/examples/webhooks/lib/validations/auth.ts b/examples/webhooks/lib/validations/auth.ts index 3ccb1b25..c83cc428 100644 --- a/examples/webhooks/lib/validations/auth.ts +++ b/examples/webhooks/lib/validations/auth.ts @@ -31,9 +31,5 @@ export const updatePasswordSchema = z path: ["confirmPassword"], }); -export const deleteAccountSchema = z.object({ - password: z.string().min(8, "Password must be at least 8 characters"), -}); - export type SignInInput = z.infer; export type SignUpInput = z.infer; diff --git a/packages/next/src/types.ts b/packages/next/src/types.ts index 6e2b6281..a1313920 100644 --- a/packages/next/src/types.ts +++ b/packages/next/src/types.ts @@ -1,200 +1,20 @@ import type { WebhookData, WebhookEvent, WebhookPayload } from "@commet/node"; -// Re-export types from @commet/node for convenience export type { WebhookData, WebhookEvent, WebhookPayload }; -/** - * Configuration for the Commet webhook handler - */ export interface WebhooksConfig { - /** - * Webhook secret from your Commet dashboard - * Used to verify webhook signatures - */ webhookSecret: string; - - /** - * Handles the `subscription.activated` webhook event - * - * Fired when a subscription payment is successful and the subscription becomes active. - * This is when you should grant access to your product. - * - * @param payload - The webhook payload containing subscription data - * - * @example - * ```typescript - * onSubscriptionActivated: async (payload) => { - * await db.update(users) - * .set({ isPaid: true }) - * .where(eq(users.id, payload.data.customerId)); - * } - * ``` - */ onSubscriptionActivated?: (payload: WebhookPayload) => Promise; - - /** - * Handles the `subscription.canceled` webhook event - * - * Fired when a subscription is canceled (either by the customer or administratively). - * This is when you should revoke access to your product. - * - * @param payload - The webhook payload containing subscription data - * - * @example - * ```typescript - * onSubscriptionCanceled: async (payload) => { - * await db.update(users) - * .set({ isPaid: false }) - * .where(eq(users.id, payload.data.customerId)); - * } - * ``` - */ onSubscriptionCanceled?: (payload: WebhookPayload) => Promise; - - /** - * Handles the `subscription.created` webhook event - * - * Fired when a new subscription is created, but before payment is processed. - * Typically used for logging or analytics, not for granting access. - * - * @param payload - The webhook payload containing subscription data - * - * @example - * ```typescript - * onSubscriptionCreated: async (payload) => { - * console.log(`New subscription: ${payload.data.subscriptionId}`); - * } - * ``` - */ onSubscriptionCreated?: (payload: WebhookPayload) => Promise; - - /** - * Handles the `subscription.updated` webhook event - * - * Fired when subscription details change (plan, quantity, status, etc.). - * Use this to handle upgrades, downgrades, or status transitions. - * - * @param payload - The webhook payload containing subscription data - * - * @example - * ```typescript - * onSubscriptionUpdated: async (payload) => { - * if (payload.data.status === 'active') { - * await handleActivation(payload); - * } - * } - * ``` - */ onSubscriptionUpdated?: (payload: WebhookPayload) => Promise; - - /** - * Handles the `subscription.plan_changed` webhook event - * - * Fired when a subscription changes from one plan to another (upgrade, downgrade, - * or billing interval change). The subscription stays active — access does not change. - * - * @param payload - The webhook payload containing plan change data - * - * @example - * ```typescript - * onSubscriptionPlanChanged: async (payload) => { - * await db.update(users) - * .set({ planId: payload.data.currentPlan.id }) - * .where(eq(users.id, payload.data.customerId)); - * } - * ``` - */ onSubscriptionPlanChanged?: (payload: WebhookPayload) => Promise; - - /** - * Handles the `customer.state_changed` webhook event - * - * Fired whenever the customer's billing state changes — activation, plan - * change, cancellation or past due. Carries the full current snapshot (plan, - * status, features, seats, credits, balance), so a single handler can keep - * local state in sync. Inspect `data.trigger` to react to a specific transition. - */ onCustomerStateChanged?: (payload: WebhookPayload) => Promise; - - /** - * Handles the `payment.received` webhook event - * - * Fired when a payment is successfully processed for a subscription. - * - * @param payload - The webhook payload containing payment data - */ onPaymentReceived?: (payload: WebhookPayload) => Promise; - - /** - * Handles the `payment.failed` webhook event - * - * Fired when a payment attempt fails. Use this to notify users - * or trigger dunning flows. - * - * @param payload - The webhook payload containing payment data - */ onPaymentFailed?: (payload: WebhookPayload) => Promise; - - /** - * Handles the `payment.recovered` webhook event - * - * Fired when a previously failed payment finally succeeds and the subscription - * leaves `past_due`. Use this to restore access — the state snapshot is not - * re-emitted on recovery. - */ onPaymentRecovered?: (payload: WebhookPayload) => Promise; - - /** - * Handles the `usage.recorded` webhook event - * - * Fired for every processed usage event. This can be high volume, so only use - * it when you need to mirror live usage locally. - */ onUsageRecorded?: (payload: WebhookPayload) => Promise; - - /** - * Handles the `invoice.created` webhook event - * - * Fired when a new invoice is generated for a subscription billing cycle. - * - * @param payload - The webhook payload containing invoice data - */ onInvoiceCreated?: (payload: WebhookPayload) => Promise; - - /** - * Catch-all handler that receives all webhook events - * - * Useful for logging, analytics, or handling events without specific handlers. - * Called in addition to specific event handlers. - * - * @param payload - The webhook payload - * - * @example - * ```typescript - * onPayload: async (payload) => { - * console.log(`Webhook received: ${payload.event}`); - * await analytics.track('webhook_received', { event: payload.event }); - * } - * ``` - */ onPayload?: (payload: WebhookPayload) => Promise; - - /** - * Error handler for webhook processing failures - * - * Called when signature verification fails or handlers throw errors. - * Use this for custom error logging or alerting. - * - * @param error - The error that occurred - * @param payload - The raw payload (may be undefined if parsing failed) - * - * @example - * ```typescript - * onError: async (error, payload) => { - * console.error('Webhook error:', error); - * await logger.error('webhook_failed', { error, payload }); - * } - * ``` - */ onError?: (error: Error, payload: unknown) => Promise; } diff --git a/packages/next/src/webhooks.test.ts b/packages/next/src/webhooks.test.ts index 671f49fb..0c0e477e 100644 --- a/packages/next/src/webhooks.test.ts +++ b/packages/next/src/webhooks.test.ts @@ -352,13 +352,12 @@ describe("Webhooks", () => { const response = await webhookHandler(request); const data = (await response.json()) as { received: boolean; - warning?: string; + error?: string; }; - // Should still return 200 to prevent retries - expect(response.status).toBe(200); - expect(data.received).toBe(true); - expect(data.warning).toBe("Handler failed"); + expect(response.status).toBe(500); + expect(data.received).toBe(false); + expect(data.error).toBe("Handler failed"); expect(onError).toHaveBeenCalledWith(handlerError, payload); }); @@ -385,7 +384,7 @@ describe("Webhooks", () => { const response = await webhookHandler(request); - expect(response.status).toBe(200); + expect(response.status).toBe(500); }); }); diff --git a/packages/next/src/webhooks.ts b/packages/next/src/webhooks.ts index 7929ef0d..32b7397e 100644 --- a/packages/next/src/webhooks.ts +++ b/packages/next/src/webhooks.ts @@ -45,18 +45,13 @@ export const Webhooks = (config: WebhooksConfig) => { onError, } = config; - // Create webhook verifier instance const webhooks = new CommetWebhooks(); return async (request: NextRequest) => { try { - // 1. Read raw request body const rawBody = await request.text(); - - // 2. Extract signature from headers const signature = request.headers.get("x-commet-signature"); - // 3. Verify signature const isValid = webhooks.verify({ payload: rawBody, signature, @@ -71,7 +66,6 @@ export const Webhooks = (config: WebhooksConfig) => { ); } - // 4. Parse payload let payload: WebhookPayload; try { payload = JSON.parse(rawBody) as WebhookPayload; @@ -91,15 +85,12 @@ export const Webhooks = (config: WebhooksConfig) => { ); } - // 5. Collect promises for parallel execution const promises: Promise[] = []; - // Call catch-all handler if provided if (onPayload) { promises.push(onPayload(payload)); } - // 6. Route to specific event handler switch (payload.event) { case "subscription.activated": if (onSubscriptionActivated) { @@ -171,7 +162,6 @@ export const Webhooks = (config: WebhooksConfig) => { console.log(`[Commet Webhook] Unhandled event: ${payload.event}`); } - // 7. Execute all handlers in parallel try { await Promise.all(promises); } catch (handlerError) { @@ -187,14 +177,12 @@ export const Webhooks = (config: WebhooksConfig) => { payload, ); } - // Still return 200 to prevent retries for handler errors return NextResponse.json( - { received: true, warning: "Handler failed" }, - { status: 200 }, + { received: false, error: "Handler failed" }, + { status: 500 }, ); } - // 8. Success response return NextResponse.json({ received: true }, { status: 200 }); } catch (error) { console.error("[Commet Webhook] Unexpected error:", error); From b94748f1df17d992e383bf763af65ac94e7278eb Mon Sep 17 00:00:00 2001 From: Franco Jalil Date: Mon, 22 Jun 2026 13:27:48 -0300 Subject: [PATCH 7/7] fix(webhooks): return bad request for duplicate processing --- examples/webhooks/app/api/webhooks/commet/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/webhooks/app/api/webhooks/commet/route.ts b/examples/webhooks/app/api/webhooks/commet/route.ts index 22a8d8c1..77b78e2d 100644 --- a/examples/webhooks/app/api/webhooks/commet/route.ts +++ b/examples/webhooks/app/api/webhooks/commet/route.ts @@ -54,7 +54,7 @@ export async function POST(request: NextRequest) { if (insertResult === "processing") { return NextResponse.json( { received: false, error: "Event is already processing" }, - { status: 500 }, + { status: 400 }, ); }