Payments
CraftJS integrates Dodo Payments for subscription billing, providing a simple and developer-friendly payment solution.
Features
- ✅ Subscription Billing - Monthly/yearly plans
- ✅ Pre-configured Plans - Free, Pro, Enterprise tiers
- ✅ Webhook Handling - Automatic subscription updates
- ✅ Usage-Based Billing - Track and bill for AI usage
- ✅ Customer Portal - Self-service subscription management
Configuration
Environment Variables
DODO_API_KEY="your-dodo-api-key"
DODO_WEBHOOK_SECRET="your-webhook-secret"Get your API keys from the Dodo Payments Dashboard .
Plans Configuration
Define your plans in src/lib/payments/plans.ts:
export const plans = {
free: {
id: "free",
name: "Free",
price: 0,
priceId: null,
features: ["100 AI requests/month", "10MB storage", "Basic support"],
limits: {
aiRequests: 100,
storage: 10 * 1024 * 1024, // 10MB
},
},
pro: {
id: "pro",
name: "Pro",
price: 19,
priceId: "price_pro_monthly", // Your Dodo price ID
features: [
"10,000 AI requests/month",
"10GB storage",
"Priority support",
"Advanced analytics",
],
limits: {
aiRequests: 10000,
storage: 10 * 1024 * 1024 * 1024, // 10GB
},
},
enterprise: {
id: "enterprise",
name: "Enterprise",
price: 99,
priceId: "price_enterprise_monthly",
features: [
"Unlimited AI requests",
"100GB storage",
"24/7 dedicated support",
"Custom integrations",
"SSO",
],
limits: {
aiRequests: Infinity,
storage: 100 * 1024 * 1024 * 1024, // 100GB
},
},
} as const;
export type PlanId = keyof typeof plans;Payment Client
Set up the Dodo client in src/lib/payments/client.ts:
import { Dodo } from "@dodopayments/sdk";
import { env } from "@/env";
export const dodo = new Dodo({
apiKey: env.DODO_API_KEY,
});Creating Checkouts
Checkout API Route
// app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth/server";
import { dodo } from "@/lib/payments/client";
import { plans } from "@/lib/payments/plans";
import { headers } from "next/headers";
export async function POST(req: Request) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { planId } = await req.json();
const plan = plans[planId as keyof typeof plans];
if (!plan || !plan.priceId) {
return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
}
const checkout = await dodo.checkout.create({
customer: {
email: session.user.email,
name: session.user.name,
},
items: [
{
priceId: plan.priceId,
quantity: 1,
},
],
successUrl: `${env.BETTER_AUTH_URL}/dashboard?checkout=success`,
cancelUrl: `${env.BETTER_AUTH_URL}/pricing?checkout=canceled`,
metadata: {
userId: session.user.id,
planId: plan.id,
},
});
return NextResponse.json({ url: checkout.url });
}Checkout Button Component
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
interface CheckoutButtonProps {
planId: string;
children: React.ReactNode;
}
export function CheckoutButton({ planId, children }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false);
const handleCheckout = async () => {
setLoading(true);
try {
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planId }),
});
const { url, error } = await response.json();
if (error) {
throw new Error(error);
}
window.location.href = url;
} catch (error) {
console.error("Checkout error:", error);
// Show error toast
} finally {
setLoading(false);
}
};
return (
<Button onClick={handleCheckout} disabled={loading}>
{loading ? "Loading..." : children}
</Button>
);
}Webhook Handling
Create Webhook Endpoint
// app/api/webhooks/dodo/route.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { dodo } from "@/lib/payments/client";
import { db } from "@/lib/db/client";
import { users, subscriptions } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { env } from "@/env";
export async function POST(req: Request) {
const body = await req.text();
const headersList = await headers();
const signature = headersList.get("dodo-signature");
// Verify webhook signature
let event;
try {
event = dodo.webhooks.verify(body, signature!, env.DODO_WEBHOOK_SECRET);
} catch (err) {
console.error("Webhook verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// Handle different event types
switch (event.type) {
case "checkout.completed":
await handleCheckoutCompleted(event.data);
break;
case "subscription.created":
await handleSubscriptionCreated(event.data);
break;
case "subscription.updated":
await handleSubscriptionUpdated(event.data);
break;
case "subscription.canceled":
await handleSubscriptionCanceled(event.data);
break;
case "payment.failed":
await handlePaymentFailed(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
}
async function handleCheckoutCompleted(data: any) {
const { userId, planId } = data.metadata;
// Update user plan
await db.update(users).set({ plan: planId }).where(eq(users.id, userId));
}
async function handleSubscriptionCreated(data: any) {
const { userId, planId } = data.metadata;
await db.insert(subscriptions).values({
id: data.subscriptionId,
userId,
plan: planId,
status: data.status,
currentPeriodStart: new Date(data.currentPeriodStart),
currentPeriodEnd: new Date(data.currentPeriodEnd),
});
}
async function handleSubscriptionUpdated(data: any) {
await db
.update(subscriptions)
.set({
status: data.status,
currentPeriodEnd: new Date(data.currentPeriodEnd),
cancelAtPeriodEnd: data.cancelAtPeriodEnd,
updatedAt: new Date(),
})
.where(eq(subscriptions.id, data.subscriptionId));
}
async function handleSubscriptionCanceled(data: any) {
const { userId } = data.metadata;
// Downgrade to free plan
await db.update(users).set({ plan: "free" }).where(eq(users.id, userId));
await db
.update(subscriptions)
.set({ status: "canceled", updatedAt: new Date() })
.where(eq(subscriptions.id, data.subscriptionId));
}
async function handlePaymentFailed(data: any) {
// Send notification email
// Update subscription status
}Configure Webhook in Dodo Dashboard
- Go to Dodo Payments Dashboard
- Navigate to Webhooks
- Add endpoint:
https://yourdomain.com/api/webhooks/dodo - Select events to listen for
- Copy the webhook secret to your environment variables
Customer Portal
Allow users to manage their subscription:
// app/api/billing/portal/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth/server";
import { dodo } from "@/lib/payments/client";
import { headers } from "next/headers";
export async function POST(req: Request) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const portalSession = await dodo.billingPortal.create({
customerId: session.user.id,
returnUrl: `${env.BETTER_AUTH_URL}/settings/billing`,
});
return NextResponse.json({ url: portalSession.url });
}Checking Subscription Status
Server-Side
import { db } from "@/lib/db/client";
import { users, subscriptions } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
export async function getUserPlan(userId: string) {
const [user] = await db
.select({ plan: users.plan })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return user?.plan ?? "free";
}
export async function hasActiveSubscription(userId: string) {
const [sub] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.userId, userId))
.limit(1);
return sub?.status === "active";
}Client-Side Hook
"use client";
import { useSession } from "@/lib/auth/client";
import { useQuery } from "@tanstack/react-query";
export function useSubscription() {
const { data: session } = useSession();
return useQuery({
queryKey: ["subscription", session?.user.id],
queryFn: async () => {
const res = await fetch("/api/billing/subscription");
return res.json();
},
enabled: !!session,
});
}Pricing Page Component
import { plans } from "@/lib/payments/plans";
import { CheckoutButton } from "@/components/checkout-button";
import { Check } from "lucide-react";
export function PricingPage() {
return (
<div className="mx-auto grid max-w-6xl gap-8 p-8 md:grid-cols-3">
{Object.values(plans).map((plan) => (
<div key={plan.id} className="flex flex-col rounded-xl border p-6">
<h3 className="text-2xl font-bold">{plan.name}</h3>
<div className="mt-4">
<span className="text-4xl font-bold">${plan.price}</span>
{plan.price > 0 && (
<span className="text-neutral-500 dark:text-neutral-400">/month</span>
)}
</div>
<ul className="mt-6 flex-1 space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<Check className="h-5 w-5 text-green-500" />
<span>{feature}</span>
</li>
))}
</ul>
<div className="mt-8">
{plan.priceId ? (
<CheckoutButton planId={plan.id}>Get {plan.name}</CheckoutButton>
) : (
<button className="w-full rounded-lg border py-2">Current Plan</button>
)}
</div>
</div>
))}
</div>
);
}Usage-Based Billing
Track usage and bill accordingly:
import { sum, and, eq, gte } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { aiUsage } from "@/lib/db/schema";
// Track AI usage
export async function trackAIUsage(userId: string, tokens: number) {
await db.insert(aiUsage).values({
id: crypto.randomUUID(),
userId,
tokens,
createdAt: new Date(),
});
}
// Calculate monthly usage
export async function getMonthlyUsage(userId: string) {
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const result = await db
.select({ total: sum(aiUsage.tokens) })
.from(aiUsage)
.where(and(eq(aiUsage.userId, userId), gte(aiUsage.createdAt, startOfMonth)));
return result[0]?.total ?? 0;
}For high-volume usage tracking, consider using Upstash Redis for real-time counting, then batch-inserting to the database.
Next Steps
- Email - Send payment notifications
- Background Jobs - Process payments async
- Webhook Handling - Advanced webhook patterns
Last updated on