@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.
@@ -1,39 +1,52 @@
1
1
  ---
2
2
  name: subscription-integration
3
- description: Guide for implementing subscription billing with Dodo Payments - trials, upgrades, downgrades, and on-demand billing.
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
- **Reference: [docs.dodopayments.com/developer-resources/subscription-integration-guide](https://docs.dodopayments.com/developer-resources/subscription-integration-guide)**
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
- Implement recurring billing with trials, plan changes, and usage-based pricing.
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
- ## Quick Start
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
- ### 1. Create Subscription Product
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
- ### 2. Create Checkout Session
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: 'prod_monthly_plan', quantity: 1 }
46
+ { product_id: 'pdt_monthly_plan', quantity: 1 }
34
47
  ],
35
48
  subscription_data: {
36
- trial_period_days: 14, // Optional trial
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
- ### 3. Handle Webhook Events
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
- // subscription.active - Grant access
51
- // subscription.cancelled - Schedule access revocation
52
- // subscription.renewed - Log renewal
53
- // payment.succeeded - Track payments
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
- │ Created │ ──▶ │ Trial │ ──▶ │ Active │
63
- └─────────────┘ └─────────┘ └────────┘
64
- │
65
- ┌────────────────────┼────────────────────┐
66
- ▼ ▼ ▼
67
- ┌──────────┐ ┌───────────┐ ┌───────────┐
68
- │ On Hold │ │ Cancelled │ │ Renewed │
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
- ## Webhook Events
102
+ ## Subscription Methods
80
103
 
81
- | Event | When | Action |
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
- ## Implementation Examples
111
+ ### Update Payment Method
95
112
 
96
- ### Full Subscription Handler
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
- // app/api/webhooks/subscription/route.ts
100
- import { NextRequest, NextResponse } from 'next/server';
101
- import { prisma } from '@/lib/prisma';
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
- export async function POST(req: NextRequest) {
104
- const event = await req.json();
105
- const data = event.data;
124
+ Success emits `payment.succeeded` followed by `subscription.active`.
106
125
 
107
- switch (event.type) {
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
- return NextResponse.json({ received: true });
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
- async function handleSubscriptionActive(data: any) {
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
- async function handleSubscriptionCancelled(data: any) {
174
- const { subscription_id, customer, cancelled_at, cancel_at_next_billing_date } = data;
175
-
176
- await prisma.subscription.update({
177
- where: { externalId: subscription_id },
178
- data: {
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
- async function handleSubscriptionOnHold(data: any) {
193
- const { subscription_id, customer } = data;
146
+ ---
194
147
 
195
- await prisma.subscription.update({
196
- where: { externalId: subscription_id },
197
- data: { status: 'on_hold' },
198
- });
148
+ ## Plan Changes (Upgrades & Downgrades)
199
149
 
200
- // Notify user about payment issue
201
- await sendPaymentFailedEmail(customer.email);
202
- }
150
+ ### Change Plan
203
151
 
204
- async function handleSubscriptionRenewed(data: any) {
205
- const { subscription_id, next_billing_date } = data;
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
- await prisma.subscription.update({
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
- async function handlePlanChanged(data: any) {
217
- const { subscription_id, product_id, recurring_pre_tax_amount } = data;
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
- await prisma.subscription.update({
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
- // Update user entitlements based on new plan
228
- await updateUserEntitlements(subscription_id, product_id);
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
- async function handleSubscriptionExpired(data: any) {
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
- ### Subscription with Trial
177
+ Show the customer a quote before committing:
251
178
 
252
179
  ```typescript
253
- const session = await client.checkoutSessions.create({
254
- product_cart: [
255
- { product_id: 'prod_pro_monthly', quantity: 1 }
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
- ### Customer Portal for Self-Service
192
+ ### Cancel a Scheduled Plan Change
269
193
 
270
- Allow customers to manage their subscription:
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
- // Create portal session
274
- const portal = await client.customers.createPortalSession({
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
- // Redirect to portal.url
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
- Portal features:
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 (Usage-Based) Subscriptions
224
+ ## On-Demand (Off-Session) Charging
291
225
 
292
- For metered/usage-based billing:
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: 'prod_usage_based', quantity: 1 }
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
- ### Charge for Usage
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
- // When usage occurs, create a charge
310
- const charge = await client.subscriptions.charge({
311
- subscription_id: 'sub_xxxxx',
312
- amount: 1500, // $15.00 in cents
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
- ### Track Usage Events
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
- ## Subscriptions with Credit Entitlements
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
- Attach credit entitlements to subscription products to grant credits each billing cycle:
263
+ ---
329
264
 
330
- ### Setup
265
+ ## Failed Payments & Recovery
331
266
 
332
- 1. Create a credit entitlement (Dashboard → Products → Credits)
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
- ### Checkout with Credits
269
+ When a renewal or plan-change charge fails, the subscription moves to `on_hold`. This is recoverable.
338
270
 
339
271
  ```typescript
340
- // Product has credit entitlement attached (e.g., 10,000 AI tokens/month)
341
- const session = await client.checkoutSessions.create({
342
- product_cart: [
343
- { product_id: 'prod_pro_with_credits', quantity: 1 }
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
- ### Credit Lifecycle per Cycle
279
+ ### Recovery Options
354
280
 
355
- Each billing cycle:
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
- ### Handle Credit Webhooks in Subscription Context
283
+ 2. **Customer Portal:** Customer updates payment method → automatic charge for outstanding dues → `subscription.active`.
362
284
 
363
- ```typescript
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
- ### Plan Changes with Credits
287
+ ### Dunning
379
288
 
380
- When customers upgrade/downgrade, credit proration can be enabled:
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
- ## Plan Changes
291
+ ---
385
292
 
386
- ### Upgrade/Downgrade Flow
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
- // Get available plans
390
- const plans = await client.products.list({
391
- type: 'subscription',
392
- });
298
+ const portalSession = await client.customers.customerPortal.create(
299
+ 'cus_xxxxx',
300
+ { return_url: 'https://yoursite.com/account' }
301
+ );
393
302
 
394
- // Change plan
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
- ### Handling `subscription.plan_changed`
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
- ```typescript
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
- ## Access Control Pattern
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
- ### Middleware Example (Next.js)
331
+ ### Example Handler
428
332
 
429
333
  ```typescript
430
- // middleware.ts
431
- import { NextResponse } from 'next/server';
432
- import type { NextRequest } from 'next/server';
433
-
434
- export async function middleware(request: NextRequest) {
435
- // Check subscription status
436
- const session = await getSession(request);
437
-
438
- if (!session?.user) {
439
- return NextResponse.redirect(new URL('/login', request.url));
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
- const subscription = await getSubscription(session.user.id);
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
- // Check if accessing premium feature
445
- if (request.nextUrl.pathname.startsWith('/dashboard/pro')) {
446
- if (!subscription || subscription.status !== 'active') {
447
- return NextResponse.redirect(new URL('/pricing', request.url));
448
- }
449
-
450
- // Check if plan includes this feature
451
- if (!subscription.features.includes('pro')) {
452
- return NextResponse.redirect(new URL('/upgrade', request.url));
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.next();
372
+ return NextResponse.json({ received: true });
457
373
  }
458
374
  ```
459
375
 
460
- ### React Hook for Subscription State
376
+ ---
461
377
 
462
- ```typescript
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
- // Usage in component
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
- if (!isActive) {
484
- return <UpgradePrompt />;
485
- }
382
+ Quick example:
486
383
 
487
- if (!isPro) {
488
- return <ProUpgradePrompt />;
489
- }
490
-
491
- return <ActualFeature />;
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 Patterns
402
+ ## Common Mistakes
498
403
 
499
- ### Grace Period for Failed Payments
404
+ ### 1. Granting Access on `return_url` Instead of Webhook
500
405
 
406
+ **Wrong:**
501
407
  ```typescript
502
- async function handleSubscriptionOnHold(data: any) {
503
- const gracePeriodDays = 7;
504
-
505
- await prisma.subscription.update({
506
- where: { externalId: data.subscription_id },
507
- data: {
508
- status: 'on_hold',
509
- gracePeriodEnds: new Date(Date.now() + gracePeriodDays * 24 * 60 * 60 * 1000),
510
- },
511
- });
512
-
513
- // Schedule job to revoke access after grace period
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
- ### Prorated Upgrades
422
+ ### 2. Using Deprecated `subscriptions.create`
519
423
 
520
- When upgrading mid-cycle:
424
+ **Wrong:**
521
425
  ```typescript
522
- // Dodo handles proration automatically
523
- // Customer pays difference for remaining days
524
- await client.subscriptions.update({
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
- ### Cancellation with End-of-Period Access
432
+ This endpoint is deprecated. Use Checkout Sessions.
532
433
 
434
+ **Right:**
533
435
  ```typescript
534
- // subscription.cancelled event includes:
535
- // - cancel_at_next_billing_date: boolean
536
- // - next_billing_date: string (when access should end)
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
- if (data.cancel_at_next_billing_date) {
539
- // Keep access until next_billing_date
540
- await scheduleAccessRevocation(
541
- data.subscription_id,
542
- new Date(data.next_billing_date)
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
- ## Testing
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
- ### Test Scenarios
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
- ### Test in Dashboard
560
- Use test mode and trigger events manually from the webhook settings.
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
- - [Webhook Events](https://docs.dodopayments.com/developer-resources/webhooks/intents/subscription)
569
- - [Customer Portal](https://docs.dodopayments.com/developer-resources/customer-portal)
570
- - [Credit-Based Billing](https://docs.dodopayments.com/features/credit-based-billing)
571
- - [Credit Webhook Events](https://docs.dodopayments.com/developer-resources/webhooks/intents/credit)
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)