@dodopayments/opencode-plugin 0.6.0 → 0.6.2

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.
Files changed (47) hide show
  1. package/README.md +1 -0
  2. package/opencode-plugin/README.md +1 -1
  3. package/package.json +1 -1
  4. package/skills/better-auth-integration/SKILL.md +12 -3
  5. package/skills/billing-sdk/SKILL.md +15 -167
  6. package/skills/billing-sdk/references/pricing-page.md +197 -0
  7. package/skills/checkout-integration/SKILL.md +31 -460
  8. package/skills/checkout-integration/references/overlay-checkout.md +148 -0
  9. package/skills/checkout-integration/references/server-examples.md +317 -0
  10. package/skills/credit-based-billing/SKILL.md +11 -158
  11. package/skills/credit-based-billing/references/ai-token-example.md +108 -0
  12. package/skills/credit-based-billing/references/credit-webhooks.md +51 -0
  13. package/skills/customer-management/SKILL.md +25 -17
  14. package/skills/discounts-and-promotions/SKILL.md +20 -17
  15. package/skills/dodo-best-practices/SKILL.md +18 -5
  16. package/skills/dodo-mcp-usage/SKILL.md +152 -0
  17. package/skills/framework-adapters/SKILL.md +64 -421
  18. package/skills/framework-adapters/references/astro.md +52 -0
  19. package/skills/framework-adapters/references/bun.md +45 -0
  20. package/skills/framework-adapters/references/convex.md +91 -0
  21. package/skills/framework-adapters/references/express.md +72 -0
  22. package/skills/framework-adapters/references/fastify.md +54 -0
  23. package/skills/framework-adapters/references/hono.md +63 -0
  24. package/skills/framework-adapters/references/nuxt.md +53 -0
  25. package/skills/framework-adapters/references/remix.md +65 -0
  26. package/skills/framework-adapters/references/sveltekit.md +49 -0
  27. package/skills/framework-adapters/references/tanstack-start.md +31 -0
  28. package/skills/license-keys/SKILL.md +117 -367
  29. package/skills/license-keys/references/cli-tool.md +143 -0
  30. package/skills/license-keys/references/desktop-app.md +170 -0
  31. package/skills/localized-pricing/SKILL.md +51 -29
  32. package/skills/mobile-checkout/SKILL.md +72 -310
  33. package/skills/mobile-checkout/references/android.md +69 -0
  34. package/skills/mobile-checkout/references/flutter.md +97 -0
  35. package/skills/mobile-checkout/references/ios.md +66 -0
  36. package/skills/mobile-checkout/references/react-native.md +100 -0
  37. package/skills/product-catalog-management/SKILL.md +39 -16
  38. package/skills/refunds-and-disputes/SKILL.md +91 -71
  39. package/skills/subscription-integration/SKILL.md +43 -64
  40. package/skills/subscription-integration/references/webhook-events.md +82 -0
  41. package/skills/testing-and-go-live/SKILL.md +22 -15
  42. package/skills/usage-based-billing/SKILL.md +16 -122
  43. package/skills/usage-based-billing/references/instrumentation.md +119 -0
  44. package/skills/webhook-integration/SKILL.md +31 -263
  45. package/skills/webhook-integration/references/common-mistakes.md +146 -0
  46. package/skills/webhook-integration/references/event-catalog.md +103 -0
  47. package/skills/webhook-integration/references/local-testing.md +42 -0
