@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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: mobile-checkout
3
- description: Guide for implementing mobile in-app checkout with Dodo Payments across React Native, Flutter, iOS, and Android platforms.
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
- // User cancelled. Dismiss checkout.
90
- dismiss();
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
- // Payment is pending (e.g., awaiting 3D Secure). Show waiting state.
94
- showPending();
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
- If the app crashes or is backgrounded during checkout, recover the session:
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
- const abandoned = await DodoCheckout.getAbandonedSession();
111
- if (abandoned) {
112
- // Reconcile abandoned.sessionId with your backend
113
- // Decide whether to resume or start fresh
114
- await DodoCheckout.clearAbandonedSession();
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
- showPending();
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
- ### Setup
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
- Use `SFSafariViewController` to open the checkout URL:
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
- import SafariServices
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
- let checkoutURL = URL(string: "https://checkout.dodopayments.com/...")!
229
- let safariVC = SFSafariViewController(url: checkoutURL)
230
- present(safariVC, animated: true)
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
- ### Deep-link handling
262
+ ### Forwarding the return URL
234
263
 
235
- Register your custom URL scheme in `Info.plist`:
264
+ `SFSafariViewController` cannot catch its own return URL, so forward every incoming URL to the SDK:
236
265
 
237
- ```xml
238
- <key>CFBundleURLTypes</key>
239
- <array>
240
- <dict>
241
- <key>CFBundleURLSchemes</key>
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
- Handle the return in your app delegate:
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 application(
253
- _ app: UIApplication,
254
- open url: URL,
255
- options: [UIApplication.OpenURLOptionsKey: Any] = [:]
256
- ) -> Bool {
257
- if url.scheme == "myapp" && url.host == "checkout" {
258
- // Parse the result from the URL query parameters
259
- let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
260
- let status = components?.queryItems?.first(where: { $0.name == "status" })?.value
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
- break
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
- ### Setup
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
- Use Chrome Custom Tabs to open the checkout URL:
298
+ ### Installation
284
299
 
285
300
  ```kotlin
286
- import androidx.browser.customtabs.CustomTabsIntent
287
- import android.net.Uri
301
+ // app/build.gradle.kts
302
+ dependencies {
303
+ implementation("com.dodopayments.api:checkout-android:1.1.0")
304
+ }
288
305
 
289
- val checkoutUri = Uri.parse("https://checkout.dodopayments.com/...")
290
- val customTabsIntent = CustomTabsIntent.Builder().build()
291
- customTabsIntent.launchUrl(context, checkoutUri)
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
- ### Deep-link handling
314
+ ### Starting checkout
295
315
 
296
- Register your custom URL scheme in `AndroidManifest.xml`:
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
- ```xml
299
- <activity android:name=".CheckoutReturnActivity">
300
- <intent-filter>
301
- <action android:name="android.intent.action.VIEW" />
302
- <category android:name="android.intent.category.DEFAULT" />
303
- <category android:name="android.intent.category.BROWSABLE" />
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
- Handle the return in your activity:
345
+ ### Abandoned sessions
310
346
 
311
347
  ```kotlin
312
- override fun onCreate(savedInstanceState: Bundle?) {
313
- super.onCreate(savedInstanceState)
314
-
315
- val uri = intent.data
316
- if (uri?.scheme == "myapp" && uri.host == "checkout") {
317
- val status = uri.getQueryParameter("status")
318
- val paymentId = uri.getQueryParameter("payment_id")
319
-
320
- when (status) {
321
- "succeeded" -> verifyPaymentOnBackend(paymentId)
322
- "cancelled" -> dismiss()
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
- const event = client.webhooks.unwrap(req.body.toString(), {
377
- headers: {
378
- 'webhook-id': req.headers['webhook-id'] as string,
379
- 'webhook-signature': req.headers['webhook-signature'] as string,
380
- 'webhook-timestamp': req.headers['webhook-timestamp'] as string,
381
- },
382
- });
383
-
384
- if (event.type === 'payment.succeeded') {
385
- const paymentId = event.data.payment_id;
386
- const customerId = event.data.customer.customer_id;
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
- If you're selling digital goods (software, in-app features, subscriptions) on iOS, Apple requires you to use in-app purchase APIs for certain categories. Dodo Payments can handle the payment processing, but you must comply with App Store guidelines:
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
- - Digital content (ebooks, music, software) must use in-app purchase.
414
- - Physical goods and services can use alternative payment methods.
415
- - Subscriptions for digital content must use in-app purchase.
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
- Consult Apple's App Store Review Guidelines and consider whether your product category requires in-app purchase. If it does, integrate StoreKit 2 alongside Dodo Payments for compliance.
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: Add an intent filter with the scheme in `AndroidManifest.xml`.
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
- showPending();
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
- const abandoned = await DodoCheckout.getAbandonedSession();
505
- if (abandoned) {
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: Guide for creating and managing products, pricing, add-ons, product collections, images, and digital product delivery
3
+ description: Dodo Payments product catalog management, covering products with one_time_price, recurring_price, or usage_based_price, add-ons, product collections, product images, and digital file delivery. Use when creating or updating products, plans, or prices, building a storefront, uploading images, attaching downloadable files, or debugging a failed product update.
4
4
  ---
5
5
 
6
6
  # Product Catalog Management
@@ -13,7 +13,7 @@ This skill covers the full product lifecycle: creating products with pricing mod
13
13
  - You're building a product collection or storefront.
14
14
  - You need to upload product images or deliver digital files to customers.
15
15
  - You're managing add-ons or product variants.
16
- - You need to understand why a product update failed or why pricing can't be changed.
16
+ - You need to understand why a product update failed or how to change a product's pricing.
17
17
 
18
18
  ## Core concepts
19
19
 
@@ -36,7 +36,7 @@ Pricing is nested inside the product object. There is no separate top-level Pric
36
36
 
37
37
  **Lifecycle:** Products support `list`, `retrieve`, `update`, and `archive`/`unarchive`. There is no delete endpoint. Archived products remain in your history but don't appear in new checkouts.
38
38
 
39
- **Images:** Presigned upload URLs expire after 60 seconds. Download the URL immediately after requesting it.
39
+ **Images:** Presigned upload URLs expire after 60 seconds. Upload to the URL immediately after requesting it.
40
40
 
41
41
  **Digital delivery:** Entitlements grant customers access to files. Download URLs expire after roughly 15 minutes.
42
42
 
@@ -129,7 +129,9 @@ const product = await client.products.retrieve('pdt_pro_bundle');
129
129
 
130
130
  ## Updating a product
131
131
 
132
- You can update the name, description, and metadata. You cannot change the pricing model or price of a live product. Create a new product instead.
132
+ `PATCH /products/{id}` (`client.products.update`) accepts the name, description, and metadata, and also `price`, `tax_category`, and `pricing_mode`. Every field is optional; omitted fields stay unchanged.
133
+
134
+ Two limits apply. The **pricing model is immutable**: you can edit the amount, but you cannot turn a one-time product into a subscription or usage-based one (or back) - create a new product instead. And a price edit only changes what **new** customers pay: existing subscriptions keep the price they were created with, so move them explicitly with Change Plan (`client.subscriptions.changePlan(...)`).
133
135
 
134
136
  ```typescript
135
137
  await client.products.update('pdt_pro_bundle', {
@@ -226,14 +228,25 @@ await client.productCollections.groups.create(collection.id, {
226
228
  ],
227
229
  });
228
230
 
229
- // Checkout with a collection
230
- const session = await client.checkoutSessions.create({
231
- product_collection_id: collection.id,
232
- product_cart: [], // Required: pass an empty array for collection checkout
233
- return_url: 'https://yoursite.com/return',
234
- });
231
+ // Server route (e.g. POST /api/checkout): create the checkout session and return its URL
232
+ export async function POST() {
233
+ const session = await client.checkoutSessions.create({
234
+ product_collection_id: collection.id,
235
+ product_cart: [], // Required: pass an empty array for collection checkout
236
+ return_url: 'https://yoursite.com/return',
237
+ });
235
238
 
236
- window.location.href = session.checkout_url;
239
+ return Response.json({ checkout_url: session.checkout_url });
240
+ }
241
+ ```
242
+
243
+ Never call the SDK or expose the API key in browser code. The browser only receives the URL from your server route and redirects:
244
+
245
+ ```typescript
246
+ // Browser
247
+ const res = await fetch('/api/checkout', { method: 'POST' });
248
+ const { checkout_url } = (await res.json()) as { checkout_url: string };
249
+ window.location.href = checkout_url;
237
250
  ```
238
251
 
239
252
  ## Digital product delivery
@@ -241,6 +254,9 @@ window.location.href = session.checkout_url;
241
254
  Entitlements grant customers access to downloadable files. Create an entitlement, upload files, and generate download links.
242
255
 
243
256
  ```typescript
257
+ // openAsBlob requires Node.js 20 or later
258
+ import { openAsBlob } from 'node:fs';
259
+
244
260
  // Create an entitlement
245
261
  const entitlement = await client.entitlements.create({
246
262
  name: 'Pro Bundle Files',
@@ -250,10 +266,15 @@ const entitlement = await client.entitlements.create({
250
266
  },
251
267
  });
252
268
 
253
- // Upload and attach a file; the response identifies the attached file
254
- const { file_id } = await client.entitlements.files.upload(entitlement.id);
269
+ // Upload and attach a file as multipart/form-data (append `filename` before `file`)
270
+ const form = new FormData();
271
+ form.append('filename', 'pro-bundle.zip');
272
+ form.append('file', await openAsBlob('./pro-bundle.zip'), 'pro-bundle.zip');
273
+ const { file_id } = await client.entitlements.files.upload(entitlement.id, { body: form });
255
274
 
256
- // Attach the entitlement to a product
275
+ // Attach the entitlement to a product.
276
+ // `entitlements` REPLACES every entitlement already attached to the product:
277
+ // include all of them, send [] to remove all, or omit the field to leave them unchanged.
257
278
  await client.products.update('pdt_pro_bundle', {
258
279
  entitlements: [{ entitlement_id: entitlement.id }],
259
280
  });
@@ -290,7 +311,9 @@ For country or currency-specific pricing, use the `localized-pricing` skill. Do
290
311
 
291
312
  **Expecting a delete endpoint:** Products don't have a delete method. Archive them instead. Archived products remain in your history for reconciliation.
292
313
 
293
- **Trying to change the pricing model:** Once a product is created with `one_time_price`, you can't change it to `recurring_price`. Create a new product and migrate customers to it.
314
+ **Trying to change the pricing model:** You cannot switch a product between one-time, subscription, and usage-based pricing (for example `one_time_price` to `recurring_price`). Create a new product with the model you need.
315
+
316
+ **Expecting a price edit to reprice existing subscribers:** `client.products.update(...)` changes the amount for new customers only. Existing subscriptions renew at the price they were created with; move them with `client.subscriptions.changePlan(...)`.
294
317
 
295
318
  **Hardcoding prices in the client:** Always fetch the product catalog from the API. Prices change, and your client code will become stale. Read `client.products.retrieve(id)` to get the current price.
296
319
 
@@ -300,7 +323,7 @@ For country or currency-specific pricing, use the `localized-pricing` skill. Do
300
323
 
301
324
  **Passing a non-empty product_cart for collection checkout:** Collection checkout requires `product_cart: []`. The collection itself defines what's in the cart.
302
325
 
303
- **Confusing purchasing_power_parity with localized pricing:** The OpenAPI schema includes a `purchasing_power_parity` field in examples, but it's marked unavailable. Use the `localized-pricing` skill for country and currency pricing instead.
326
+ **Confusing purchasing_power_parity with localized pricing:** PPP is native. Set `price.purchasing_power_parity: true` to charge a business-wide, per-country percentage of the price (default `false`). It requires Adaptive Currency to be enabled, and a matching localized price always wins over PPP. See the `localized-pricing` skill.
304
327
 
305
328
  ## Resources
306
329