subscription-integration
Guide for managing recurring subscriptions after checkout, including trials, lifecycle states, plan changes, cancellation, failed-payment recovery, …
它会碰到什么
这一栏是扫描器报的事实,不是结论。命中多不等于有毒(安全工具、规则库、示例脚本本来就会包含危险写法),命中少也不等于干净。它和你手上的凭据、文件、网络有什么关系,需要你自己看。
技能内容
Dodo Payments Subscription Integration
Implement recurring billing with trials, plan changes, and on-demand charging. Subscriptions are created through Checkout Sessions, managed via the subscriptions API, and monitored through webhooks.
When to use this skill
- Building a subscription product with recurring billing and trials
- Handling plan upgrades, downgrades, and migrations mid-cycle
- Implementing on-demand/off-session charging with mandates
- Managing failed payments and dunning recovery
- Building a customer self-service portal for subscription management
Core Concepts
Subscription lifecycle: The six subscription statuses are pending, active, on_hold, cancelled, failed, and expired. A trialing subscription reports active; subscription.renewed is an event, not a status. Failed payments can move a subscription to on_hold (recoverable) or failed (terminal). Cancellation sets it to cancelled or schedules it to become expired at period end.
Checkout Sessions: The recommended path for creating subscriptions. A single-use hosted checkout that collects payment and customer data, then creates the subscription server-side.
Proration: When a customer changes plans mid-cycle, Dodo calculates credits or charges based on the time remaining. Proration mode controls whether the customer is billed immediately, credited, or neither.
Mandates: Authorization to charge a customer's payment method repeatedly (for subscriptions) or on-demand (for usage-based billing). Created during Checkout, can be updated if payment fails.
Creating a Subscription
Via Checkout Session (Recommended)
import DodoPayments from 'dodopayments';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment: 'test_mode',
});
const session = await client.checkoutSessions.create({
product_cart: [
{ product_id: 'pdt_monthly_plan', quantity: 1 }
],
subscription_data: {
trial_period_days: 14, // Optional
},
customer: {
email: 'subscriber@example.com',
name: 'Jane Doe',
},
return_url: 'https://yoursite.com/success',
});
// Redirect user to session.checkout_url
console.log('Redirect to:', session.checkout_url);
The customer completes payment on the hosted checkout. On success, Dodo creates the subscription and fires the subscription.active webhook.
With Add-ons
const session = await client.checkoutSessions.create({
product_cart: [
{
product_id: 'pdt_pro_monthly',
quantity: 1,
addons: [
{ addon_id: 'adn_extra_seats', quantity: 3 }
]
}
],
subscription_data: {
trial_period_days: 7,
},
customer: { email: 'user@example.com' },
return_url: 'https://yoursite.com/success',
});
Subscription Lifecycle & Status
| Status | Meaning | Transitions |
|--------|---------|-----------|
| pending | Creation in progress | → active, failed |
| active | Actively renewing | → on_hold, cancelled, expired |
| on_hold | Renewal/plan-change payment failed; recoverable | → active (payment method updated), failed (retries exhausted), cancelled |
| cancelled | Will not renew | → expired (at period end) |
| failed | Initial mandate/payment failed; terminal | (no recovery) |
| expired | Subscription term ended | (terminal) |
Trial period: If trial_period_days is set, the subscription enters active immediately but charges nothing until the trial ends. The first charge occurs on the trial end date.
Subscription Methods
Retrieve
const subscription = await client.subscriptions.retrieve('sub_xxxxx');
console.log(subscription.status, subscription.next_billing_date);
Update Payment Method
When a subscription is on_hold due to failed payment, the customer can update their payment method. This automatically charges any outstanding dues.
await client.subscriptions.updatePaymentMethod('sub_xxxxx', {
payment_method: {
type: 'existing',
payment_method_id: 'pm_new_method',
},
});
Success emits payment.succeeded followed by subscription.active.
List Usage History (Metered Subscriptions)
const history = await client.subscriptions.retrieveUsageHistory('sub_xxxxx', {
page_size: 50,
page_number: 0,
});
Retrieve Credit Usage
const creditUsage = await client.subscriptions.retrieveCreditUsage('sub_xxxxx');
console.log('Subscription:', creditUsage.subscription_id);
for (const item of creditUsage.items) {
console.log(item.credit_entitlement_name, item.balance); // balance is a string
}
Plan Changes (Upgrades & Downgrades)
Change Plan
await client.subscriptions.changePlan('sub_xxxxx', {
product_id: 'pdt_higher_tier',
quantity: 1,
proration_billing_mode: 'prorated_immediately',
on_payment_failure: 'prevent_change',
});
Proration modes:
| Mode | Upgrade | Downgrade | Billing date |
|------|---------|-----------|--------------|
| prorated_immediately | Time-prorated charge | Time-prorated credit | Resets to change date |
| difference_immediately | Full new-plan charge | Difference becomes credit | Resets |
| full_immediately | Full new-plan charge | Full new-plan charge, no credit | Resets |
| do_not_bill | No charge | No credit | Preserved |
Payment failure handling:
prevent_change: Keep the old plan if the charge fails.apply_change: Apply the new plan even if payment fails (subscription may becomeon_hold).
Preview Plan Change
Show the customer a quote before committing:
const preview = await client.subscriptions.previewChangePlan('sub_xxxxx', {
product_id: 'pdt_new_plan',
quantity: 1,
proration_billing_mode: 'prorated_immediately',
});
console.log('Effective at:', preview.immediate_charge.effective_at);
console.log('Line items:', preview.immediate_charge.line_items);
console.log('Summary:', preview.immediate_charge.summary);
console.log('New plan:', preview.new_plan);
Cancel a Scheduled Plan Change
await client.subscriptions.cancelChangePlan('sub_xxxxx');
Cancellation
Immediate Cancellation
await client.subscriptions.update('sub_xxxxx', {
status: 'cancelled',
});
Access is revoked immediately.
Cancel at Period End
await client.subscriptions.update('sub_xxxxx', {
cancel_at_next_billing_date: true,
});
The subscription remains active until the next billing date, then transitions to expired. The subscription.cancelled webhook includes cancel_at_next_billing_date: true and next_billing_date so you know when to revoke access.
On-Demand (Off-Session) Charging
For usage-based or metered subscriptions, charge the customer on-demand without a scheduled renewal.
Create Subscription with Mandate
const session = await client.checkoutSessions.create({
product_cart: [
{ product_id: 'pdt_usage_based', quantity: 1 }
],
subscription_data: {
on_demand: {
mandate_only: true,
}
},
customer: { email: 'user@example.com' },
return_url: 'https://yoursite.com/success',
});
This creates a subscription with a mandate but no automatic renewal. You control when to charge.
Charge On-Demand
const charge = await client.subscriptions.charge('sub_xxxxx', {
product_price: 2500, // $25.00 in cents
product_currency: 'USD',
product_description: 'API calls for January 2025',
});
console.log('Payment ID:', charge.payment_id);
Amounts are in the smallest currency unit (cents for USD, paise for INR, etc.).
Dodo does not automatically retry on-demand charges. You own retry logic and decline filtering. Monitor payment.failed webhooks and implement your own retry strategy.
Failed Payments & Recovery
On-Hold Subscriptions
When a renewal or plan-change charge fails, the subscription moves to on_hold. This is recoverable.
// Listen for subscription.on_hold webhook
case 'subscription.on_hold':
// Notify customer, offer payment method update
await sendPaymentFailedEmail(data.customer.customer_id);
break;
Recovery Options
- Payment Retries: Opt-in under Settings → Recovery. Dodo retries failed renewals up to 8 times over a configurable 1–30 day window (default 13 days). Only for soft declines.
- Customer Portal: Customer updates payment method → automatic charge for outstanding dues →
subscription.active.
- Manual Charge: You update the payment method server-side, then call
updatePaymentMethod().
Dunning
Dunning sends up to four configurable emails for on_hold renewals and customer-portal cancellations. Exhausted dunning does not change the subscription state; you must handle the final outcome.
Customer Portal
Allow customers to self-serve: view subscriptions, update payment methods, cancel, and upgrade/downgrade.
const portalSession = await client.customers.customerPortal.create(
'cus_xxxxx',
{ return_url: 'https://yoursite.com/account' }
);
// Redirect to portalSession.link
Portal links expire after 24 hours. Customers can:
- View subscription details and renewal dates
- Cancel immediately or at period end
- Upgrade/downgrade within enabled Product Collections
- Update payment methods and reactivate
on_holdsubscriptions - View billing history and download invoices
For full customer management (creating, updating, listing), see the customer-management skill.
Webhook Events
| Event | When | Action |
|-------|------|--------|
| subscription.active | Subscription becomes active, including a trial start or recovery | Grant access |
| subscription.renewed | Successful renewal | Log renewal, send receipt |
| subscription.on_hold | Renewal/plan-change payment failed | Notify customer, offer recovery |
| subscription.plan_changed | Plan upgraded/downgraded or add-ons changed | Update entitlements |
| subscription.cancelled | Customer cancels | Schedule access revocation per cancel_at_next_billing_date |
| subscription.failed | Initial mandate/payment failed | Notify customer, offer retry or new subscription |
| subscription.expired | Subscription term ended | Revoke access |
Webhook signature verification, raw-body handling, durable processing, and idempotency are covered in the webhook-integration skill.
Example Handler
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const raw = await req.text();
const headers = Object.fromEntries(req.headers.entries());
const event = await client.webhooks.unwrap(raw, { headers });
const webhookId = req.headers.get('webhook-id');
if (!webhookId) {
return NextResponse.json({ error: 'Missing webhook-id' }, { status: 400 });
}
// Implement this as an atomic insert backed by a UNIQUE constraint.
// Keep the claim and entitlement changes in the same database transaction.
const claimed = await claimWebhookId(webhookId);
if (!claimed) {
return NextResponse.json({ received: true });
}
switch (event.type) {
case 'subscription.active':
await grantAccess(event.data.customer.customer_id, event.data.product_id);
break;
case 'subscription.on_hold':
await notifyPaymentFailed(event.data.customer.customer_id);
break;
case 'subscription.cancelled':
if (event.data.cancel_at_next_billing_date) {
await scheduleAccessRevocation(event.data.subscription_id, new Date(event.data.next_billing_date));
} else {
await revokeAccessImmediately(event.data.subscription_id);
}
break;
case 'subscription.expired':
await revokeAccess(event.data.customer.customer_id);
break;
}
return NextResponse.json({ received: true });
}
Credit Entitlements
Attach credit entitlements to subscription products to grant credits each billing cycle. For full credit management, see the credit-based-billing skill.
Quick example:
// Product has credit entitlement attached (e.g., 10,000 tokens/month)
const session = await client.checkoutSessions.create({
product_cart: [
{ product_id: 'pdt_pro_with_credits', quantity: 1 }
],
subscription_data: {
trial_period_days: 14,
},
customer: { email: 'user@example.com' },
return_url: 'https://yoursite.com/success',
});
On each renewal, credits are issued. Monitor credit.added and credit.deducted webhooks to sync your ledger.
Common Mistakes
1. Granting Access on return_url Instead of Webhook
Wrong:
// On return_url redirect
await grantAccess(user.id);
The return_url is hit before the subscription is fully created. Dodo may still be processing the mandate or payment. Always wait for subscription.active webhook.
Right:
// In webhook handler
case 'subscription.active':
await grantAccess(data.customer.customer_id);
break;
2. Using Deprecated subscriptions.create
Wrong:
const sub = await client.subscriptions.create({
product_id: 'pdt_monthly',
customer_id: 'cus_xxxxx',
});
This endpoint is deprecated. Use Checkout Sessions.
Right:
const session = await client.checkoutSessions.create({
product_cart: [{ product_id: 'pdt_monthly', quantity: 1 }],
customer: { customer_id: 'cus_xxxxx' },
return_url: 'https://yoursite.com/success',
});
3. Misinterpreting subscription.plan_changed
subscription.plan_changed fires for upgrades, downgrades, add-on changes, AND when cancel_at_next_billing_date is toggled. Don't assume every plan_changed event means a paid upgrade succeeded. Inspect the payload and check the subscription's current product_id.
4. Forgetting Proration Mode
Wrong:
// Missing proration_billing_mode
await client.subscriptions.changePlan('sub_xxxxx', {
product_id: 'pdt_new',
quantity: 1,
// proration_billing_mode: 'prorated_immediately', // REQUIRED
});
This will fail. Always specify a proration mode.
5. Confusing on_hold with Pause
on_hold means payment failed, not that the customer paused. There is no general pause/resume operation. on_hold is recoverable only by updating the payment method or waiting for retry.
6. Not Handling cancel_at_next_billing_date
// Wrong: revoke immediately
await revokeAccess(data.customer.customer_id);
// Right: check the flag
if (data.cancel_at_next_billing_date) {
await scheduleAccessRevocation(data.subscription_id, new Date(data.next_billing_date));
} else {
await revokeAccessImmediately(data.subscription_id);
}
Resources
想直接用这个技能?
本站把开放许可(MIT / Apache 等)的技能按仓库打包整理到网盘,点一下转存到你自己的网盘,不用一个个从 GitHub 拉。许可未声明的技能只给原始仓库链接,不打包。
它属于哪个仓库
plugins/dodopayments/dodo-agent-plugin/skills/subscription-integration/SKILL.md