@@ -0,0 +1,100 @@
1
+ # Mobile In-App Checkout: React Native (Turbo Module)
2
+
3
+
4
+ ### Installation
5
+
6
+ ```bash
7
+ npm install @dodopayments/react-native-checkout
8
+ ```
9
+
10
+ For Expo projects, add the plugin to `app.json`:
11
+
12
+ ```json
13
+ {
14
+ "expo": {
15
+ "scheme": "myapp",
16
+ "plugins": [
17
+ [
18
+ "@dodopayments/react-native-checkout",
19
+ { "scheme": "myappcheckout" }
20
+ ]
21
+ ]
22
+ }
23
+ }
24
+ ```
25
+
26
+ The plugin registers a custom URL scheme (`myappcheckout://`) that the checkout flow uses to return to your app.
27
+
28
+ ### Setup
29
+
30
+ Register the URL listener at app startup:
31
+
32
+ ```typescript
33
+ import { Linking } from 'react-native';
34
+ import { DodoCheckout } from '@dodopayments/react-native-checkout';
35
+
36
+ // Required for iOS return-URL handling
37
+ Linking.addEventListener('url', ({ url }) => DodoCheckout.handleOpenURL(url));
38
+ ```
39
+
40
+ ### Starting checkout
41
+
42
+ ```typescript
43
+ const result = await DodoCheckout.start({
44
+ checkoutUrl: 'https://checkout.dodopayments.com/...', // from your backend
45
+ returnUrl: 'myappcheckout://checkout/return', // must match registered scheme
46
+ onEvent: (e) => console.log(e.type), // optional event logging
47
+ });
48
+
49
+ switch (result.status) {
50
+ case 'succeeded':
51
+ // Payment succeeded. Verify server-side before granting access.
52
+ await verifyPaymentOnBackend(result.paymentId);
53
+ showSuccess();
54
+ break;
55
+ case 'failed':
56
+ // Payment failed. Show error to user.
57
+ showFailure();
58
+ break;
59
+ case 'cancelled':
60
+ // Sheet closed before the return URL arrived: the outcome is UNKNOWN and the
61
+ // payment may have succeeded. Do not show a failure - reconcile instead.
62
+ await reconcileAbandonedSession();
63
+ break;
64
+ case 'pending':
65
+ // Settles later (status=processing / requires_*), or status was missing.
66
+ await reconcileAbandonedSession();
67
+ break;
68
+ case 'expired':
69
+ // Checkout session expired. Prompt user to start a new checkout.
70
+ showExpired();
71
+ break;
72
+ }
73
+ ```
74
+
75
+ ### Abandoned session recovery
76
+
77
+ The SDK keeps a record of the session until checkout ends with `succeeded`, `failed`, or `expired`. The record survives an app kill and **stays after a `cancelled` or `pending` result**. Check it on the next launch and after every `cancelled`/`pending`:
78
+
79
+ ```typescript
80
+ import { DodoCheckout } from '@dodopayments/react-native-checkout';
81
+
82
+ async function reconcileAbandonedSession() {
83
+ const abandoned = await DodoCheckout.getAbandonedSession();
84
+ if (!abandoned) return;
85
+
86
+ // Your backend calls GET /checkouts/{abandoned.sessionId} and returns payment_status.
87
+ const outcome = await fetchCheckoutOutcome(abandoned.sessionId);
88
+ if (outcome === 'succeeded' || outcome === 'failed' || outcome === 'expired') {
89
+ showOutcome(outcome);
90
+ // Clear only once the outcome is final; until then treat it as pending, not failed.
91
+ await DodoCheckout.clearAbandonedSession();
92
+ } else {
93
+ showPending();
94
+ }
95
+ }
96
+ ```
97
+
98
+ ### Android minSdk requirement
99
+
100
+ React Native checkout requires Android minSdk 24 or higher. Note: the general mobile documentation mentions minSdk 23, but React Native specifically requires 24.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: product-catalog-management
3
- description: Guide for creating and managing products, pricing, add-ons, product collections, images, and digital product delivery
3
+ description: Dodo Payments product catalog management, covering products with one_time_price, recurring_price, or usage_based_price, add-ons, product collections, product images, and digital file delivery. Use when creating or updating products, plans, or prices, building a storefront, uploading images, attaching downloadable files, or debugging a failed product update.
4
4
  ---
5
5
 
6
6
  # Product Catalog Management
@@ -13,7 +13,7 @@ This skill covers the full product lifecycle: creating products with pricing mod
13
13
  - You're building a product collection or storefront.
14
14
  - You need to upload product images or deliver digital files to customers.
15
15
  - You're managing add-ons or product variants.
16
- - You need to understand why a product update failed or why pricing can't be changed.
16
+ - You need to understand why a product update failed or how to change a product's pricing.
17
17
 
18
18
  ## Core concepts
19
19
 
@@ -36,7 +36,7 @@ Pricing is nested inside the product object. There is no separate top-level Pric
36
36
 
37
37
  **Lifecycle:** Products support `list`, `retrieve`, `update`, and `archive`/`unarchive`. There is no delete endpoint. Archived products remain in your history but don't appear in new checkouts.
38
38
 
39
- **Images:** Presigned upload URLs expire after 60 seconds. Download the URL immediately after requesting it.
39
+ **Images:** Presigned upload URLs expire after 60 seconds. Upload to the URL immediately after requesting it.
40
40
 
