@dodopayments/opencode-plugin 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -12
- package/opencode-plugin/README.md +3 -3
- package/package.json +1 -1
- package/skills/best-practices/SKILL.md +335 -179
- package/skills/better-auth-integration/SKILL.md +214 -0
- package/skills/billing-sdk/SKILL.md +357 -370
- package/skills/checkout-integration/SKILL.md +580 -329
- package/skills/credit-based-billing/SKILL.md +448 -775
- package/skills/customer-management/SKILL.md +281 -0
- package/skills/discounts-and-promotions/SKILL.md +464 -0
- package/skills/framework-adapters/SKILL.md +644 -0
- package/skills/license-keys/SKILL.md +324 -199
- package/skills/localized-pricing/SKILL.md +230 -0
- package/skills/mobile-checkout/SKILL.md +521 -0
- package/skills/product-catalog-management/SKILL.md +311 -0
- package/skills/refunds-and-disputes/SKILL.md +333 -0
- package/skills/subscription-integration/SKILL.md +323 -404
- package/skills/testing-and-go-live/SKILL.md +335 -0
- package/skills/usage-based-billing/SKILL.md +339 -479
- package/skills/webhook-integration/SKILL.md +600 -402
|
@@ -1,39 +1,52 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: subscription-integration
|
|
3
|
-
description: Guide for
|
|
3
|
+
description: Guide for managing recurring subscriptions after checkout, including trials, lifecycle states, plan changes, cancellation, failed-payment recovery, proration, mandates, and on-demand charges.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Dodo Payments Subscription Integration
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
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.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
## When to use this skill
|
|
11
|
+
|
|
12
|
+
- Building a subscription product with recurring billing and trials
|
|
13
|
+
- Handling plan upgrades, downgrades, and migrations mid-cycle
|
|
14
|
+
- Implementing on-demand/off-session charging with mandates
|
|
15
|
+
- Managing failed payments and dunning recovery
|
|
16
|
+
- Building a customer self-service portal for subscription management
|
|
11
17
|
|
|
12
18
|
---
|
|
13
19
|
|
|
14
|
-
##
|
|
20
|
+
## Core Concepts
|
|
21
|
+
|
|
22
|
+
**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.
|
|
23
|
+
|
|
24
|
+
**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.
|
|
25
|
+
|
|
26
|
+
**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.
|
|
27
|
+
|
|
28
|
+
**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.
|
|
29
|
+
|
|
30
|
+
---
|
|
15
31
|
|
|
16
|
-
|
|
17
|
-
In the dashboard (Products → Create Product):
|
|
18
|
-
- Select "Subscription" type
|
|
19
|
-
- Set billing interval (monthly, yearly, etc.)
|
|
20
|
-
- Configure pricing
|
|
32
|
+
## Creating a Subscription
|
|
21
33
|
|
|
22
|
-
###
|
|
34
|
+
### Via Checkout Session (Recommended)
|
|
23
35
|
|
|
24
36
|
```typescript
|
|
25
37
|
import DodoPayments from 'dodopayments';
|
|
26
38
|
|
|
27
39
|
const client = new DodoPayments({
|
|
28
40
|
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
|
|
41
|
+
environment: 'test_mode',
|
|
29
42
|
});
|
|
30
43
|
|
|
31
44
|
const session = await client.checkoutSessions.create({
|
|
32
45
|
product_cart: [
|
|
33
|
-
{ product_id: '
|
|
46
|
+
{ product_id: 'pdt_monthly_plan', quantity: 1 }
|
|
34
47
|
],
|
|
35
48
|
subscription_data: {
|
|
36
|
-
trial_period_days: 14, // Optional
|
|
49
|
+
trial_period_days: 14, // Optional
|
|
37
50
|
},
|
|
38
51
|
customer: {
|
|
39
52
|
email: 'subscriber@example.com',
|
|
@@ -42,530 +55,436 @@ const session = await client.checkoutSessions.create({
|
|
|
42
55
|
return_url: 'https://yoursite.com/success',
|
|
43
56
|
});
|
|
44
57
|
|
|
45
|
-
// Redirect to session.checkout_url
|
|
58
|
+
// Redirect user to session.checkout_url
|
|
59
|
+
console.log('Redirect to:', session.checkout_url);
|
|
46
60
|
```
|
|
47
61
|
|
|
48
|
-
|
|
62
|
+
The customer completes payment on the hosted checkout. On success, Dodo creates the subscription and fires the `subscription.active` webhook.
|
|
63
|
+
|
|
64
|
+
### With Add-ons
|
|
65
|
+
|
|
49
66
|
```typescript
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
const session = await client.checkoutSessions.create({
|
|
68
|
+
product_cart: [
|
|
69
|
+
{
|
|
70
|
+
product_id: 'pdt_pro_monthly',
|
|
71
|
+
quantity: 1,
|
|
72
|
+
addons: [
|
|
73
|
+
{ addon_id: 'adn_extra_seats', quantity: 3 }
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
subscription_data: {
|
|
78
|
+
trial_period_days: 7,
|
|
79
|
+
},
|
|
80
|
+
customer: { email: 'user@example.com' },
|
|
81
|
+
return_url: 'https://yoursite.com/success',
|
|
82
|
+
});
|
|
54
83
|
```
|
|
55
84
|
|
|
56
85
|
---
|
|
57
86
|
|
|
58
|
-
## Subscription Lifecycle
|
|
87
|
+
## Subscription Lifecycle & Status
|
|
59
88
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
│ │
|
|
71
|
-
▼ ▼
|
|
72
|
-
┌──────────┐ ┌───────────┐
|
|
73
|
-
│ Failed │ │ Expired │
|
|
74
|
-
└──────────┘ └───────────┘
|
|
75
|
-
```
|
|
89
|
+
| Status | Meaning | Transitions |
|
|
90
|
+
|--------|---------|-----------|
|
|
91
|
+
| `pending` | Creation in progress | → `active`, `failed` |
|
|
92
|
+
| `active` | Actively renewing | → `on_hold`, `cancelled`, `expired` |
|
|
93
|
+
| `on_hold` | Renewal/plan-change payment failed; recoverable | → `active` (payment method updated), `failed` (retries exhausted), `cancelled` |
|
|
94
|
+
| `cancelled` | Will not renew | → `expired` (at period end) |
|
|
95
|
+
| `failed` | Initial mandate/payment failed; terminal | (no recovery) |
|
|
96
|
+
| `expired` | Subscription term ended | (terminal) |
|
|
97
|
+
|
|
98
|
+
**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.
|
|
76
99
|
|
|
77
100
|
---
|
|
78
101
|
|
|
79
|
-
##
|
|
102
|
+
## Subscription Methods
|
|
80
103
|
|
|
81
|
-
|
|
82
|
-
|-------|------|--------|
|
|
83
|
-
| `subscription.active` | Subscription starts | Grant access |
|
|
84
|
-
| `subscription.updated` | Any field changes | Sync state |
|
|
85
|
-
| `subscription.on_hold` | Payment fails | Notify user, retry |
|
|
86
|
-
| `subscription.renewed` | Successful renewal | Log, send receipt |
|
|
87
|
-
| `subscription.plan_changed` | Upgrade/downgrade | Update entitlements |
|
|
88
|
-
| `subscription.cancelled` | User cancels | Schedule end of access |
|
|
89
|
-
| `subscription.failed` | Mandate creation fails | Notify, retry options |
|
|
90
|
-
| `subscription.expired` | Term ends | Revoke access |
|
|
104
|
+
### Retrieve
|
|
91
105
|
|
|
92
|
-
|
|
106
|
+
```typescript
|
|
107
|
+
const subscription = await client.subscriptions.retrieve('sub_xxxxx');
|
|
108
|
+
console.log(subscription.status, subscription.next_billing_date);
|
|
109
|
+
```
|
|
93
110
|
|
|
94
|
-
|
|
111
|
+
### Update Payment Method
|
|
95
112
|
|
|
96
|
-
|
|
113
|
+
When a subscription is `on_hold` due to failed payment, the customer can update their payment method. This automatically charges any outstanding dues.
|
|
97
114
|
|
|
98
115
|
```typescript
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
116
|
+
await client.subscriptions.updatePaymentMethod('sub_xxxxx', {
|
|
117
|
+
payment_method: {
|
|
118
|
+
type: 'existing',
|
|
119
|
+
payment_method_id: 'pm_new_method',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
```
|
|
102
123
|
|
|
103
|
-
|
|
104
|
-
const event = await req.json();
|
|
105
|
-
const data = event.data;
|
|
124
|
+
Success emits `payment.succeeded` followed by `subscription.active`.
|
|
106
125
|
|
|
107
|
-
|
|
108
|
-
case 'subscription.active':
|
|
109
|
-
await handleSubscriptionActive(data);
|
|
110
|
-
break;
|
|
111
|
-
case 'subscription.cancelled':
|
|
112
|
-
await handleSubscriptionCancelled(data);
|
|
113
|
-
break;
|
|
114
|
-
case 'subscription.on_hold':
|
|
115
|
-
await handleSubscriptionOnHold(data);
|
|
116
|
-
break;
|
|
117
|
-
case 'subscription.renewed':
|
|
118
|
-
await handleSubscriptionRenewed(data);
|
|
119
|
-
break;
|
|
120
|
-
case 'subscription.plan_changed':
|
|
121
|
-
await handlePlanChanged(data);
|
|
122
|
-
break;
|
|
123
|
-
case 'subscription.expired':
|
|
124
|
-
await handleSubscriptionExpired(data);
|
|
125
|
-
break;
|
|
126
|
-
}
|
|
126
|
+
### List Usage History (Metered Subscriptions)
|
|
127
127
|
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
```typescript
|
|
129
|
+
const history = await client.subscriptions.retrieveUsageHistory('sub_xxxxx', {
|
|
130
|
+
page_size: 50,
|
|
131
|
+
page_number: 0,
|
|
132
|
+
});
|
|
133
|
+
```
|
|
130
134
|
|
|
131
|
-
|
|
132
|
-
const {
|
|
133
|
-
subscription_id,
|
|
134
|
-
customer,
|
|
135
|
-
product_id,
|
|
136
|
-
next_billing_date,
|
|
137
|
-
recurring_pre_tax_amount,
|
|
138
|
-
payment_frequency_interval,
|
|
139
|
-
} = data;
|
|
140
|
-
|
|
141
|
-
// Create or update user subscription
|
|
142
|
-
await prisma.subscription.upsert({
|
|
143
|
-
where: { externalId: subscription_id },
|
|
144
|
-
create: {
|
|
145
|
-
externalId: subscription_id,
|
|
146
|
-
userId: customer.customer_id,
|
|
147
|
-
email: customer.email,
|
|
148
|
-
productId: product_id,
|
|
149
|
-
status: 'active',
|
|
150
|
-
currentPeriodEnd: new Date(next_billing_date),
|
|
151
|
-
amount: recurring_pre_tax_amount,
|
|
152
|
-
interval: payment_frequency_interval,
|
|
153
|
-
},
|
|
154
|
-
update: {
|
|
155
|
-
status: 'active',
|
|
156
|
-
currentPeriodEnd: new Date(next_billing_date),
|
|
157
|
-
},
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
// Grant access
|
|
161
|
-
await prisma.user.update({
|
|
162
|
-
where: { id: customer.customer_id },
|
|
163
|
-
data: {
|
|
164
|
-
subscriptionStatus: 'active',
|
|
165
|
-
plan: product_id,
|
|
166
|
-
},
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
// Send welcome email
|
|
170
|
-
await sendWelcomeEmail(customer.email, product_id);
|
|
171
|
-
}
|
|
135
|
+
### Retrieve Credit Usage
|
|
172
136
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
status: 'cancelled',
|
|
180
|
-
cancelledAt: new Date(cancelled_at),
|
|
181
|
-
// Keep access until end of billing period if cancel_at_next_billing_date
|
|
182
|
-
accessEndsAt: cancel_at_next_billing_date
|
|
183
|
-
? new Date(data.next_billing_date)
|
|
184
|
-
: new Date(),
|
|
185
|
-
},
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
// Send cancellation email
|
|
189
|
-
await sendCancellationEmail(customer.email, cancel_at_next_billing_date);
|
|
137
|
+
```typescript
|
|
138
|
+
const creditUsage = await client.subscriptions.retrieveCreditUsage('sub_xxxxx');
|
|
139
|
+
console.log('Subscription:', creditUsage.subscription_id);
|
|
140
|
+
|
|
141
|
+
for (const item of creditUsage.items) {
|
|
142
|
+
console.log(item.credit_entitlement_name, item.balance); // balance is a string
|
|
190
143
|
}
|
|
144
|
+
```
|
|
191
145
|
|
|
192
|
-
|
|
193
|
-
const { subscription_id, customer } = data;
|
|
146
|
+
---
|
|
194
147
|
|
|
195
|
-
|
|
196
|
-
where: { externalId: subscription_id },
|
|
197
|
-
data: { status: 'on_hold' },
|
|
198
|
-
});
|
|
148
|
+
## Plan Changes (Upgrades & Downgrades)
|
|
199
149
|
|
|
200
|
-
|
|
201
|
-
await sendPaymentFailedEmail(customer.email);
|
|
202
|
-
}
|
|
150
|
+
### Change Plan
|
|
203
151
|
|
|
204
|
-
|
|
205
|
-
|
|
152
|
+
```typescript
|
|
153
|
+
await client.subscriptions.changePlan('sub_xxxxx', {
|
|
154
|
+
product_id: 'pdt_higher_tier',
|
|
155
|
+
quantity: 1,
|
|
156
|
+
proration_billing_mode: 'prorated_immediately',
|
|
157
|
+
on_payment_failure: 'prevent_change',
|
|
158
|
+
});
|
|
159
|
+
```
|
|
206
160
|
|
|
207
|
-
|
|
208
|
-
where: { externalId: subscription_id },
|
|
209
|
-
data: {
|
|
210
|
-
status: 'active',
|
|
211
|
-
currentPeriodEnd: new Date(next_billing_date),
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
}
|
|
161
|
+
**Proration modes:**
|
|
215
162
|
|
|
216
|
-
|
|
217
|
-
|
|
163
|
+
| Mode | Upgrade | Downgrade | Billing date |
|
|
164
|
+
|------|---------|-----------|--------------|
|
|
165
|
+
| `prorated_immediately` | Time-prorated charge | Time-prorated credit | Resets to change date |
|
|
166
|
+
| `difference_immediately` | Full new-plan charge | Difference becomes credit | Resets |
|
|
167
|
+
| `full_immediately` | Full new-plan charge | Full new-plan charge, no credit | Resets |
|
|
168
|
+
| `do_not_bill` | No charge | No credit | Preserved |
|
|
218
169
|
|
|
219
|
-
|
|
220
|
-
where: { externalId: subscription_id },
|
|
221
|
-
data: {
|
|
222
|
-
productId: product_id,
|
|
223
|
-
amount: recurring_pre_tax_amount,
|
|
224
|
-
},
|
|
225
|
-
});
|
|
170
|
+
**Payment failure handling:**
|
|
226
171
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
172
|
+
- `prevent_change`: Keep the old plan if the charge fails.
|
|
173
|
+
- `apply_change`: Apply the new plan even if payment fails (subscription may become `on_hold`).
|
|
230
174
|
|
|
231
|
-
|
|
232
|
-
const { subscription_id, customer } = data;
|
|
233
|
-
|
|
234
|
-
await prisma.subscription.update({
|
|
235
|
-
where: { externalId: subscription_id },
|
|
236
|
-
data: { status: 'expired' },
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
// Revoke access
|
|
240
|
-
await prisma.user.update({
|
|
241
|
-
where: { id: customer.customer_id },
|
|
242
|
-
data: {
|
|
243
|
-
subscriptionStatus: 'expired',
|
|
244
|
-
plan: null,
|
|
245
|
-
},
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
```
|
|
175
|
+
### Preview Plan Change
|
|
249
176
|
|
|
250
|
-
|
|
177
|
+
Show the customer a quote before committing:
|
|
251
178
|
|
|
252
179
|
```typescript
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
subscription_data: {
|
|
258
|
-
trial_period_days: 14,
|
|
259
|
-
},
|
|
260
|
-
customer: {
|
|
261
|
-
email: 'user@example.com',
|
|
262
|
-
name: 'John Doe',
|
|
263
|
-
},
|
|
264
|
-
return_url: 'https://yoursite.com/welcome',
|
|
180
|
+
const preview = await client.subscriptions.previewChangePlan('sub_xxxxx', {
|
|
181
|
+
product_id: 'pdt_new_plan',
|
|
182
|
+
quantity: 1,
|
|
183
|
+
proration_billing_mode: 'prorated_immediately',
|
|
265
184
|
});
|
|
185
|
+
|
|
186
|
+
console.log('Effective at:', preview.immediate_charge.effective_at);
|
|
187
|
+
console.log('Line items:', preview.immediate_charge.line_items);
|
|
188
|
+
console.log('Summary:', preview.immediate_charge.summary);
|
|
189
|
+
console.log('New plan:', preview.new_plan);
|
|
266
190
|
```
|
|
267
191
|
|
|
268
|
-
###
|
|
192
|
+
### Cancel a Scheduled Plan Change
|
|
269
193
|
|
|
270
|
-
|
|
194
|
+
```typescript
|
|
195
|
+
await client.subscriptions.cancelChangePlan('sub_xxxxx');
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Cancellation
|
|
201
|
+
|
|
202
|
+
### Immediate Cancellation
|
|
271
203
|
|
|
272
204
|
```typescript
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
customer_id: 'cust_xxxxx',
|
|
276
|
-
return_url: 'https://yoursite.com/account',
|
|
205
|
+
await client.subscriptions.update('sub_xxxxx', {
|
|
206
|
+
status: 'cancelled',
|
|
277
207
|
});
|
|
208
|
+
```
|
|
278
209
|
|
|
279
|
-
|
|
210
|
+
Access is revoked immediately.
|
|
211
|
+
|
|
212
|
+
### Cancel at Period End
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
await client.subscriptions.update('sub_xxxxx', {
|
|
216
|
+
cancel_at_next_billing_date: true,
|
|
217
|
+
});
|
|
280
218
|
```
|
|
281
219
|
|
|
282
|
-
|
|
283
|
-
- View subscription details
|
|
284
|
-
- Update payment method
|
|
285
|
-
- Cancel subscription
|
|
286
|
-
- View billing history
|
|
220
|
+
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.
|
|
287
221
|
|
|
288
222
|
---
|
|
289
223
|
|
|
290
|
-
## On-Demand (
|
|
224
|
+
## On-Demand (Off-Session) Charging
|
|
291
225
|
|
|
292
|
-
For
|
|
226
|
+
For usage-based or metered subscriptions, charge the customer on-demand without a scheduled renewal.
|
|
293
227
|
|
|
294
228
|
### Create Subscription with Mandate
|
|
295
229
|
|
|
296
230
|
```typescript
|
|
297
231
|
const session = await client.checkoutSessions.create({
|
|
298
232
|
product_cart: [
|
|
299
|
-
{ product_id: '
|
|
233
|
+
{ product_id: 'pdt_usage_based', quantity: 1 }
|
|
300
234
|
],
|
|
235
|
+
subscription_data: {
|
|
236
|
+
on_demand: {
|
|
237
|
+
mandate_only: true,
|
|
238
|
+
}
|
|
239
|
+
},
|
|
301
240
|
customer: { email: 'user@example.com' },
|
|
302
241
|
return_url: 'https://yoursite.com/success',
|
|
303
242
|
});
|
|
304
243
|
```
|
|
305
244
|
|
|
306
|
-
|
|
245
|
+
This creates a subscription with a mandate but no automatic renewal. You control when to charge.
|
|
246
|
+
|
|
247
|
+
### Charge On-Demand
|
|
307
248
|
|
|
308
249
|
```typescript
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
description: 'API calls for January 2025',
|
|
250
|
+
const charge = await client.subscriptions.charge('sub_xxxxx', {
|
|
251
|
+
product_price: 2500, // $25.00 in cents
|
|
252
|
+
product_currency: 'USD',
|
|
253
|
+
product_description: 'API calls for January 2025',
|
|
314
254
|
});
|
|
315
|
-
```
|
|
316
255
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
```typescript
|
|
320
|
-
// payment.succeeded - Charge succeeded
|
|
321
|
-
// payment.failed - Charge failed, implement retry logic
|
|
256
|
+
console.log('Payment ID:', charge.payment_id);
|
|
322
257
|
```
|
|
323
258
|
|
|
324
|
-
|
|
259
|
+
Amounts are in the smallest currency unit (cents for USD, paise for INR, etc.).
|
|
325
260
|
|
|
326
|
-
|
|
261
|
+
**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.
|
|
327
262
|
|
|
328
|
-
|
|
263
|
+
---
|
|
329
264
|
|
|
330
|
-
|
|
265
|
+
## Failed Payments & Recovery
|
|
331
266
|
|
|
332
|
-
|
|
333
|
-
2. Create/edit a subscription product
|
|
334
|
-
3. In **Entitlements** section, click **Attach** next to Credits
|
|
335
|
-
4. Configure: credits per cycle, trial credits, proration, low balance threshold
|
|
267
|
+
### On-Hold Subscriptions
|
|
336
268
|
|
|
337
|
-
|
|
269
|
+
When a renewal or plan-change charge fails, the subscription moves to `on_hold`. This is recoverable.
|
|
338
270
|
|
|
339
271
|
```typescript
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
subscription_data: {
|
|
346
|
-
trial_period_days: 14, // Trial credits can differ from regular amount
|
|
347
|
-
},
|
|
348
|
-
customer: { email: 'user@example.com' },
|
|
349
|
-
return_url: 'https://yoursite.com/success',
|
|
350
|
-
});
|
|
272
|
+
// Listen for subscription.on_hold webhook
|
|
273
|
+
case 'subscription.on_hold':
|
|
274
|
+
// Notify customer, offer payment method update
|
|
275
|
+
await sendPaymentFailedEmail(data.customer.customer_id);
|
|
276
|
+
break;
|
|
351
277
|
```
|
|
352
278
|
|
|
353
|
-
###
|
|
279
|
+
### Recovery Options
|
|
354
280
|
|
|
355
|
-
|
|
356
|
-
1. **New credits issued** — `credit.added` webhook fires
|
|
357
|
-
2. **Usage deducts credits** — Automatically via meters or manually via API
|
|
358
|
-
3. **Cycle ends** — Unused credits expire or roll over based on settings
|
|
359
|
-
4. **Overage handled** — Forgiven, billed, or carried as deficit
|
|
281
|
+
1. **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.
|
|
360
282
|
|
|
361
|
-
|
|
283
|
+
2. **Customer Portal:** Customer updates payment method → automatic charge for outstanding dues → `subscription.active`.
|
|
362
284
|
|
|
363
|
-
|
|
364
|
-
case 'credit.added':
|
|
365
|
-
// Credits issued with subscription renewal
|
|
366
|
-
await syncCreditBalance(data.customer_id, data.credit_entitlement_id, data.balance_after);
|
|
367
|
-
break;
|
|
368
|
-
case 'credit.balance_low':
|
|
369
|
-
// Notify customer or suggest upgrade
|
|
370
|
-
await sendLowBalanceAlert(data.customer_id, data.credit_entitlement_name, data.available_balance);
|
|
371
|
-
break;
|
|
372
|
-
case 'credit.deducted':
|
|
373
|
-
// Track consumption for analytics
|
|
374
|
-
await logCreditUsage(data.customer_id, data.amount);
|
|
375
|
-
break;
|
|
376
|
-
```
|
|
285
|
+
3. **Manual Charge:** You update the payment method server-side, then call `updatePaymentMethod()`.
|
|
377
286
|
|
|
378
|
-
###
|
|
287
|
+
### Dunning
|
|
379
288
|
|
|
380
|
-
|
|
381
|
-
- **Proration enabled**: Remaining credits are prorated based on time left in cycle
|
|
382
|
-
- **Proration disabled**: Credits continue as-is until next cycle
|
|
289
|
+
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.
|
|
383
290
|
|
|
384
|
-
|
|
291
|
+
---
|
|
385
292
|
|
|
386
|
-
|
|
293
|
+
## Customer Portal
|
|
294
|
+
|
|
295
|
+
Allow customers to self-serve: view subscriptions, update payment methods, cancel, and upgrade/downgrade.
|
|
387
296
|
|
|
388
297
|
```typescript
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
298
|
+
const portalSession = await client.customers.customerPortal.create(
|
|
299
|
+
'cus_xxxxx',
|
|
300
|
+
{ return_url: 'https://yoursite.com/account' }
|
|
301
|
+
);
|
|
393
302
|
|
|
394
|
-
//
|
|
395
|
-
await client.subscriptions.update({
|
|
396
|
-
subscription_id: 'sub_xxxxx',
|
|
397
|
-
product_id: 'prod_new_plan',
|
|
398
|
-
proration_behavior: 'create_prorations', // or 'none'
|
|
399
|
-
});
|
|
303
|
+
// Redirect to portalSession.link
|
|
400
304
|
```
|
|
401
305
|
|
|
402
|
-
|
|
306
|
+
Portal links expire after 24 hours. Customers can:
|
|
307
|
+
- View subscription details and renewal dates
|
|
308
|
+
- Cancel immediately or at period end
|
|
309
|
+
- Upgrade/downgrade within enabled Product Collections
|
|
310
|
+
- Update payment methods and reactivate `on_hold` subscriptions
|
|
311
|
+
- View billing history and download invoices
|
|
403
312
|
|
|
404
|
-
|
|
405
|
-
async function handlePlanChanged(data: any) {
|
|
406
|
-
const { subscription_id, product_id, customer } = data;
|
|
407
|
-
|
|
408
|
-
// Map product to features/limits
|
|
409
|
-
const planFeatures = getPlanFeatures(product_id);
|
|
410
|
-
|
|
411
|
-
await prisma.user.update({
|
|
412
|
-
where: { externalId: customer.customer_id },
|
|
413
|
-
data: {
|
|
414
|
-
plan: product_id,
|
|
415
|
-
features: planFeatures,
|
|
416
|
-
apiLimit: planFeatures.apiLimit,
|
|
417
|
-
storageLimit: planFeatures.storageLimit,
|
|
418
|
-
},
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
```
|
|
313
|
+
For full customer management (creating, updating, listing), see the `customer-management` skill.
|
|
422
314
|
|
|
423
315
|
---
|
|
424
316
|
|
|
425
|
-
##
|
|
317
|
+
## Webhook Events
|
|
318
|
+
|
|
319
|
+
| Event | When | Action |
|
|
320
|
+
|-------|------|--------|
|
|
321
|
+
| `subscription.active` | Subscription becomes active, including a trial start or recovery | Grant access |
|
|
322
|
+
| `subscription.renewed` | Successful renewal | Log renewal, send receipt |
|
|
323
|
+
| `subscription.on_hold` | Renewal/plan-change payment failed | Notify customer, offer recovery |
|
|
324
|
+
| `subscription.plan_changed` | Plan upgraded/downgraded or add-ons changed | Update entitlements |
|
|
325
|
+
| `subscription.cancelled` | Customer cancels | Schedule access revocation per `cancel_at_next_billing_date` |
|
|
326
|
+
| `subscription.failed` | Initial mandate/payment failed | Notify customer, offer retry or new subscription |
|
|
327
|
+
| `subscription.expired` | Subscription term ended | Revoke access |
|
|
328
|
+
|
|
329
|
+
Webhook signature verification, raw-body handling, durable processing, and idempotency are covered in the `webhook-integration` skill.
|
|
426
330
|
|
|
427
|
-
###
|
|
331
|
+
### Example Handler
|
|
428
332
|
|
|
429
333
|
```typescript
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
if (!
|
|
439
|
-
return NextResponse.
|
|
334
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
335
|
+
|
|
336
|
+
export async function POST(req: NextRequest) {
|
|
337
|
+
const raw = await req.text();
|
|
338
|
+
const headers = Object.fromEntries(req.headers.entries());
|
|
339
|
+
const event = await client.webhooks.unwrap(raw, { headers });
|
|
340
|
+
const webhookId = req.headers.get('webhook-id');
|
|
341
|
+
|
|
342
|
+
if (!webhookId) {
|
|
343
|
+
return NextResponse.json({ error: 'Missing webhook-id' }, { status: 400 });
|
|
440
344
|
}
|
|
441
345
|
|
|
442
|
-
|
|
346
|
+
// Implement this as an atomic insert backed by a UNIQUE constraint.
|
|
347
|
+
// Keep the claim and entitlement changes in the same database transaction.
|
|
348
|
+
const claimed = await claimWebhookId(webhookId);
|
|
349
|
+
if (!claimed) {
|
|
350
|
+
return NextResponse.json({ received: true });
|
|
351
|
+
}
|
|
443
352
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
353
|
+
switch (event.type) {
|
|
354
|
+
case 'subscription.active':
|
|
355
|
+
await grantAccess(event.data.customer.customer_id, event.data.product_id);
|
|
356
|
+
break;
|
|
357
|
+
case 'subscription.on_hold':
|
|
358
|
+
await notifyPaymentFailed(event.data.customer.customer_id);
|
|
359
|
+
break;
|
|
360
|
+
case 'subscription.cancelled':
|
|
361
|
+
if (event.data.cancel_at_next_billing_date) {
|
|
362
|
+
await scheduleAccessRevocation(event.data.subscription_id, new Date(event.data.next_billing_date));
|
|
363
|
+
} else {
|
|
364
|
+
await revokeAccessImmediately(event.data.subscription_id);
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
case 'subscription.expired':
|
|
368
|
+
await revokeAccess(event.data.customer.customer_id);
|
|
369
|
+
break;
|
|
454
370
|
}
|
|
455
371
|
|
|
456
|
-
return NextResponse.
|
|
372
|
+
return NextResponse.json({ received: true });
|
|
457
373
|
}
|
|
458
374
|
```
|
|
459
375
|
|
|
460
|
-
|
|
376
|
+
---
|
|
461
377
|
|
|
462
|
-
|
|
463
|
-
// hooks/useSubscription.ts
|
|
464
|
-
import useSWR from 'swr';
|
|
465
|
-
|
|
466
|
-
export function useSubscription() {
|
|
467
|
-
const { data, error, mutate } = useSWR('/api/subscription', fetcher);
|
|
468
|
-
|
|
469
|
-
return {
|
|
470
|
-
subscription: data,
|
|
471
|
-
isLoading: !error && !data,
|
|
472
|
-
isError: error,
|
|
473
|
-
isActive: data?.status === 'active',
|
|
474
|
-
isPro: data?.plan?.includes('pro'),
|
|
475
|
-
refresh: mutate,
|
|
476
|
-
};
|
|
477
|
-
}
|
|
378
|
+
## Credit Entitlements
|
|
478
379
|
|
|
479
|
-
|
|
480
|
-
function PremiumFeature() {
|
|
481
|
-
const { isActive, isPro } = useSubscription();
|
|
380
|
+
Attach credit entitlements to subscription products to grant credits each billing cycle. For full credit management, see the `credit-based-billing` skill.
|
|
482
381
|
|
|
483
|
-
|
|
484
|
-
return <UpgradePrompt />;
|
|
485
|
-
}
|
|
382
|
+
Quick example:
|
|
486
383
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
384
|
+
```typescript
|
|
385
|
+
// Product has credit entitlement attached (e.g., 10,000 tokens/month)
|
|
386
|
+
const session = await client.checkoutSessions.create({
|
|
387
|
+
product_cart: [
|
|
388
|
+
{ product_id: 'pdt_pro_with_credits', quantity: 1 }
|
|
389
|
+
],
|
|
390
|
+
subscription_data: {
|
|
391
|
+
trial_period_days: 14,
|
|
392
|
+
},
|
|
393
|
+
customer: { email: 'user@example.com' },
|
|
394
|
+
return_url: 'https://yoursite.com/success',
|
|
395
|
+
});
|
|
493
396
|
```
|
|
494
397
|
|
|
398
|
+
On each renewal, credits are issued. Monitor `credit.added` and `credit.deducted` webhooks to sync your ledger.
|
|
399
|
+
|
|
495
400
|
---
|
|
496
401
|
|
|
497
|
-
## Common
|
|
402
|
+
## Common Mistakes
|
|
498
403
|
|
|
499
|
-
###
|
|
404
|
+
### 1. Granting Access on `return_url` Instead of Webhook
|
|
500
405
|
|
|
406
|
+
**Wrong:**
|
|
501
407
|
```typescript
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
await scheduleAccessRevocation(data.subscription_id, gracePeriodDays);
|
|
515
|
-
}
|
|
408
|
+
// On return_url redirect
|
|
409
|
+
await grantAccess(user.id);
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
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.
|
|
413
|
+
|
|
414
|
+
**Right:**
|
|
415
|
+
```typescript
|
|
416
|
+
// In webhook handler
|
|
417
|
+
case 'subscription.active':
|
|
418
|
+
await grantAccess(data.customer.customer_id);
|
|
419
|
+
break;
|
|
516
420
|
```
|
|
517
421
|
|
|
518
|
-
###
|
|
422
|
+
### 2. Using Deprecated `subscriptions.create`
|
|
519
423
|
|
|
520
|
-
|
|
424
|
+
**Wrong:**
|
|
521
425
|
```typescript
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
subscription_id: 'sub_xxxxx',
|
|
526
|
-
product_id: 'prod_higher_plan',
|
|
527
|
-
proration_behavior: 'create_prorations',
|
|
426
|
+
const sub = await client.subscriptions.create({
|
|
427
|
+
product_id: 'pdt_monthly',
|
|
428
|
+
customer_id: 'cus_xxxxx',
|
|
528
429
|
});
|
|
529
430
|
```
|
|
530
431
|
|
|
531
|
-
|
|
432
|
+
This endpoint is deprecated. Use Checkout Sessions.
|
|
532
433
|
|
|
434
|
+
**Right:**
|
|
533
435
|
```typescript
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
436
|
+
const session = await client.checkoutSessions.create({
|
|
437
|
+
product_cart: [{ product_id: 'pdt_monthly', quantity: 1 }],
|
|
438
|
+
customer: { customer_id: 'cus_xxxxx' },
|
|
439
|
+
return_url: 'https://yoursite.com/success',
|
|
440
|
+
});
|
|
441
|
+
```
|
|
537
442
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
443
|
+
### 3. Misinterpreting `subscription.plan_changed`
|
|
444
|
+
|
|
445
|
+
`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`.
|
|
446
|
+
|
|
447
|
+
### 4. Forgetting Proration Mode
|
|
448
|
+
|
|
449
|
+
**Wrong:**
|
|
450
|
+
|
|
451
|
+
```typescript
|
|
452
|
+
// Missing proration_billing_mode
|
|
453
|
+
await client.subscriptions.changePlan('sub_xxxxx', {
|
|
454
|
+
product_id: 'pdt_new',
|
|
455
|
+
quantity: 1,
|
|
456
|
+
// proration_billing_mode: 'prorated_immediately', // REQUIRED
|
|
457
|
+
});
|
|
545
458
|
```
|
|
546
459
|
|
|
547
|
-
|
|
460
|
+
This will fail. Always specify a proration mode.
|
|
461
|
+
|
|
462
|
+
### 5. Confusing `on_hold` with Pause
|
|
548
463
|
|
|
549
|
-
|
|
464
|
+
`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.
|
|
550
465
|
|
|
551
|
-
###
|
|
552
|
-
1. New subscription → `subscription.active`
|
|
553
|
-
2. Renewal success → `subscription.renewed` + `payment.succeeded`
|
|
554
|
-
3. Renewal failure → `subscription.on_hold` + `payment.failed`
|
|
555
|
-
4. Plan upgrade → `subscription.plan_changed`
|
|
556
|
-
5. Cancellation → `subscription.cancelled`
|
|
557
|
-
6. Expiration → `subscription.expired`
|
|
466
|
+
### 6. Not Handling `cancel_at_next_billing_date`
|
|
558
467
|
|
|
559
|
-
|
|
560
|
-
|
|
468
|
+
```typescript
|
|
469
|
+
// Wrong: revoke immediately
|
|
470
|
+
await revokeAccess(data.customer.customer_id);
|
|
471
|
+
|
|
472
|
+
// Right: check the flag
|
|
473
|
+
if (data.cancel_at_next_billing_date) {
|
|
474
|
+
await scheduleAccessRevocation(data.subscription_id, new Date(data.next_billing_date));
|
|
475
|
+
} else {
|
|
476
|
+
await revokeAccessImmediately(data.subscription_id);
|
|
477
|
+
}
|
|
478
|
+
```
|
|
561
479
|
|
|
562
480
|
---
|
|
563
481
|
|
|
564
482
|
## Resources
|
|
565
483
|
|
|
566
|
-
- [Subscription Guide](https://docs.dodopayments.com/developer-resources/subscription-integration-guide)
|
|
484
|
+
- [Subscription Integration Guide](https://docs.dodopayments.com/developer-resources/subscription-integration-guide)
|
|
485
|
+
- [Upgrade & Downgrade Guide](https://docs.dodopayments.com/developer-resources/subscription-upgrade-downgrade)
|
|
567
486
|
- [On-Demand Subscriptions](https://docs.dodopayments.com/developer-resources/ondemand-subscriptions)
|
|
568
|
-
- [
|
|
569
|
-
- [Customer Portal](https://docs.dodopayments.com/
|
|
570
|
-
- [
|
|
571
|
-
- [
|
|
487
|
+
- [Subscription Webhooks](https://docs.dodopayments.com/developer-resources/webhooks/intents/subscription)
|
|
488
|
+
- [Customer Portal](https://docs.dodopayments.com/features/customer-portal)
|
|
489
|
+
- [Subscription Payment Retries](https://docs.dodopayments.com/features/recovery/payment-retries)
|
|
490
|
+
- [Subscription Dunning](https://docs.dodopayments.com/features/recovery/subscription-dunning)
|