@dodopayments/opencode-plugin 0.5.1 → 0.6.1
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 +4 -14
- package/README.md +34 -19
- package/mcp.json +13 -0
- package/opencode-plugin/README.md +3 -3
- package/opencode-plugin/index.js +9 -7
- package/package.json +3 -2
- package/skills/better-auth-integration/SKILL.md +12 -3
- package/skills/billing-sdk/SKILL.md +66 -33
- package/skills/checkout-integration/SKILL.md +26 -20
- package/skills/credit-based-billing/SKILL.md +7 -8
- 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 +167 -35
- package/skills/license-keys/SKILL.md +142 -97
- package/skills/localized-pricing/SKILL.md +51 -29
- package/skills/mobile-checkout/SKILL.md +168 -118
- package/skills/product-catalog-management/SKILL.md +39 -16
- package/skills/refunds-and-disputes/SKILL.md +91 -71
- package/skills/subscription-integration/SKILL.md +62 -9
- package/skills/testing-and-go-live/SKILL.md +22 -15
- package/skills/usage-based-billing/SKILL.md +18 -15
- package/skills/webhook-integration/SKILL.md +27 -10
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mobile-checkout
|
|
3
|
-
description:
|
|
3
|
+
description: Dodo Payments in-app checkout for React Native, Flutter, native iOS, and Android using SFSafariViewController or Chrome Custom Tabs. Use when a mobile app must open a backend-created checkout session, handle deep link or custom URL scheme returns, recover abandoned checkouts, and verify payment server-side before unlocking access.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Mobile In-App Checkout
|
|
@@ -86,12 +86,13 @@ switch (result.status) {
|
|
|
86
86
|
showFailure();
|
|
87
87
|
break;
|
|
88
88
|
case 'cancelled':
|
|
89
|
-
//
|
|
90
|
-
|
|
89
|
+
// Sheet closed before the return URL arrived: the outcome is UNKNOWN and the
|
|
90
|
+
// payment may have succeeded. Do not show a failure - reconcile instead.
|
|
91
|
+
await reconcileAbandonedSession();
|
|
91
92
|
break;
|
|
92
93
|
case 'pending':
|
|
93
|
-
//
|
|
94
|
-
|
|
94
|
+
// Settles later (status=processing / requires_*), or status was missing.
|
|
95
|
+
await reconcileAbandonedSession();
|
|
95
96
|
break;
|
|
96
97
|
case 'expired':
|
|
97
98
|
// Checkout session expired. Prompt user to start a new checkout.
|
|
@@ -102,16 +103,24 @@ switch (result.status) {
|
|
|
102
103
|
|
|
103
104
|
### Abandoned session recovery
|
|
104
105
|
|
|
105
|
-
|
|
106
|
+
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`:
|
|
106
107
|
|
|
107
108
|
```typescript
|
|
108
109
|
import { DodoCheckout } from '@dodopayments/react-native-checkout';
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
async function reconcileAbandonedSession() {
|
|
112
|
+
const abandoned = await DodoCheckout.getAbandonedSession();
|
|
113
|
+
if (!abandoned) return;
|
|
114
|
+
|
|
115
|
+
// Your backend calls GET /checkouts/{abandoned.sessionId} and returns payment_status.
|
|
116
|
+
const outcome = await fetchCheckoutOutcome(abandoned.sessionId);
|
|
117
|
+
if (outcome === 'succeeded' || outcome === 'failed' || outcome === 'expired') {
|
|
118
|
+
showOutcome(outcome);
|
|
119
|
+
// Clear only once the outcome is final; until then treat it as pending, not failed.
|
|
120
|
+
await DodoCheckout.clearAbandonedSession();
|
|
121
|
+
} else {
|
|
122
|
+
showPending();
|
|
123
|
+
}
|
|
115
124
|
}
|
|
116
125
|
```
|
|
117
126
|
|
|
@@ -176,10 +185,10 @@ switch (result.status) {
|
|
|
176
185
|
showFailure();
|
|
177
186
|
break;
|
|
178
187
|
case CheckoutStatus.cancelled:
|
|
179
|
-
dismiss();
|
|
180
|
-
break;
|
|
181
188
|
case CheckoutStatus.pending:
|
|
182
|
-
|
|
189
|
+
// Outcome unknown (the payment may have succeeded): reconcile the
|
|
190
|
+
// abandoned session with your backend instead of showing a failure.
|
|
191
|
+
await reconcileAbandonedSession();
|
|
183
192
|
break;
|
|
184
193
|
case CheckoutStatus.expired:
|
|
185
194
|
showExpired();
|
|
@@ -218,111 +227,136 @@ On iOS, register the same scheme in `ios/Runner/Info.plist`:
|
|
|
218
227
|
|
|
219
228
|
## iOS (native)
|
|
220
229
|
|
|
221
|
-
|
|
230
|
+
Use the official Swift package rather than parsing the return URL yourself. It presents `SFSafariViewController`, matches the return URL, and maps `status` (including `active` for subscriptions and `processing`/`requires_*` as pending) into a typed result. Requires iOS 16+ and Swift 6.2+.
|
|
222
231
|
|
|
223
|
-
|
|
232
|
+
### Installation
|
|
233
|
+
|
|
234
|
+
Add `https://github.com/dodopayments/dodopayments-mobile-sdk-ios` (1.1.0 or later) in **File → Add Package Dependencies**, or in `Package.swift`:
|
|
224
235
|
|
|
225
236
|
```swift
|
|
226
|
-
|
|
237
|
+
.package(url: "https://github.com/dodopayments/dodopayments-mobile-sdk-ios", from: "1.1.0")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The library product is `DodoCheckout`. Register your scheme (`myapp`) under `CFBundleURLTypes` in `Info.plist`, and use the same URL as the session's `return_url`.
|
|
227
241
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
242
|
+
### Starting checkout
|
|
243
|
+
|
|
244
|
+
```swift
|
|
245
|
+
import DodoCheckout
|
|
246
|
+
|
|
247
|
+
let result = try await DodoCheckout.start(
|
|
248
|
+
checkoutUrl: checkoutUrl, // URL from your backend's checkout session
|
|
249
|
+
returnUrl: URL(string: "myapp://checkout/return")!,
|
|
250
|
+
onEvent: { event in print(event.name) } // logging only
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
switch result.status {
|
|
254
|
+
case .succeeded: showSuccess(result.paymentId) // UI hint only; verify on the backend
|
|
255
|
+
case .failed: showFailure()
|
|
256
|
+
case .cancelled: await reconcileAbandonedSession() // outcome unknown, not a failure
|
|
257
|
+
case .pending: await reconcileAbandonedSession()
|
|
258
|
+
case .expired: showExpired()
|
|
259
|
+
}
|
|
231
260
|
```
|
|
232
261
|
|
|
233
|
-
###
|
|
262
|
+
### Forwarding the return URL
|
|
234
263
|
|
|
235
|
-
|
|
264
|
+
`SFSafariViewController` cannot catch its own return URL, so forward every incoming URL to the SDK:
|
|
236
265
|
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
<array>
|
|
243
|
-
<string>myapp</string>
|
|
244
|
-
</array>
|
|
245
|
-
</dict>
|
|
246
|
-
</array>
|
|
266
|
+
```swift
|
|
267
|
+
// SwiftUI
|
|
268
|
+
.onOpenURL { url in
|
|
269
|
+
DodoCheckout.handleOpenURL(url)
|
|
270
|
+
}
|
|
247
271
|
```
|
|
248
272
|
|
|
249
|
-
|
|
273
|
+
`handleOpenURL` returns `true` only for the in-progress checkout's return URL; handle other URLs yourself.
|
|
274
|
+
|
|
275
|
+
### Abandoned sessions
|
|
250
276
|
|
|
251
277
|
```swift
|
|
252
|
-
func
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
switch status {
|
|
263
|
-
case "succeeded":
|
|
264
|
-
let paymentId = components?.queryItems?.first(where: { $0.name == "payment_id" })?.value
|
|
265
|
-
verifyPaymentOnBackend(paymentId: paymentId)
|
|
266
|
-
case "cancelled":
|
|
267
|
-
dismiss()
|
|
268
|
-
case "expired":
|
|
269
|
-
showExpired()
|
|
278
|
+
func reconcileAbandonedSession() async {
|
|
279
|
+
guard let abandoned = DodoCheckout.getAbandonedSession() else { return }
|
|
280
|
+
|
|
281
|
+
// Your backend calls GET /checkouts/{sessionId} and returns payment_status.
|
|
282
|
+
let outcome = await fetchCheckoutOutcome(abandoned.sessionId)
|
|
283
|
+
switch outcome {
|
|
284
|
+
case "succeeded", "failed", "expired":
|
|
285
|
+
showOutcome(outcome)
|
|
286
|
+
// Clear only once the outcome is final.
|
|
287
|
+
DodoCheckout.clearAbandonedSession()
|
|
270
288
|
default:
|
|
271
|
-
|
|
289
|
+
showPending() // still pending - keep the record and check again later
|
|
272
290
|
}
|
|
273
|
-
return true
|
|
274
|
-
}
|
|
275
|
-
return false
|
|
276
291
|
}
|
|
277
292
|
```
|
|
278
293
|
|
|
279
294
|
## Android (native)
|
|
280
295
|
|
|
281
|
-
|
|
296
|
+
Use the official Android checkout SDK (`minSdk` 23, Kotlin, Java 17). It opens Chrome Custom Tabs, declares the redirect intent filter itself, and returns a typed result.
|
|
282
297
|
|
|
283
|
-
|
|
298
|
+
### Installation
|
|
284
299
|
|
|
285
300
|
```kotlin
|
|
286
|
-
|
|
287
|
-
|
|
301
|
+
// app/build.gradle.kts
|
|
302
|
+
dependencies {
|
|
303
|
+
implementation("com.dodopayments.api:checkout-android:1.1.0")
|
|
304
|
+
}
|
|
288
305
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
306
|
+
android {
|
|
307
|
+
defaultConfig {
|
|
308
|
+
// The SDK's manifest uses this placeholder; do not add an intent filter yourself.
|
|
309
|
+
manifestPlaceholders["dodoCallbackScheme"] = "myapp"
|
|
310
|
+
}
|
|
311
|
+
}
|
|
292
312
|
```
|
|
293
313
|
|
|
294
|
-
###
|
|
314
|
+
### Starting checkout
|
|
295
315
|
|
|
296
|
-
|
|
316
|
+
```kotlin
|
|
317
|
+
import com.dodopayments.checkout.CheckoutParams
|
|
318
|
+
import com.dodopayments.checkout.CheckoutStatus
|
|
319
|
+
import com.dodopayments.checkout.DodoCheckout
|
|
320
|
+
import androidx.lifecycle.lifecycleScope
|
|
321
|
+
import kotlinx.coroutines.launch
|
|
322
|
+
|
|
323
|
+
// The activity-result launcher survives process death.
|
|
324
|
+
private val checkoutLauncher =
|
|
325
|
+
registerForActivityResult(DodoCheckout.contract()) { result ->
|
|
326
|
+
when (result.status) {
|
|
327
|
+
CheckoutStatus.SUCCEEDED -> showSuccess(result.paymentId) // UI hint only; verify on the backend
|
|
328
|
+
CheckoutStatus.FAILED -> showFailure()
|
|
329
|
+
// Outcome unknown, not a failure. reconcileAbandonedSession() suspends
|
|
330
|
+
// (it calls your backend), so launch it from the lifecycle scope.
|
|
331
|
+
CheckoutStatus.CANCELLED, CheckoutStatus.PENDING ->
|
|
332
|
+
lifecycleScope.launch { reconcileAbandonedSession() }
|
|
333
|
+
CheckoutStatus.EXPIRED -> showExpired()
|
|
334
|
+
}
|
|
335
|
+
}
|
|
297
336
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
<data android:scheme="myapp" android:host="checkout" android:path="/return" />
|
|
305
|
-
</intent-filter>
|
|
306
|
-
</activity>
|
|
337
|
+
checkoutLauncher.launch(
|
|
338
|
+
CheckoutParams(
|
|
339
|
+
checkoutUrl = checkoutUrl, // from your backend's checkout session
|
|
340
|
+
returnUrl = "myapp://checkout/return"
|
|
341
|
+
)
|
|
342
|
+
)
|
|
307
343
|
```
|
|
308
344
|
|
|
309
|
-
|
|
345
|
+
### Abandoned sessions
|
|
310
346
|
|
|
311
347
|
```kotlin
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
"expired" -> showExpired()
|
|
348
|
+
suspend fun reconcileAbandonedSession() {
|
|
349
|
+
val abandoned = DodoCheckout.getAbandonedSession(context) ?: return
|
|
350
|
+
|
|
351
|
+
// Your backend calls GET /checkouts/{sessionId} and returns payment_status.
|
|
352
|
+
when (val outcome = fetchCheckoutOutcome(abandoned.sessionId)) {
|
|
353
|
+
"succeeded", "failed", "expired" -> {
|
|
354
|
+
showOutcome(outcome)
|
|
355
|
+
// Clear only once the outcome is final.
|
|
356
|
+
DodoCheckout.clearAbandonedSession(context)
|
|
357
|
+
}
|
|
358
|
+
else -> showPending() // still pending - keep the record and check again later
|
|
324
359
|
}
|
|
325
|
-
}
|
|
326
360
|
}
|
|
327
361
|
```
|
|
328
362
|
|
|
@@ -372,23 +406,40 @@ Never grant access based on the mobile SDK result alone. Always verify via webho
|
|
|
372
406
|
Listen for `payment.succeeded` webhooks. Webhook signature verification is covered in the `webhook-integration` skill.
|
|
373
407
|
|
|
374
408
|
```typescript
|
|
409
|
+
// Mount with express.raw({ type: 'application/json' }) so req.body is the raw Buffer.
|
|
375
410
|
app.post('/webhook', async (req, res) => {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
// Grant access to the customer
|
|
389
|
-
await grantAccess(customerId);
|
|
411
|
+
let event;
|
|
412
|
+
try {
|
|
413
|
+
event = client.webhooks.unwrap(req.body.toString(), {
|
|
414
|
+
headers: {
|
|
415
|
+
'webhook-id': req.headers['webhook-id'] as string,
|
|
416
|
+
'webhook-signature': req.headers['webhook-signature'] as string,
|
|
417
|
+
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
} catch {
|
|
421
|
+
return res.status(401).json({ error: 'Invalid signature' });
|
|
390
422
|
}
|
|
391
|
-
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
// Dodo retries and may redeliver: claim webhook-id with a UNIQUE insert
|
|
426
|
+
// and grant in the same transaction so a duplicate is a no-op.
|
|
427
|
+
await db.$transaction(async (tx) => {
|
|
428
|
+
const claim = await tx.webhookLog.createMany({
|
|
429
|
+
data: [{ webhookId: req.headers['webhook-id'] as string, eventType: event.type }],
|
|
430
|
+
skipDuplicates: true,
|
|
431
|
+
});
|
|
432
|
+
if (claim.count === 0) return; // already processed
|
|
433
|
+
|
|
434
|
+
if (event.type === 'payment.succeeded') {
|
|
435
|
+
await grantAccess(event.data.customer.customer_id, tx);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
} catch (error) {
|
|
439
|
+
// Non-2xx makes Dodo retry; the transaction rolled back the claim.
|
|
440
|
+
return res.status(500).json({ error: 'Processing failed' });
|
|
441
|
+
}
|
|
442
|
+
|
|
392
443
|
res.json({ received: true });
|
|
393
444
|
});
|
|
394
445
|
```
|
|
@@ -408,13 +459,14 @@ if (session.payment_status === 'succeeded' && session.payment_id) {
|
|
|
408
459
|
|
|
409
460
|
## Selling digital goods on iOS
|
|
410
461
|
|
|
411
|
-
|
|
462
|
+
Dodo Payments hosted checkout can sell digital goods (subscriptions, courses, downloads, SaaS plans) in an iOS app **only on App Store storefronts where Apple allows external purchases**:
|
|
412
463
|
|
|
413
|
-
-
|
|
414
|
-
-
|
|
415
|
-
-
|
|
464
|
+
- **United States:** Guideline 3.1.1(a) allows buttons and links to other purchase methods without an entitlement (subject to the Epic v. Apple proceedings).
|
|
465
|
+
- **European Union:** requires Apple's EU external purchase entitlement (the StoreKit External Purchases or Offers Entitlement from October 1, 2026) and DMA compliance.
|
|
466
|
+
- **Japan:** allowed under the Mobile Software Competition Act, following Apple's Japan-specific entitlement requirements.
|
|
467
|
+
- **South Korea is not supported** (Apple requires a native, non-web-view flow through an approved Korean PSP).
|
|
416
468
|
|
|
417
|
-
|
|
469
|
+
On other storefronts, digital goods sold inside the iOS app must use Apple in-app purchase (StoreKit). Review Apple's region-specific entitlements before enabling Dodo checkout for a storefront; unsupported flows can get the app rejected.
|
|
418
470
|
|
|
419
471
|
## Common mistakes
|
|
420
472
|
|
|
@@ -459,8 +511,8 @@ If you don't register the custom URL scheme, the app won't receive the return ca
|
|
|
459
511
|
|
|
460
512
|
- React Native: Use the Expo plugin or manually register in `Info.plist` and `AndroidManifest.xml`.
|
|
461
513
|
- Flutter: Register in both `Info.plist` and `AndroidManifest.xml`.
|
|
462
|
-
- iOS: Add `CFBundleURLTypes` to `Info.plist`.
|
|
463
|
-
- Android:
|
|
514
|
+
- iOS: Add `CFBundleURLTypes` to `Info.plist` and forward URLs to `DodoCheckout.handleOpenURL`.
|
|
515
|
+
- Android: Set `manifestPlaceholders["dodoCallbackScheme"]`; the SDK supplies the intent filter.
|
|
464
516
|
|
|
465
517
|
### Not handling all result statuses
|
|
466
518
|
|
|
@@ -481,10 +533,9 @@ switch (result.status) {
|
|
|
481
533
|
showFailure();
|
|
482
534
|
break;
|
|
483
535
|
case 'cancelled':
|
|
484
|
-
dismiss();
|
|
485
|
-
break;
|
|
486
536
|
case 'pending':
|
|
487
|
-
|
|
537
|
+
// Unknown outcome - reconcile, never show a failure
|
|
538
|
+
reconcileAbandonedSession();
|
|
488
539
|
break;
|
|
489
540
|
case 'expired':
|
|
490
541
|
showExpired();
|
|
@@ -500,12 +551,9 @@ If the app crashes or is backgrounded during checkout, the session is abandoned.
|
|
|
500
551
|
// WRONG
|
|
501
552
|
// No recovery logic
|
|
502
553
|
|
|
503
|
-
// CORRECT
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
// Reconcile and clear
|
|
507
|
-
await DodoCheckout.clearAbandonedSession();
|
|
508
|
-
}
|
|
554
|
+
// CORRECT: reconcile on launch and after every cancelled/pending result,
|
|
555
|
+
// and clear the record only once the backend reports a final outcome.
|
|
556
|
+
await reconcileAbandonedSession(); // defined in the React Native section above
|
|
509
557
|
```
|
|
510
558
|
|
|
511
559
|
## Package names
|
|
@@ -516,6 +564,8 @@ Use `@dodopayments/react-native-checkout` for React Native and `dodopayments_che
|
|
|
516
564
|
|
|
517
565
|
- [Mobile Integration](https://docs.dodopayments.com/developer-resources/mobile-integration)
|
|
518
566
|
- [React Native SDK](https://docs.dodopayments.com/developer-resources/sdks/react-native)
|
|
567
|
+
- [iOS SDK](https://docs.dodopayments.com/developer-resources/sdks/ios)
|
|
568
|
+
- [Android SDK](https://docs.dodopayments.com/developer-resources/sdks/android)
|
|
519
569
|
- [Flutter SDK](https://pub.dev/packages/dodopayments_checkout)
|
|
520
570
|
- [Selling Digital Goods on iOS](https://docs.dodopayments.com/features/appstore-digital-goods)
|
|
521
571
|
- [Webhook Integration](https://docs.dodopayments.com/developer-resources/webhooks/intents) (for payment verification)
|
|
@@ -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
|
|