41
41
  **Digital delivery:** Entitlements grant customers access to files. Download URLs expire after roughly 15 minutes.
42
42
 
@@ -129,7 +129,9 @@ const product = await client.products.retrieve('pdt_pro_bundle');
129
129
 
130
130
  ## Updating a product
131
131
 
132
- You can update the name, description, and metadata. You cannot change the pricing model or price of a live product. Create a new product instead.
132
+ `PATCH /products/{id}` (`client.products.update`) accepts the name, description, and metadata, and also `price`, `tax_category`, and `pricing_mode`. Every field is optional; omitted fields stay unchanged.
133
+
134
+ Two limits apply. The **pricing model is immutable**: you can edit the amount, but you cannot turn a one-time product into a subscription or usage-based one (or back) - create a new product instead. And a price edit only changes what **new** customers pay: existing subscriptions keep the price they were created with, so move them explicitly with Change Plan (`client.subscriptions.changePlan(...)`).
133
135
 
134
136
  ```typescript
135
137
  await client.products.update('pdt_pro_bundle', {
@@ -226,14 +228,25 @@ await client.productCollections.groups.create(collection.id, {
226
228
  ],
227
229
  });
228
230
 
229
- // Checkout with a collection
230
- const session = await client.checkoutSessions.create({
231
- product_collection_id: collection.id,
232
- product_cart: [], // Required: pass an empty array for collection checkout
233
- return_url: 'https://yoursite.com/return',
234
- });
231
+ // Server route (e.g. POST /api/checkout): create the checkout session and return its URL
232
+ export async function POST() {
233
+ const session = await client.checkoutSessions.create({
234
+ product_collection_id: collection.id,
235
+ product_cart: [], // Required: pass an empty array for collection checkout
236
+ return_url: 'https://yoursite.com/return',
237
+ });
235
238
 
236
- window.location.href = session.checkout_url;
239
+ return Response.json({ checkout_url: session.checkout_url });
240
+ }
241
+ ```
242
+
243
+ Never call the SDK or expose the API key in browser code. The browser only receives the URL from your server route and redirects:
244
+
245
+ ```typescript
246
+ // Browser
247
+ const res = await fetch('/api/checkout', { method: 'POST' });
248
+ const { checkout_url } = (await res.json()) as { checkout_url: string };
249
+ window.location.href = checkout_url;
237
250
  ```
238
251
 
239
252
  ## Digital product delivery
@@ -241,6 +254,9 @@ window.location.href = session.checkout_url;
241
254
  Entitlements grant customers access to downloadable files. Create an entitlement, upload files, and generate download links.
242
255
 
