@dodopayments/opencode-plugin 0.3.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mcp.json +1 -1
- package/README.md +152 -42
- package/opencode-plugin/README.md +20 -6
- package/opencode-plugin/index.js +27 -1
- package/package.json +5 -5
- package/skills/better-auth-integration/SKILL.md +214 -0
- package/skills/billing-sdk/SKILL.md +357 -370
- package/skills/checkout-integration/SKILL.md +580 -329
- package/skills/credit-based-billing/SKILL.md +448 -775
- package/skills/customer-management/SKILL.md +281 -0
- package/skills/discounts-and-promotions/SKILL.md +464 -0
- package/skills/dodo-best-practices/SKILL.md +471 -0
- package/skills/framework-adapters/SKILL.md +644 -0
- package/skills/license-keys/SKILL.md +324 -199
- package/skills/localized-pricing/SKILL.md +230 -0
- package/skills/mobile-checkout/SKILL.md +521 -0
- package/skills/product-catalog-management/SKILL.md +311 -0
- package/skills/refunds-and-disputes/SKILL.md +333 -0
- package/skills/subscription-integration/SKILL.md +323 -404
- package/skills/testing-and-go-live/SKILL.md +335 -0
- package/skills/usage-based-billing/SKILL.md +339 -479
- package/skills/webhook-integration/SKILL.md +600 -402
- package/skills/best-practices/SKILL.md +0 -315
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: product-catalog-management
|
|
3
|
+
description: Guide for creating and managing products, pricing, add-ons, product collections, images, and digital product delivery
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Product Catalog Management
|
|
7
|
+
|
|
8
|
+
This skill covers the full product lifecycle: creating products with pricing models, managing add-ons and collections, uploading product images, and delivering digital files to customers.
|
|
9
|
+
|
|
10
|
+
## When to use this skill
|
|
11
|
+
|
|
12
|
+
- You need to create or update products with one-time, recurring, or usage-based pricing.
|
|
13
|
+
- You're building a product collection or storefront.
|
|
14
|
+
- You need to upload product images or deliver digital files to customers.
|
|
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.
|
|
17
|
+
|
|
18
|
+
## Core concepts
|
|
19
|
+
|
|
20
|
+
**Product model:** Every product has exactly one pricing model selected at creation. The three models are:
|
|
21
|
+
|
|
22
|
+
- `one_time_price`: charge once per purchase.
|
|
23
|
+
- `recurring_price`: charge on a configurable payment frequency and subscription period.
|
|
24
|
+
- `usage_based_price`: charge per unit consumed (see `usage-based-billing` skill for meter setup).
|
|
25
|
+
|
|
26
|
+
Pricing is nested inside the product object. There is no separate top-level Price resource.
|
|
27
|
+
|
|
28
|
+
**Pricing structure:** Each price object contains:
|
|
29
|
+
|
|
30
|
+
- `type`: one of the three models above.
|
|
31
|
+
- `currency`: ISO 4217 code (e.g., `USD`, `AED`, `INR`).
|
|
32
|
+
- `price`: amount in the smallest currency unit (cents for USD).
|
|
33
|
+
- `discount`: optional discount amount in the same unit.
|
|
34
|
+
|
|
35
|
+
**Tax category:** Required at product creation. Dodo uses this to calculate and collect sales tax. Supported values are `digital_products`, `saas`, `e_book`, and `edtech`.
|
|
36
|
+
|
|
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
|
+
|
|
39
|
+
**Images:** Presigned upload URLs expire after 60 seconds. Download the URL immediately after requesting it.
|
|
40
|
+
|
|
41
|
+
**Digital delivery:** Entitlements grant customers access to files. Download URLs expire after roughly 15 minutes.
|
|
42
|
+
|
|
43
|
+
## Creating a product
|
|
44
|
+
|
|
45
|
+
All products require a name, a pricing model, and a tax category.
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
import DodoPayments from 'dodopayments';
|
|
49
|
+
|
|
50
|
+
const client = new DodoPayments({
|
|
51
|
+
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
|
|
52
|
+
environment: 'test_mode',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// One-time purchase
|
|
56
|
+
const product = await client.products.create({
|
|
57
|
+
name: 'Pro Bundle',
|
|
58
|
+
tax_category: 'digital_products',
|
|
59
|
+
price: {
|
|
60
|
+
type: 'one_time_price',
|
|
61
|
+
currency: 'USD',
|
|
62
|
+
price: 9900, // $99.00
|
|
63
|
+
discount: 0,
|
|
64
|
+
purchasing_power_parity: false,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
console.log(product.product_id); // pdt_...
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
For recurring products, specify the billing cycle:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
const subscription = await client.products.create({
|
|
75
|
+
name: 'Premium Plan',
|
|
76
|
+
tax_category: 'saas',
|
|
77
|
+
price: {
|
|
78
|
+
type: 'recurring_price',
|
|
79
|
+
currency: 'USD',
|
|
80
|
+
price: 2999, // $29.99/month
|
|
81
|
+
discount: 0,
|
|
82
|
+
payment_frequency_count: 1,
|
|
83
|
+
payment_frequency_interval: 'Month',
|
|
84
|
+
subscription_period_count: 1,
|
|
85
|
+
subscription_period_interval: 'Month',
|
|
86
|
+
purchasing_power_parity: false,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
This skill is the canonical source for product creation request shapes. For a usage-based product, create the meter as described in the `usage-based-billing` skill, then attach it through the singular `price.meters` array:
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
const metered = await client.products.create({
|
|
95
|
+
name: 'API Calls',
|
|
96
|
+
tax_category: 'saas',
|
|
97
|
+
price: {
|
|
98
|
+
type: 'usage_based_price',
|
|
99
|
+
currency: 'USD',
|
|
100
|
+
discount: 0,
|
|
101
|
+
fixed_price: 0, // No fixed monthly charge; amounts use the smallest currency unit
|
|
102
|
+
payment_frequency_count: 1,
|
|
103
|
+
payment_frequency_interval: 'Month',
|
|
104
|
+
subscription_period_count: 1,
|
|
105
|
+
subscription_period_interval: 'Month',
|
|
106
|
+
purchasing_power_parity: false,
|
|
107
|
+
meters: [
|
|
108
|
+
{
|
|
109
|
+
meter_id: 'mtr_api_calls',
|
|
110
|
+
price_per_unit: '0.01',
|
|
111
|
+
free_threshold: 1000,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`price_per_unit` is a decimal string in the configured currency's smallest unit. The optional `free_threshold` excludes that many aggregated units before per-unit charging begins.
|
|
119
|
+
|
|
120
|
+
## Listing and retrieving products
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// List all products
|
|
124
|
+
const products = await client.products.list();
|
|
125
|
+
|
|
126
|
+
// Retrieve a single product
|
|
127
|
+
const product = await client.products.retrieve('pdt_pro_bundle');
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Updating a product
|
|
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.
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
await client.products.update('pdt_pro_bundle', {
|
|
136
|
+
name: 'Pro Bundle (Updated)',
|
|
137
|
+
description: 'Includes all pro features',
|
|
138
|
+
metadata: {
|
|
139
|
+
category: 'software',
|
|
140
|
+
tier: 'premium',
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Archiving and unarchiving
|
|
146
|
+
|
|
147
|
+
Archive a product to hide it from new checkouts without deleting it:
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
await client.products.archive('pdt_pro_bundle');
|
|
151
|
+
|
|
152
|
+
// Restore it later
|
|
153
|
+
await client.products.unarchive('pdt_pro_bundle');
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Product images
|
|
157
|
+
|
|
158
|
+
Upload a product image via presigned URL. The URL is valid for 60 seconds.
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import fs from 'node:fs';
|
|
162
|
+
|
|
163
|
+
// Request a presigned upload URL
|
|
164
|
+
const uploadUrl = await client.products.images.update('pdt_pro_bundle', {
|
|
165
|
+
force_update: true,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Upload immediately (within 60 seconds)
|
|
169
|
+
const response = await fetch(uploadUrl.url, {
|
|
170
|
+
method: 'PUT',
|
|
171
|
+
body: fs.readFileSync('./product.png'),
|
|
172
|
+
headers: { 'Content-Type': 'image/png' },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
throw new Error('Image upload failed');
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Add-ons
|
|
181
|
+
|
|
182
|
+
Add-ons are optional extras customers can purchase alongside a product. Create them independently, then reference them in checkout.
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
// Create an add-on
|
|
186
|
+
const addon = await client.addons.create({
|
|
187
|
+
name: 'Priority Support',
|
|
188
|
+
tax_category: 'saas',
|
|
189
|
+
currency: 'USD',
|
|
190
|
+
price: 4900, // $49.00
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Upload an image for the add-on
|
|
194
|
+
const addonImageUrl = await client.addons.updateImages(addon.id);
|
|
195
|
+
|
|
196
|
+
// List add-ons
|
|
197
|
+
const addons = await client.addons.list();
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Product collections
|
|
201
|
+
|
|
202
|
+
Group products into collections for themed checkouts. Collections have nested groups and items.
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
// Create a collection
|
|
206
|
+
const collection = await client.productCollections.create({
|
|
207
|
+
name: 'Starter Bundle',
|
|
208
|
+
description: 'Everything you need to get started',
|
|
209
|
+
groups: [
|
|
210
|
+
{
|
|
211
|
+
group_name: 'Core Tools',
|
|
212
|
+
products: [
|
|
213
|
+
{ product_id: 'pdt_tool_a' },
|
|
214
|
+
{ product_id: 'pdt_tool_b' },
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Add another group later
|
|
221
|
+
await client.productCollections.groups.create(collection.id, {
|
|
222
|
+
group_name: 'Optional Tools',
|
|
223
|
+
products: [
|
|
224
|
+
{ product_id: 'pdt_tool_c' },
|
|
225
|
+
{ product_id: 'pdt_tool_d' },
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
|
|
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
|
+
});
|
|
235
|
+
|
|
236
|
+
window.location.href = session.checkout_url;
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Digital product delivery
|
|
240
|
+
|
|
241
|
+
Entitlements grant customers access to downloadable files. Create an entitlement, upload files, and generate download links.
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
// Create an entitlement
|
|
245
|
+
const entitlement = await client.entitlements.create({
|
|
246
|
+
name: 'Pro Bundle Files',
|
|
247
|
+
integration_type: 'digital_files',
|
|
248
|
+
integration_config: {
|
|
249
|
+
digital_file_ids: [],
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Upload and attach a file; the response identifies the attached file
|
|
254
|
+
const { file_id } = await client.entitlements.files.upload(entitlement.id);
|
|
255
|
+
|
|
256
|
+
// Attach the entitlement to a product
|
|
257
|
+
await client.products.update('pdt_pro_bundle', {
|
|
258
|
+
entitlements: [{ entitlement_id: entitlement.id }],
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// A grant is produced by fulfillment when the customer purchases the product.
|
|
262
|
+
// Grants can be listed, revoked, or fulfilled with a manually managed license key;
|
|
263
|
+
// they cannot be created directly through the SDK.
|
|
264
|
+
|
|
265
|
+
// List grants for a customer
|
|
266
|
+
const grants = await client.customers.listEntitlementGrants('cus_customer_123');
|
|
267
|
+
|
|
268
|
+
// Generate a download URL (valid for roughly 15 minutes)
|
|
269
|
+
const grant = grants.items[0];
|
|
270
|
+
const downloadUrl = grant?.digital_product_delivery?.files[0]?.download_url;
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Short links
|
|
274
|
+
|
|
275
|
+
Create short, shareable links to products:
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
const shortLink = await client.products.shortLinks.create('pdt_pro_bundle', {
|
|
279
|
+
slug: 'pro-bundle-deal',
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
console.log(shortLink.short_url); // https://dodo.link/pro-bundle-deal
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Localized pricing
|
|
286
|
+
|
|
287
|
+
For country or currency-specific pricing, use the `localized-pricing` skill. Do not hardcode prices in the client.
|
|
288
|
+
|
|
289
|
+
## Common mistakes
|
|
290
|
+
|
|
291
|
+
**Expecting a delete endpoint:** Products don't have a delete method. Archive them instead. Archived products remain in your history for reconciliation.
|
|
292
|
+
|
|
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.
|
|
294
|
+
|
|
295
|
+
**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
|
+
|
|
297
|
+
**Forgetting the tax category:** Every product requires a `tax_category`. Dodo uses this to calculate and remit sales tax. Omitting it will cause the create request to fail.
|
|
298
|
+
|
|
299
|
+
**Uploading images after the 60-second window:** Request the presigned URL and upload immediately. If the URL expires, request a new one.
|
|
300
|
+
|
|
301
|
+
**Passing a non-empty product_cart for collection checkout:** Collection checkout requires `product_cart: []`. The collection itself defines what's in the cart.
|
|
302
|
+
|
|
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.
|
|
304
|
+
|
|
305
|
+
## Resources
|
|
306
|
+
|
|
307
|
+
- [Products](https://docs.dodopayments.com/features/products)
|
|
308
|
+
- [Create product API](https://docs.dodopayments.com/api-reference/products/post-products)
|
|
309
|
+
- [Product Collections](https://docs.dodopayments.com/features/product-collections)
|
|
310
|
+
- [Digital Product Delivery](https://docs.dodopayments.com/features/digital-product-delivery)
|
|
311
|
+
- [Add-ons](https://docs.dodopayments.com/features/addons)
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: refunds-and-disputes
|
|
3
|
+
description: Guide for issuing refunds, handling disputes and chargebacks, and reconciling customer access with Dodo Payments
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Refunds and Disputes
|
|
7
|
+
|
|
8
|
+
This skill covers issuing refunds (full and partial), handling the dispute lifecycle, and managing customer access during payment reversals. Disputes are inbound-only; Dodo handles the card-network process as Merchant of Record while you control application access and evidence gathering.
|
|
9
|
+
|
|
10
|
+
## When to use this skill
|
|
11
|
+
|
|
12
|
+
- You need to issue a refund (full or partial) for a payment
|
|
13
|
+
- A customer disputes a charge and you need to respond with evidence
|
|
14
|
+
- You're building access-revocation logic tied to refund or dispute status
|
|
15
|
+
- You need to reconcile customer entitlements after a payment reversal
|
|
16
|
+
- You're handling refund or dispute webhooks in production
|
|
17
|
+
|
|
18
|
+
## Core concepts
|
|
19
|
+
|
|
20
|
+
**Refunds** are initiated by you via the API. Each refund has a status that tells you whether the money has actually left your account. Partial refunds target specific line items in the original payment.
|
|
21
|
+
|
|
22
|
+
**Disputes** are initiated by the customer's card network. You cannot create them; you only list and retrieve them. The dispute lifecycle spans seven events, each requiring different application actions.
|
|
23
|
+
|
|
24
|
+
**Amounts** are always in the smallest currency unit (cents for USD, paise for INR, etc.).
|
|
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.
|
|
27
|
+
|
|
28
|
+
## Refunds
|
|
29
|
+
|
|
30
|
+
### Create a refund
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import DodoPayments from 'dodopayments';
|
|
34
|
+
|
|
35
|
+
const client = new DodoPayments({
|
|
36
|
+
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
|
|
37
|
+
environment: 'test_mode',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Full refund
|
|
41
|
+
const refund = await client.refunds.create({
|
|
42
|
+
payment_id: 'pay_abc123',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
console.log(refund.refund_id);
|
|
46
|
+
console.log(refund.status); // 'pending', 'succeeded', 'review', or 'failed'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Partial refund by item
|
|
50
|
+
|
|
51
|
+
To refund only specific items from a payment, pass the `items` array with each item ID and the amount to refund in the smallest currency unit. Omit `amount` to refund the whole item:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
const partialRefund = await client.refunds.create({
|
|
55
|
+
payment_id: 'pay_abc123',
|
|
56
|
+
items: [
|
|
57
|
+
{ item_id: 'item_1', amount: 1000 },
|
|
58
|
+
{ item_id: 'item_2', amount: 2500 },
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Refund statuses
|
|
64
|
+
|
|
65
|
+
Each refund has one of four statuses:
|
|
66
|
+
|
|
67
|
+
| Status | Meaning | Your action |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `pending` | Refund is processing; money hasn't left your account yet | Wait for a webhook or poll the refund status |
|
|
70
|
+
| `succeeded` | Money has been returned to the customer | Revoke access if the product is non-refundable; update your records |
|
|
71
|
+
| `review` | Refund is under review (rare) | Contact support; do not assume it will succeed |
|
|
72
|
+
| `failed` | Refund failed; money remains in your account | Investigate the failure; consider retrying or contacting the customer |
|
|
73
|
+
|
|
74
|
+
### List and retrieve refunds
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// List all refunds
|
|
78
|
+
const refunds = await client.refunds.list();
|
|
79
|
+
|
|
80
|
+
// Retrieve a specific refund
|
|
81
|
+
const refund = await client.refunds.retrieve('ref_xyz789');
|
|
82
|
+
console.log(refund.status);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Handle refund webhooks
|
|
86
|
+
|
|
87
|
+
Webhook signature verification is covered in the `webhook-integration` skill. Always verify the signature before processing.
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
import DodoPayments from 'dodopayments';
|
|
91
|
+
import express from 'express';
|
|
92
|
+
|
|
93
|
+
const app = express();
|
|
94
|
+
app.use(express.raw({ type: 'application/json' }));
|
|
95
|
+
|
|
96
|
+
// `environment` is a narrow union, but env vars are `string | undefined`.
|
|
97
|
+
// Narrow explicitly rather than casting, and default to test mode so a missing
|
|
98
|
+
// variable can never accidentally hit live.
|
|
99
|
+
const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode';
|
|
100
|
+
|
|
101
|
+
const client = new DodoPayments({
|
|
102
|
+
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
|
|
103
|
+
environment,
|
|
104
|
+
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
app.post('/webhook', async (req, res) => {
|
|
108
|
+
try {
|
|
109
|
+
const event = client.webhooks.unwrap(req.body.toString(), {
|
|
110
|
+
headers: {
|
|
111
|
+
'webhook-id': req.headers['webhook-id'] as string,
|
|
112
|
+
'webhook-signature': req.headers['webhook-signature'] as string,
|
|
113
|
+
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
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
|
+
|
|
130
|
+
res.json({ received: true });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
res.status(401).json({ error: 'Invalid signature' });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Disputes
|
|
138
|
+
|
|
139
|
+
### Retrieve disputes
|
|
140
|
+
|
|
141
|
+
Disputes are inbound-only. You cannot create them; you can only list and retrieve them.
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// List all disputes
|
|
145
|
+
const disputes = await client.disputes.list();
|
|
146
|
+
|
|
147
|
+
// Retrieve a specific dispute
|
|
148
|
+
const dispute = await client.disputes.retrieve('dis_abc123');
|
|
149
|
+
console.log(dispute.dispute_status);
|
|
150
|
+
console.log(dispute.amount); // in smallest currency unit
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Dispute lifecycle
|
|
154
|
+
|
|
155
|
+
A dispute moves through seven events. Each event requires a different action from your application:
|
|
156
|
+
|
|
157
|
+
| Event | Meaning | Your action |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `dispute.opened` | Customer initiated a chargeback | Record the dispute; consider revoking access immediately; gather evidence from your logs |
|
|
160
|
+
| `dispute.challenged` | You submitted evidence | Wait for the card network to review |
|
|
161
|
+
| `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 |
|
|
163
|
+
| `dispute.expired` | Dispute window closed without resolution | Treat as lost; keep access revoked |
|
|
164
|
+
| `dispute.won` | You won the dispute | Funds are retained; restore access; update customer records |
|
|
165
|
+
| `dispute.lost` | You lost the dispute | Funds returned to cardholder; keep access revoked; reconcile your records |
|
|
166
|
+
|
|
167
|
+
### Handle dispute webhooks
|
|
168
|
+
|
|
169
|
+
Webhook dispute payloads intentionally contain no customer field. Resolve the customer with an extra `disputes.retrieve()` call: the webhook's `dispute_id` identifies the dispute, and the returned `GetDispute` includes `customer`. Without this lookup, access-control handlers would receive `undefined`.
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
async function resolveDisputeCustomerId(disputeId: string) {
|
|
173
|
+
const dispute = await client.disputes.retrieve(disputeId);
|
|
174
|
+
return dispute.customer.customer_id;
|
|
175
|
+
}
|
|
176
|
+
|
|
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
|
+
}
|
|
204
|
+
|
|
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
|
+
}
|
|
211
|
+
|
|
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
|
+
}
|
|
218
|
+
|
|
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
|
+
}
|
|
224
|
+
|
|
225
|
+
res.json({ received: true });
|
|
226
|
+
} catch (error) {
|
|
227
|
+
res.status(401).json({ error: 'Invalid signature' });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Evidence submission
|
|
233
|
+
|
|
234
|
+
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
|
+
|
|
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.
|
|
237
|
+
|
|
238
|
+
## Access revocation pattern
|
|
239
|
+
|
|
240
|
+
A common pattern for managing access during disputes:
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
async function handleDisputeLifecycle(
|
|
244
|
+
dispute: Awaited<ReturnType<typeof client.disputes.retrieve>>,
|
|
245
|
+
) {
|
|
246
|
+
const customerId = dispute.customer.customer_id;
|
|
247
|
+
|
|
248
|
+
switch (dispute.dispute_status) {
|
|
249
|
+
case 'dispute_opened':
|
|
250
|
+
// Revoke access immediately
|
|
251
|
+
await revokeCustomerAccess(customerId);
|
|
252
|
+
break;
|
|
253
|
+
|
|
254
|
+
case 'dispute_won':
|
|
255
|
+
// You won; restore access
|
|
256
|
+
await restoreCustomerAccess(customerId);
|
|
257
|
+
break;
|
|
258
|
+
|
|
259
|
+
case 'dispute_lost':
|
|
260
|
+
// You lost; keep access revoked
|
|
261
|
+
// (do nothing)
|
|
262
|
+
break;
|
|
263
|
+
|
|
264
|
+
case 'dispute_accepted':
|
|
265
|
+
// You conceded; funds go to the cardholder and access stays revoked
|
|
266
|
+
break;
|
|
267
|
+
|
|
268
|
+
case 'dispute_cancelled':
|
|
269
|
+
// Cancellation is not a win; reconcile separately and keep access revoked
|
|
270
|
+
break;
|
|
271
|
+
|
|
272
|
+
case 'dispute_expired':
|
|
273
|
+
// Treat as lost; keep access revoked
|
|
274
|
+
break;
|
|
275
|
+
|
|
276
|
+
case 'dispute_challenged':
|
|
277
|
+
// Evidence is under review; keep access revoked
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Reconciling entitlements after refund
|
|
284
|
+
|
|
285
|
+
When a refund succeeds, you must reconcile the customer's access. If the product is non-refundable (e.g., a digital download or subscription already used), revoke access. If it's refundable (e.g., a subscription not yet started), you may restore access or leave it revoked depending on your policy.
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
async function reconcileRefund(refund) {
|
|
289
|
+
if (refund.status !== 'succeeded') {
|
|
290
|
+
return; // Not yet final
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const payment = await client.payments.retrieve(refund.payment_id);
|
|
294
|
+
const customer = payment.customer;
|
|
295
|
+
|
|
296
|
+
// Revoke access for non-refundable products
|
|
297
|
+
const includesNonRefundableProduct = payment.product_cart?.some(({ product_id }) =>
|
|
298
|
+
isNonRefundable(product_id),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (includesNonRefundableProduct) {
|
|
302
|
+
await revokeCustomerAccess(customer.customer_id);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Update your entitlement records
|
|
306
|
+
await updateEntitlementRecord(customer.customer_id, {
|
|
307
|
+
refund_id: refund.refund_id,
|
|
308
|
+
refund_amount: refund.amount,
|
|
309
|
+
refund_date: new Date(),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Common mistakes
|
|
315
|
+
|
|
316
|
+
**Auto-refunding without revoking access.** A refund webhook means money is leaving your account. If the product is non-refundable, revoke access immediately. Don't wait for the customer to ask.
|
|
317
|
+
|
|
318
|
+
**Treating `dispute.opened` as final.** A dispute is not lost until the card network says so. Keep access revoked while it's open, but don't delete customer data or close their account.
|
|
319
|
+
|
|
320
|
+
**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
|
+
|
|
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`.
|
|
323
|
+
|
|
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.
|
|
325
|
+
|
|
326
|
+
**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
|
+
|
|
328
|
+
## Resources
|
|
329
|
+
|
|
330
|
+
- [Refunds API](https://docs.dodopayments.com/api-reference/refunds/post-refunds)
|
|
331
|
+
- [Refunds feature guide](https://docs.dodopayments.com/features/transactions/refunds)
|
|
332
|
+
- [Dispute webhooks](https://docs.dodopayments.com/developer-resources/webhooks/intents/dispute)
|
|
333
|
+
- [Webhook integration skill](../webhook-integration/) for signature verification
|