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
- Create Subscription Product
In the dashboard (Products → Create Product):
-
Select "Subscription" type
-
Set billing interval (monthly, yearly, etc.)
-
Configure pricing
- 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, // Optional trial }, customer: { email: 'subscriber@example.com', name: 'Jane Doe', }, return_url: 'https://yoursite.com/success', });
// Redirect to session.checkout_url
- Handle Webhook Events
// subscription.active - Grant access // subscription.cancelled - Schedule access revocation // subscription.renewed - Log renewal // payment.succeeded - Track payments
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
// app/api/webhooks/subscription/route.ts 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;
// Create or update user subscription 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), }, });
// Grant access await prisma.user.update({ where: { id: customer.customer_id }, data: { subscriptionStatus: 'active', plan: product_id, }, });
// Send welcome email 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), // Keep access until end of billing period if cancel_at_next_billing_date accessEndsAt: cancel_at_next_billing_date ? new Date(data.next_billing_date) : new Date(), }, });
// Send cancellation email 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' }, });
// Notify user about payment issue 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, }, });
// Update user entitlements based on new plan 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' }, });
// Revoke access 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:
// Create portal session const portal = await client.customers.createPortalSession({ customer_id: 'cust_xxxxx', return_url: 'https://yoursite.com/account', });
// Redirect to portal.url
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
// When usage occurs, create a charge const charge = await client.subscriptions.charge({ subscription_id: 'sub_xxxxx', amount: 1500, // $15.00 in cents description: 'API calls for January 2025', });
Track Usage Events
// payment.succeeded - Charge succeeded // payment.failed - Charge failed, implement retry logic
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
// Product has credit entitlement attached (e.g., 10,000 AI tokens/month) const session = await client.checkoutSessions.create({ product_cart: [ { product_id: 'prod_pro_with_credits', quantity: 1 } ], subscription_data: { trial_period_days: 14, // Trial credits can differ from regular amount }, 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': // Credits issued with subscription renewal await syncCreditBalance(data.customer_id, data.credit_entitlement_id, data.balance_after); break; case 'credit.balance_low': // Notify customer or suggest upgrade await sendLowBalanceAlert(data.customer_id, data.credit_entitlement_name, data.available_balance); break; case 'credit.deducted': // Track consumption for analytics 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
// Get available plans const plans = await client.products.list({ type: 'subscription', });
// Change plan await client.subscriptions.update({ subscription_id: 'sub_xxxxx', product_id: 'prod_new_plan', proration_behavior: 'create_prorations', // or 'none' });
Handling subscription.plan_changed
async function handlePlanChanged(data: any) { const { subscription_id, product_id, customer } = data;
// Map product to features/limits 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)
// middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) { // Check subscription status const session = await getSession(request);
if (!session?.user) { return NextResponse.redirect(new URL('/login', request.url)); }
const subscription = await getSubscription(session.user.id);
// Check if accessing premium feature if (request.nextUrl.pathname.startsWith('/dashboard/pro')) { if (!subscription || subscription.status !== 'active') { return NextResponse.redirect(new URL('/pricing', request.url)); }
// Check if plan includes this feature
if (!subscription.features.includes('pro')) {
return NextResponse.redirect(new URL('/upgrade', request.url));
}
}
return NextResponse.next(); }
React Hook for Subscription State
// hooks/useSubscription.ts 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, }; }
// Usage in component 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), }, });
// Schedule job to revoke access after grace period await scheduleAccessRevocation(data.subscription_id, gracePeriodDays); }
Prorated Upgrades
When upgrading mid-cycle:
// Dodo handles proration automatically // Customer pays difference for remaining days await client.subscriptions.update({ subscription_id: 'sub_xxxxx', product_id: 'prod_higher_plan', proration_behavior: 'create_prorations', });
Cancellation with End-of-Period Access
// subscription.cancelled event includes: // - cancel_at_next_billing_date: boolean // - next_billing_date: string (when access should end)
if (data.cancel_at_next_billing_date) { // Keep access until 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
-
Subscription Guide
-
On-Demand Subscriptions
-
Webhook Events
-
Customer Portal
-
Credit-Based Billing
-
Credit Webhook Events