243
256
  ```typescript
257
+ // openAsBlob requires Node.js 20 or later
258
+ import { openAsBlob } from 'node:fs';
259
+
244
260
  // Create an entitlement
245
261
  const entitlement = await client.entitlements.create({
246
262
  name: 'Pro Bundle Files',
@@ -250,10 +266,15 @@ const entitlement = await client.entitlements.create({
250
266
  },
251
267
  });
252
268
 
253
- // Upload and attach a file; the response identifies the attached file
254
- const { file_id } = await client.entitlements.files.upload(entitlement.id);
269
+ // Upload and attach a file as multipart/form-data (append `filename` before `file`)
270
+ const form = new FormData();
271
+ form.append('filename', 'pro-bundle.zip');
272
+ form.append('file', await openAsBlob('./pro-bundle.zip'), 'pro-bundle.zip');
273
+ const { file_id } = await client.entitlements.files.upload(entitlement.id, { body: form });
255
274
 
256
- // Attach the entitlement to a product
275
+ // Attach the entitlement to a product.
276
+ // `entitlements` REPLACES every entitlement already attached to the product:
277
+ // include all of them, send [] to remove all, or omit the field to leave them unchanged.
257
278
  await client.products.update('pdt_pro_bundle', {
258
279
  entitlements: [{ entitlement_id: entitlement.id }],
259
280
  });
@@ -290,7 +311,9 @@ For country or currency-specific pricing, use the `localized-pricing` skill. Do
290
311
 
291
312
  **Expecting a delete endpoint:** Products don't have a delete method. Archive them instead. Archived products remain in your history for reconciliation.
292
313
 
293
- **Trying to change the pricing model:** Once a product is created with `one_time_price`, you can't change it to `recurring_price`. Create a new product and migrate customers to it.
314
+ **Trying to change the pricing model:** You cannot switch a product between one-time, subscription, and usage-based pricing (for example `one_time_price` to `recurring_price`). Create a new product with the model you need.
315
+
316
+ **Expecting a price edit to reprice existing subscribers:** `client.products.update(...)` changes the amount for new customers only. Existing subscriptions renew at the price they were created with; move them with `client.subscriptions.changePlan(...)`.
294
317
 
295
318
  **Hardcoding prices in the client:** Always fetch the product catalog from the API. Prices change, and your client code will become stale. Read `client.products.retrieve(id)` to get the current price.
296
319
 
@@ -300,7 +323,7 @@ For country or currency-specific pricing, use the `localized-pricing` skill. Do
300
323
 
301
324
  **Passing a non-empty product_cart for collection checkout:** Collection checkout requires `product_cart: []`. The collection itself defines what's in the cart.
302
325
 
303
- **Confusing purchasing_power_parity with localized pricing:** The OpenAPI schema includes a `purchasing_power_parity` field in examples, but it's marked unavailable. Use the `localized-pricing` skill for country and currency pricing instead.
326
+ **Confusing purchasing_power_parity with localized pricing:** PPP is native. Set `price.purchasing_power_parity: true` to charge a business-wide, per-country percentage of the price (default `false`). It requires Adaptive Currency to be enabled, and a matching localized price always wins over PPP. See the `localized-pricing` skill.
304
327
 
305
328
  ## Resources
306
329
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: refunds-and-disputes
3
- description: Guide for issuing refunds, handling disputes and chargebacks, and reconciling customer access with Dodo Payments
3
+ description: Dodo Payments refunds and disputes, covering full and partial refunds, refund statuses, dispute and chargeback lifecycle events, evidence, and revoking or restoring customer access. Use when issuing a refund, handling refund or dispute webhooks, responding to a chargeback, or reconciling entitlements after a payment reversal.
4
4
  ---
5
5
 
6
6
  # Refunds and Disputes
@@ -23,7 +23,7 @@ This skill covers issuing refunds (full and partial), handling the dispute lifec
23
23
 
24
24
  **Amounts** are always in the smallest currency unit (cents for USD, paise for INR, etc.).
25
25
 
26
- **Access revocation** means removing the customer's ability to use the product or service. On a dispute, you typically revoke access while it's open. Restore it only on `dispute.won`; all other outcomes keep access revoked until you reconcile them separately.
26
+ **Access revocation** means removing the customer's ability to use the product or service. On a dispute, you typically revoke access while it's open. Restore it on `dispute.won` or `dispute.cancelled` (withdrawn, no further action needed); the losing outcomes (`dispute.accepted`, `dispute.lost`, `dispute.expired`) keep access revoked.
27
27
 
28
28
  ## Refunds
29
29
 
@@ -54,7 +54,7 @@ To refund only specific items from a payment, pass the `items` array with each i
54
54
  const partialRefund = await client.refunds.create({
55
55
  payment_id: 'pay_abc123',
56
56
  items: [
57
- { item_id: 'item_1', amount: 1000 },
57
+ { item_id: 'item_1' }, // no amount: the whole item is refunded
58
58
  { item_id: 'item_2', amount: 2500 },
59
59
  ],
60
60
  });
@@ -104,34 +104,63 @@ const client = new DodoPayments({
104
104
  webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
105
105
  });
106
106
 
107
+ type WebhookEvent = ReturnType<typeof client.webhooks.unwrap>;
108
+
109
+ // Register ONE endpoint for every Dodo event. A second app.post('/webhook')
110
+ // would never run, because the first handler answers every request.
107
111
  app.post('/webhook', async (req, res) => {
112
+ let event: WebhookEvent;
108
113
  try {
109
- const event = client.webhooks.unwrap(req.body.toString(), {
114
+ event = client.webhooks.unwrap(req.body.toString(), {
110
115
  headers: {
111
116
  'webhook-id': req.headers['webhook-id'] as string,
112
117
  'webhook-signature': req.headers['webhook-signature'] as string,
113
118
  'webhook-timestamp': req.headers['webhook-timestamp'] as string,
114
119
  },
115
120
  });
121
+ } catch {
122
+ // Only signature verification failures reach this branch
123
+ res.status(401).json({ error: 'Invalid signature' });
124
+ return;
125
+ }
116
126
 
117
- if (event.type === 'refund.succeeded') {
118
- const refund = event.data;
119
- // Refund succeeded; revoke access if needed
120
- await revokeCustomerAccess(refund.customer.customer_id);
121
- await updateRefundRecord(refund.refund_id, 'succeeded');
122
- }
123
-
124
- if (event.type === 'refund.failed') {
125
- const refund = event.data;
126
- // Refund failed; keep access active, alert support
127
- await logRefundFailure(refund.refund_id, refund.reason);
128
- }
129
-
127
+ try {
128
+ await handleRefundEvent(event);
129
+ await handleDisputeEvent(event); // defined under "Handle dispute webhooks"
130
130
  res.json({ received: true });
131
131
  } catch (error) {
132
- res.status(401).json({ error: 'Invalid signature' });
132
+ // Any non-2xx response makes Dodo retry the delivery
133
+ console.error('Webhook handler failed:', error);
134
+ res.status(500).json({ error: 'Webhook handler failed' });
133
135
  }
134
136
  });
137
+
138
+ async function handleRefundEvent(event: WebhookEvent) {
139
+ if (event.type === 'refund.succeeded') {
140
+ const refund = event.data;
141
+ if (!refund.is_partial) {
142
+ // Full refund: nothing in the payment is still paid for
143
+ await revokeCustomerAccess(refund.customer.customer_id);
144
+ } else {
145
+ // Partial refund: keep access to items that are still paid for, and
146
+ // remove it for any item with nothing left to refund. Reading the line
147
+ // items covers every case - an omitted amount, an explicit amount equal
148
+ // to the item price, or several partial refunds that add up to it.
149
+ const { items } = await client.payments.retrieveLineItems(refund.payment_id);
150
+ const fullyRefundedItemIds = items
151
+ .filter((item) => item.refundable_amount === 0)
152
+ .map((item) => item.items_id);
153
+ await revokeItemAccess(refund.customer.customer_id, fullyRefundedItemIds);
154
+ }
155
+ await updateRefundRecord(refund.refund_id, 'succeeded');
156
+ }
157
+
158
+ if (event.type === 'refund.failed') {
159
+ const refund = event.data;
160
+ // Refund failed; keep access active, alert support
161
+ await logRefundFailure(refund.refund_id, refund.reason);
162
+ }
163
+ }
135
164
  ```
