@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.
- package/README.md +1 -0
- package/opencode-plugin/README.md +1 -1
- package/package.json +1 -1
- package/skills/better-auth-integration/SKILL.md +12 -3
- package/skills/billing-sdk/SKILL.md +15 -167
- package/skills/billing-sdk/references/pricing-page.md +197 -0
- package/skills/checkout-integration/SKILL.md +31 -460
- package/skills/checkout-integration/references/overlay-checkout.md +148 -0
- package/skills/checkout-integration/references/server-examples.md +317 -0
- package/skills/credit-based-billing/SKILL.md +11 -158
- package/skills/credit-based-billing/references/ai-token-example.md +108 -0
- package/skills/credit-based-billing/references/credit-webhooks.md +51 -0
- package/skills/customer-management/SKILL.md +25 -17
- package/skills/discounts-and-promotions/SKILL.md +20 -17
- package/skills/dodo-best-practices/SKILL.md +18 -5
- package/skills/dodo-mcp-usage/SKILL.md +152 -0
- package/skills/framework-adapters/SKILL.md +64 -421
- package/skills/framework-adapters/references/astro.md +52 -0
- package/skills/framework-adapters/references/bun.md +45 -0
- package/skills/framework-adapters/references/convex.md +91 -0
- package/skills/framework-adapters/references/express.md +72 -0
- package/skills/framework-adapters/references/fastify.md +54 -0
- package/skills/framework-adapters/references/hono.md +63 -0
- package/skills/framework-adapters/references/nuxt.md +53 -0
- package/skills/framework-adapters/references/remix.md +65 -0
- package/skills/framework-adapters/references/sveltekit.md +49 -0
- package/skills/framework-adapters/references/tanstack-start.md +31 -0
- package/skills/license-keys/SKILL.md +117 -367
- package/skills/license-keys/references/cli-tool.md +143 -0
- package/skills/license-keys/references/desktop-app.md +170 -0
- package/skills/localized-pricing/SKILL.md +51 -29
- package/skills/mobile-checkout/SKILL.md +72 -310
- package/skills/mobile-checkout/references/android.md +69 -0
- package/skills/mobile-checkout/references/flutter.md +97 -0
- package/skills/mobile-checkout/references/ios.md +66 -0
- package/skills/mobile-checkout/references/react-native.md +100 -0
- package/skills/product-catalog-management/SKILL.md +39 -16
- package/skills/refunds-and-disputes/SKILL.md +91 -71
- package/skills/subscription-integration/SKILL.md +43 -64
- package/skills/subscription-integration/references/webhook-events.md +82 -0
- package/skills/testing-and-go-live/SKILL.md +22 -15
- package/skills/usage-based-billing/SKILL.md +16 -122
- package/skills/usage-based-billing/references/instrumentation.md +119 -0
- package/skills/webhook-integration/SKILL.md +31 -263
- package/skills/webhook-integration/references/common-mistakes.md +146 -0
- package/skills/webhook-integration/references/event-catalog.md +103 -0
- 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:
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
|
254
|
-
const
|
|
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:**
|
|
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:**
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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` |
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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
|
|
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.**
|
|
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:
|
|
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
|
|
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
|
-
| `
|
|
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` |
|
|
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
|
-
|
|
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
|
-
|
|
352
|
+
Covers:
|
|
332
353
|
|
|
333
|
-
|
|
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
|
|
440
|
+
### 5. Confusing `on_hold` with `paused`
|
|
463
441
|
|
|
464
|
-
`on_hold`
|
|
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)
|