| name | subscription-integration |
| description | Guide for implementing subscription billing with Dodo Payments - trials, upgrades, downgrades, and on-demand billing. |
Dodo Payments Subscription Integration
Reference: docs.dodopayments.com/developer-resources/subscription-integration-guide
Implement recurring billing with trials, plan changes, and usage-based pricing.
Quick Start
1. Create Subscription Product
In the dashboard (Products โ Create Product):
- Select "Subscription" type
- Set billing interval (monthly, yearly, etc.)
- Configure pricing
2. Create Checkout Session
import DodoPayments from "dodopayments";
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
});
const session = await client.checkoutSessions.create({
product_cart: [{ product_id: "prod_monthly_plan", quantity: 1 }],
subscription_data: {
trial_period_days: 14,
},
customer: {
email: "subscriber@example.com",
name: "Jane Doe",
},
return_url: "https://yoursite.com/success",
});
3. Handle Webhook Events
Subscription Lifecycle
โโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโ
โ Created โ โโโถ โ Trial โ โโโถ โ Active โ
โโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโ
โผ โผ โผ
โโโโโโโโโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโโ
โ On Hold โ โ Cancelled โ โ Renewed โ
โโโโโโโโโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโโ โโโโโโโโโโโโโ
โ Failed โ โ Expired โ
โโโโโโโโโโโโ โโโโโโโโโโโโโ
Webhook Events
| Event | When | Action |
|---|
subscription.active | Subscription starts | Grant access |
subscription.updated | Any field changes | Sync state |
subscription.on_hold | Payment fails | Notify user, retry |
subscription.renewed | Successful renewal | Log, send receipt |
subscription.plan_changed | Upgrade/downgrade | Update entitlements |
subscription.cancelled | User cancels | Schedule end of access |
subscription.failed | Mandate creation fails | Notify, retry options |
subscription.expired | Term ends | Revoke access |
Implementation Examples
Full Subscription Handler
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
const event = await req.json();
const data = event.data;
switch (event.type) {
case "subscription.active":
await handleSubscriptionActive(data);
break;
case "subscription.cancelled":
await handleSubscriptionCancelled(data);
break;
case "subscription.on_hold":
await handleSubscriptionOnHold(data);
break;
case "subscription.renewed":
await handleSubscriptionRenewed(data);
break;
case "subscription.plan_changed":
await handlePlanChanged(data);
break;
case "subscription.expired":
await handleSubscriptionExpired(data);
break;
}
return NextResponse.json({ received: true });
}
async function handleSubscriptionActive(data: any) {
const {
subscription_id,
customer,
product_id,
next_billing_date,
recurring_pre_tax_amount,
payment_frequency_interval,
} = data;
await prisma.subscription.upsert({
where: { externalId: subscription_id },
create: {
externalId: subscription_id,
userId: customer.customer_id,
email: customer.email,
productId: product_id,
status: "active",
currentPeriodEnd: new Date(next_billing_date),
amount: recurring_pre_tax_amount,
interval: payment_frequency_interval,
},
update: {
status: "active",
currentPeriodEnd: new Date(next_billing_date),
},
});
await prisma.user.update({
where: { id: customer.customer_id },
data: {
subscriptionStatus: "active",
plan: product_id,
},
});
await sendWelcomeEmail(customer.email, product_id);
}
async function handleSubscriptionCancelled(data: any) {
const {
subscription_id,
customer,
cancelled_at,
cancel_at_next_billing_date,
} = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: {
status: "cancelled",
cancelledAt: new Date(cancelled_at),
accessEndsAt: cancel_at_next_billing_date
? new Date(data.next_billing_date)
: new Date(),
},
});
await sendCancellationEmail(customer.email, cancel_at_next_billing_date);
}
async function handleSubscriptionOnHold(data: any) {
const { subscription_id, customer } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: { status: "on_hold" },
});
await sendPaymentFailedEmail(customer.email);
}
async function handleSubscriptionRenewed(data: any) {
const { subscription_id, next_billing_date } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: {
status: "active",
currentPeriodEnd: new Date(next_billing_date),
},
});
}
async function handlePlanChanged(data: any) {
const { subscription_id, product_id, recurring_pre_tax_amount } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: {
productId: product_id,
amount: recurring_pre_tax_amount,
},
});
await updateUserEntitlements(subscription_id, product_id);
}
async function handleSubscriptionExpired(data: any) {
const { subscription_id, customer } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: { status: "expired" },
});
await prisma.user.update({
where: { id: customer.customer_id },
data: {
subscriptionStatus: "expired",
plan: null,
},
});
}
Subscription with Trial
const session = await client.checkoutSessions.create({
product_cart: [{ product_id: "prod_pro_monthly", quantity: 1 }],
subscription_data: {
trial_period_days: 14,
},
customer: {
email: "user@example.com",
name: "John Doe",
},
return_url: "https://yoursite.com/welcome",
});
Customer Portal for Self-Service
Allow customers to manage their subscription:
const portal = await client.customers.createPortalSession({
customer_id: "cust_xxxxx",
return_url: "https://yoursite.com/account",
});
Portal features:
- View subscription details
- Update payment method
- Cancel subscription
- View billing history
On-Demand (Usage-Based) Subscriptions
For metered/usage-based billing:
Create Subscription with Mandate
const session = await client.checkoutSessions.create({
product_cart: [{ product_id: "prod_usage_based", quantity: 1 }],
customer: { email: "user@example.com" },
return_url: "https://yoursite.com/success",
});
Charge for Usage
const charge = await client.subscriptions.charge({
subscription_id: "sub_xxxxx",
amount: 1500,
description: "API calls for January 2025",
});
Track Usage Events
Subscriptions with Credit Entitlements
Attach credit entitlements to subscription products to grant credits each billing cycle:
Setup
- Create a credit entitlement (Dashboard โ Products โ Credits)
- Create/edit a subscription product
- In Entitlements section, click Attach next to Credits
- Configure: credits per cycle, trial credits, proration, low balance threshold
Checkout with Credits
const session = await client.checkoutSessions.create({
product_cart: [{ product_id: "prod_pro_with_credits", quantity: 1 }],
subscription_data: {
trial_period_days: 14,
},
customer: { email: "user@example.com" },
return_url: "https://yoursite.com/success",
});
Credit Lifecycle per Cycle
Each billing cycle:
- New credits issued โ
credit.added webhook fires
- Usage deducts credits โ Automatically via meters or manually via API
- Cycle ends โ Unused credits expire or roll over based on settings
- Overage handled โ Forgiven, billed, or carried as deficit
Handle Credit Webhooks in Subscription Context
case 'credit.added':
await syncCreditBalance(data.customer_id, data.credit_entitlement_id, data.balance_after);
break;
case 'credit.balance_low':
await sendLowBalanceAlert(data.customer_id, data.credit_entitlement_name, data.available_balance);
break;
case 'credit.deducted':
await logCreditUsage(data.customer_id, data.amount);
break;
Plan Changes with Credits
When customers upgrade/downgrade, credit proration can be enabled:
- Proration enabled: Remaining credits are prorated based on time left in cycle
- Proration disabled: Credits continue as-is until next cycle
Plan Changes
Upgrade/Downgrade Flow
const plans = await client.products.list({
type: "subscription",
});
await client.subscriptions.update({
subscription_id: "sub_xxxxx",
product_id: "prod_new_plan",
proration_behavior: "create_prorations",
});
Handling subscription.plan_changed
async function handlePlanChanged(data: any) {
const { subscription_id, product_id, customer } = data;
const planFeatures = getPlanFeatures(product_id);
await prisma.user.update({
where: { externalId: customer.customer_id },
data: {
plan: product_id,
features: planFeatures,
apiLimit: planFeatures.apiLimit,
storageLimit: planFeatures.storageLimit,
},
});
}
Access Control Pattern
Middleware Example (Next.js)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
const session = await getSession(request);
if (!session?.user) {
return NextResponse.redirect(new URL("/login", request.url));
}
const subscription = await getSubscription(session.user.id);
if (request.nextUrl.pathname.startsWith("/dashboard/pro")) {
if (!subscription || subscription.status !== "active") {
return NextResponse.redirect(new URL("/pricing", request.url));
}
if (!subscription.features.includes("pro")) {
return NextResponse.redirect(new URL("/upgrade", request.url));
}
}
return NextResponse.next();
}
React Hook for Subscription State
import useSWR from 'swr';
export function useSubscription() {
const { data, error, mutate } = useSWR('/api/subscription', fetcher);
return {
subscription: data,
isLoading: !error && !data,
isError: error,
isActive: data?.status === 'active',
isPro: data?.plan?.includes('pro'),
refresh: mutate,
};
}
function PremiumFeature() {
const { isActive, isPro } = useSubscription();
if (!isActive) {
return <UpgradePrompt />;
}
if (!isPro) {
return <ProUpgradePrompt />;
}
return <ActualFeature />;
}
Common Patterns
Grace Period for Failed Payments
async function handleSubscriptionOnHold(data: any) {
const gracePeriodDays = 7;
await prisma.subscription.update({
where: { externalId: data.subscription_id },
data: {
status: "on_hold",
gracePeriodEnds: new Date(
Date.now() + gracePeriodDays * 24 * 60 * 60 * 1000
),
},
});
await scheduleAccessRevocation(data.subscription_id, gracePeriodDays);
}
Prorated Upgrades
When upgrading mid-cycle:
await client.subscriptions.update({
subscription_id: "sub_xxxxx",
product_id: "prod_higher_plan",
proration_behavior: "create_prorations",
});
Cancellation with End-of-Period Access
if (data.cancel_at_next_billing_date) {
await scheduleAccessRevocation(
data.subscription_id,
new Date(data.next_billing_date)
);
}
Testing
Test Scenarios
- New subscription โ
subscription.active
- Renewal success โ
subscription.renewed + payment.succeeded
- Renewal failure โ
subscription.on_hold + payment.failed
- Plan upgrade โ
subscription.plan_changed
- Cancellation โ
subscription.cancelled
- Expiration โ
subscription.expired
Test in Dashboard
Use test mode and trigger events manually from the webhook settings.
Resources