136
165
 
137
166
  ## Disputes
@@ -159,7 +188,7 @@ A dispute moves through seven events. Each event requires a different action fro
159
188
  | `dispute.opened` | Customer initiated a chargeback | Record the dispute; consider revoking access immediately; gather evidence from your logs |
160
189
  | `dispute.challenged` | You submitted evidence | Wait for the card network to review |
161
190
  | `dispute.accepted` | You accepted (conceded) the dispute; funds go to the cardholder | Keep access revoked; mark the dispute as accepted in your records |
162
- | `dispute.cancelled` | Customer or system cancelled the dispute | Keep access revoked; reconcile the payment separately |
191
+ | `dispute.cancelled` | The dispute was withdrawn or cancelled | No further action needed; restore access you revoked on `dispute.opened` |
163
192
  | `dispute.expired` | Dispute window closed without resolution | Treat as lost; keep access revoked |
164
193
  | `dispute.won` | You won the dispute | Funds are retained; restore access; update customer records |
165
194
  | `dispute.lost` | You lost the dispute | Funds returned to cardholder; keep access revoked; reconcile your records |
@@ -174,66 +203,56 @@ async function resolveDisputeCustomerId(disputeId: string) {
174
203
  return dispute.customer.customer_id;
175
204
  }
176
205
 
177
- app.post('/webhook', async (req, res) => {
178
- try {
179
- const event = client.webhooks.unwrap(req.body.toString(), {
180
- headers: {
181
- 'webhook-id': req.headers['webhook-id'] as string,
182
- 'webhook-signature': req.headers['webhook-signature'] as string,
183
- 'webhook-timestamp': req.headers['webhook-timestamp'] as string,
184
- },
185
- });
186
-
187
- if (event.type === 'dispute.opened') {
188
- const dispute = event.data;
189
- // Record the dispute and revoke access
190
- await recordDispute(dispute.dispute_id, dispute.payment_id, dispute.amount);
191
- const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
192
- await revokeCustomerAccess(customerId);
193
- // Gather evidence from your system and submit via dashboard
194
- // (no evidence-submission API exists; use the Dodo dashboard)
195
- }
196
-
197
- if (event.type === 'dispute.won') {
198
- const dispute = event.data;
199
- // Funds retained; restore normal state
200
- await markDisputeResolved(dispute.dispute_id, 'won');
201
- const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
202
- await restoreCustomerAccess(customerId);
203
- }
206
+ // Called from the single /webhook handler above, after signature verification
207
+ async function handleDisputeEvent(event: WebhookEvent) {
208
+ if (event.type === 'dispute.opened') {
209
+ const dispute = event.data;
210
+ // Record the dispute and revoke access
211
+ await recordDispute(dispute.dispute_id, dispute.payment_id, dispute.amount);
212
+ const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
213
+ await revokeCustomerAccess(customerId);
214
+ // Gather evidence from your system and submit via dashboard
215
+ // (no evidence-submission API exists; use the Dodo dashboard)
216
+ }
204
217
 
205
- if (event.type === 'dispute.lost') {
206
- const dispute = event.data;
207
- // Funds returned to cardholder; keep access revoked
208
- await markDisputeResolved(dispute.dispute_id, 'lost');
209
- // Do NOT restore access
210
- }
218
+ if (event.type === 'dispute.won') {
219
+ const dispute = event.data;
220
+ // Funds retained; restore normal state
221
+ await markDisputeResolved(dispute.dispute_id, 'won');
222
+ const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
223
+ await restoreCustomerAccess(customerId);
224
+ }
211
225
 
212
- if (event.type === 'dispute.accepted') {
213
- const dispute = event.data;
214
- // Merchant conceded; funds go to the cardholder and access stays revoked
215
- await markDisputeResolved(dispute.dispute_id, 'accepted');
216
- // Do NOT restore access
217
- }
226
+ if (event.type === 'dispute.lost') {
227
+ const dispute = event.data;
228
+ // Funds returned to cardholder; keep access revoked
229
+ await markDisputeResolved(dispute.dispute_id, 'lost');
230
+ // Do NOT restore access
231
+ }
218
232
 
219
- if (event.type === 'dispute.cancelled') {
220
- const dispute = event.data;
221
- // Cancellation is not a win; keep access revoked and reconcile separately
222
- await markDisputeResolved(dispute.dispute_id, 'cancelled');
223
- }
233
+ if (event.type === 'dispute.accepted') {
234
+ const dispute = event.data;
235
+ // Merchant conceded; funds go to the cardholder and access stays revoked
236
+ await markDisputeResolved(dispute.dispute_id, 'accepted');
237
+ // Do NOT restore access
238
+ }
224
239
 
225
- res.json({ received: true });
226
- } catch (error) {
227
- res.status(401).json({ error: 'Invalid signature' });
240
+ if (event.type === 'dispute.cancelled') {
241
+ const dispute = event.data;
242
+ // The dispute was withdrawn; no further action is needed, so undo the
243
+ // revocation from dispute.opened
244
+ await markDisputeResolved(dispute.dispute_id, 'cancelled');
245
+ const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
246
+ await restoreCustomerAccess(customerId);
228
247
  }
229
- });
248
+ }
230
249
  ```
231
250
 
232
251
  ### Evidence submission
233
252
 
234
253
  Dodo handles the card-network dispute process as Merchant of Record. You submit evidence through the Dodo dashboard, not via API. No evidence-submission API exists.
235
254
 
236
- When a dispute opens, gather your evidence (order confirmation, delivery proof, customer communication, etc.) and upload it to the dashboard within the dispute window (typically 4 days). The card network reviews your evidence and makes a final decision.
255
+ When a dispute opens, gather your evidence (order confirmation, delivery proof, customer communication, etc.) and upload it to the dashboard within the dispute window (10 days from when the dispute is created). The card network reviews your evidence and makes a final decision.
237
256
 
238
257
  ## Access revocation pattern
239
258
 
@@ -266,7 +285,8 @@ async function handleDisputeLifecycle(
266
285
  break;
267
286
 
268
287
  case 'dispute_cancelled':
269
- // Cancellation is not a win; reconcile separately and keep access revoked
288
+ // Withdrawn; no further action needed, so restore access
289
+ await restoreCustomerAccess(customerId);
270
290
  break;
271
291
 
272
292
  case 'dispute_expired':
@@ -319,9 +339,9 @@ async function reconcileRefund(refund) {
319
339
 
320
340
  **Ignoring partial refunds when computing entitlements.** If a customer refunds only one item from a multi-item purchase, their entitlement to the other items remains valid. Track refunds by item, not just by payment.
321
341
 
322
- **Restoring access on any outcome except `dispute.won`.** `dispute.accepted` means you conceded and the cardholder receives the funds. A cancelled dispute is also not a win. Keep access revoked and reconcile separately unless you receive `dispute.won`.
342
+ **Restoring access on a losing outcome.** `dispute.accepted`, `dispute.lost`, and `dispute.expired` resolve (or typically resolve) in the cardholder's favor; keep access revoked. Restore access only on `dispute.won` or `dispute.cancelled` (the dispute was withdrawn and needs no further action).
323
343
 
324
- **Submitting evidence after the dispute window closes.** The card network typically gives you 4 days to respond. Set a calendar reminder and gather evidence immediately when a dispute opens.
344
+ **Submitting evidence after the dispute window closes.** You have 10 days from when the dispute is created to respond. Set a calendar reminder and gather evidence immediately when a dispute opens.
325
345
 
326
346
  **Assuming Dodo will handle access revocation.** Dodo handles the card-network process; you handle application access. Dodo won't revoke your customer's subscription or file access automatically.
327
347
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: subscription-integration
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.
3
+ description: Dodo Payments subscription lifecycle after checkout, covering trials, statuses like active, on_hold, and cancelled, plan upgrades and downgrades, proration, cancellation, failed-payment recovery, mandates, and on-demand charges. Use when managing recurring billing, plan changes, dunning, or off-session charging; use checkout-integration to start the purchase.
4
4
  ---
5
5
 
6
6
  # Dodo Payments Subscription Integration
@@ -19,7 +19,7 @@ Implement recurring billing with trials, plan changes, and on-demand charging. S
19
19
 
20
20
  ## Core Concepts
21
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.
22
+ **Subscription lifecycle:** The eight subscription statuses are `pending`, `active`, `past_due`, `on_hold`, `paused`, `cancelled`, `failed`, and `expired`. A trialing subscription reports `active`; `subscription.renewed` is an event, not a status. A failed renewal moves a subscription to `past_due` (only if you configured a grace period; access is kept) or `on_hold` (access revoked); both are recoverable. `paused` is a deliberate freeze by you or the customer. `failed` is terminal and only happens when **initial creation** fails. Cancellation sets it to `cancelled` or schedules it to become `expired` at period end.
23
23
 
24
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
25
 
@@ -89,12 +89,16 @@ const session = await client.checkoutSessions.create({
89
89
  | Status | Meaning | Transitions |
90
90
  |--------|---------|-----------|
91
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` |
92
+ | `active` | Actively renewing | → `past_due`, `on_hold`, `paused`, `cancelled`, `expired` |
93
+ | `past_due` | Renewal failed and the grace period is open; customer **keeps** access until `past_due_ends_at` | → `active` (debt settled), `on_hold` or `cancelled` (grace period ends, per your setting) |
94
+ | `on_hold` | Renewal/plan-change payment failed; renewals stopped, access revoked; recoverable | → `active` (payment method updated / retry succeeds), `cancelled` |
95
+ | `paused` | Deliberately paused by you or the customer; billing frozen, access revoked | → `active` (resumed), `cancelled` |
94
96
  | `cancelled` | Will not renew | → `expired` (at period end) |
95
- | `failed` | Initial mandate/payment failed; terminal | (no recovery) |
97
+ | `failed` | Initial mandate/payment failed at creation; terminal. Never reached from `on_hold` or exhausted retries | (no recovery) |
96
98
  | `expired` | Subscription term ended | (terminal) |
97
99
 
100
+ `past_due` only occurs if you enable a grace period; without one, a failed renewal goes straight to `on_hold`.
101
+
98
102
  **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.
99
103
 
100
104
  ---
@@ -108,6 +112,20 @@ const subscription = await client.subscriptions.retrieve('sub_xxxxx');
108
112
  console.log(subscription.status, subscription.next_billing_date);
109
113
  ```
110
114
 
115
+ ### Pause and Resume
116
+
117
+ Pause and resume run through `status` on the update endpoint. Send it on its own: combining `paused`/`active` with any other field is rejected with `422`.
118
+
119
+ ```typescript
120
+ // Pause: renewals stop, access is revoked, next_billing_date shifts by the pause length
121
+ await client.subscriptions.update('sub_xxxxx', { status: 'paused' });
122
+
123
+ // Resume
124
+ await client.subscriptions.update('sub_xxxxx', { status: 'active' });
125
+ ```
126
+
127
+ Pausing emits `subscription.paused`; resuming emits `subscription.unpaused`. Customer self-service pause in the portal is opt-in (**Settings → Subscriptions → Allow Subscription Pause**).
128
+
111
129
  ### Update Payment Method
112
130
 
113
131
  When a subscription is `on_hold` due to failed payment, the customer can update their payment method. This automatically charges any outstanding dues.
@@ -162,7 +180,7 @@ await client.subscriptions.changePlan('sub_xxxxx', {
162
180
 
163
181
  | Mode | Upgrade | Downgrade | Billing date |
164
182
  |------|---------|-----------|--------------|
165
- | `prorated_immediately` | Time-prorated charge | Time-prorated credit | Resets to change date |
183
+ | `prorated_immediately` | Credit for unused time on the old plan, then charge a **full** new cycle (net = full new cycle − unused credit) | Same formula; a credit only remains if the unused credit exceeds the new plan price | Resets to change date |
166
184
  | `difference_immediately` | Full new-plan charge | Difference becomes credit | Resets |
167
185
  | `full_immediately` | Full new-plan charge | Full new-plan charge, no credit | Resets |
168
186
  | `do_not_bill` | No charge | No credit | Preserved |
@@ -172,6 +190,19 @@ await client.subscriptions.changePlan('sub_xxxxx', {
172
190
  - `prevent_change`: Keep the old plan if the charge fails.
173
191
  - `apply_change`: Apply the new plan even if payment fails (subscription may become `on_hold`).
174
192
 
193
+ **Scheduling:** `effective_at` defaults to `'immediately'`. Pass `effective_at: 'next_billing_date'` to schedule the change (typical for downgrades) - nothing is charged now, the customer keeps the current plan until the period ends, and the pending change appears on the subscription as `scheduled_change` (cancel it with `cancelChangePlan`).
194
+
195
+ ```typescript
196
+ await client.subscriptions.changePlan('sub_xxxxx', {
197
+ product_id: 'pdt_lower_tier',
198
+ quantity: 1,
199
+ proration_billing_mode: 'prorated_immediately',
200
+ effective_at: 'next_billing_date',
201
+ });
202
+ ```
203
+
204
+ **Plan changes are rejected while the subscription is `past_due`.** Settle the renewal debt first.
205
+
175
206
  ### Preview Plan Change
176
207
 
177
208
  Show the customer a quote before committing:
@@ -316,64 +347,11 @@ For full customer management (creating, updating, listing), see the `customer-ma
316
347
 
317
348
  ## Webhook Events
318
349
 
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.
350
+ Full guide: [references/webhook-events.md](references/webhook-events.md).
330
351
 
331
- ### Example Handler
352
+ Covers:
332
353
 
333
- ```typescript
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 });
344
- }
345
-
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
- }
352
-
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;
370
- }
371
-
372
- return NextResponse.json({ received: true });
373
- }
374
- ```
375
-
376
- ---
354
+ - Example Handler
377
355
 
378
356
  ## Credit Entitlements
379
357
 
@@ -459,9 +437,9 @@ await client.subscriptions.changePlan('sub_xxxxx', {
459
437
 
460
438
  This will fail. Always specify a proration mode.
461
439
 
462
- ### 5. Confusing `on_hold` with Pause
440
+ ### 5. Confusing `on_hold` with `paused`
463
441
 
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.
442
+ `on_hold` is involuntary: a payment failed, and it is recovered by updating the payment method or a successful retry. `paused` is deliberate: you or the customer froze the subscription with `subscriptions.update(id, { status: 'paused' })`, and it is resumed with `{ status: 'active' }`. Handle `subscription.paused`/`subscription.unpaused` separately from `subscription.on_hold`.
465
443
 
466
444
  ### 6. Not Handling `cancel_at_next_billing_date`
467
445
 
@@ -481,6 +459,7 @@ if (data.cancel_at_next_billing_date) {
481
459
 
482
460
  ## Resources
483
461
 
462
+ - [Subscriptions (states, pause, grace period)](https://docs.dodopayments.com/features/subscription)
484
463
  - [Subscription Integration Guide](https://docs.dodopayments.com/developer-resources/subscription-integration-guide)
485
464
  - [Upgrade & Downgrade Guide](https://docs.dodopayments.com/developer-resources/subscription-upgrade-downgrade)
486
465
  - [On-Demand Subscriptions](https://docs.dodopayments.com/developer-resources/ondemand-subscriptions)