@dodopayments/opencode-plugin 0.6.0 → 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: license-keys
3
- description: Guide for implementing license key management with Dodo Payments - activation, validation, and access control for software products.
3
+ description: Dodo Payments license keys for software products, covering activation, validation, and deactivation via client.licenses, activation limits, expiry, entitlement grants, revocation, and license webhooks. Use when gating a desktop app, CLI, plugin, or SaaS feature behind a license key, enforcing per-seat or per-device limits, or building a license dashboard.
4
4
  ---
5
5
 
6
6
  # Dodo Payments License Keys
@@ -26,8 +26,11 @@ License keys authorize access to your digital products. Use them for software li
26
26
  - `validate()` — check if a key is valid
27
27
  - `deactivate()` — free an activation slot
28
28
 
29
- 2. **`client.licenseKeys.*`** — merchant-side key management (requires API key)
30
- - `list()`, `retrieve()`, `create()`, `update()` — manage keys for your customers
29
+ 2. **Entitlement grants** — merchant-side reads and revocation (requires API key)
30
+ - `client.customers.listEntitlementGrants()`, `client.entitlements.grants.list()` — list issued keys (each license-key grant carries a `license_key` object)
31
+ - `client.entitlements.grants.revoke()` — revoke a key
32
+ - `client.licenseKeys.create()` — still supported, for **importing** existing keys
33
+ - `client.licenseKeys.list()` / `retrieve()` / `update()` are **deprecated** (`GET /license_keys`, `GET`/`PATCH /license_keys/{id}`); use the grant endpoints above instead
31
34
 
32
35
  3. **`client.licenseKeyInstances.*`** — per-device activation instances (requires API key)
33
36
  - `list()`, `retrieve()`, `update()` — track active devices per key
@@ -36,7 +39,7 @@ License keys authorize access to your digital products. Use them for software li
36
39
 
37
40
  **Expiry semantics:**
38
41
  - One-time-payment keys honor the entitlement duration.
39
- - Subscription-issued keys have no independent expiry; validity follows subscription state. On hold disables them temporarily. An immediate cancellation disables them permanently, but when `cancel_at_next_billing_date` is set, keep them active until the subscription reaches the end of its term.
42
+ - Subscription-issued keys have no independent expiry; Dodo drives their validity from subscription state **automatically**: `past_due` leaves them active, `on_hold` and `paused` disable them until the subscription recovers or resumes, `cancelled`/`expired` disable them permanently, and `plan_changed` replaces them. A one-time `refund.succeeded` also disables them. You do not need to disable keys yourself.
40
43
  - Imported keys use nullable `expires_at`; null means perpetual.
41
44
 
42
45
  ---
@@ -48,8 +51,10 @@ License keys authorize access to your digital products. Use them for software li
48
51
  ```typescript
49
52
  import DodoPayments from 'dodopayments';
50
53
 
51
- // No API key needed for public endpoints
52
- const client = new DodoPayments();
54
+ // The license endpoints are public and don't check the token, but the SDK
55
+ // constructor requires a value. Never ship your secret API key in client software.
56
+ // Set the environment explicitly: the SDK defaults to live_mode.
57
+ const client = new DodoPayments({ bearerToken: 'public', environment: 'test_mode' });
53
58
 
54
59
  async function activateLicense(licenseKey: string, deviceName: string) {
55
60
  try {
@@ -80,12 +85,15 @@ Response includes `id` (instance ID), `business_id`, `name`, `license_key_id`, `
80
85
  ```typescript
81
86
  import DodoPayments from 'dodopayments';
82
87
 
83
- const client = new DodoPayments();
88
+ const client = new DodoPayments({ bearerToken: 'public', environment: 'test_mode' }); // public endpoints: placeholder token; use 'live_mode' in production
84
89
 
85
- async function validateLicense(licenseKey: string) {
90
+ async function validateLicense(licenseKey: string, instanceId: string) {
86
91
  try {
87
92
  const response = await client.licenses.validate({
88
93
  license_key: licenseKey,
94
+ // Also checks that THIS device's activation still exists, so a
95
+ // deactivated device stops validating even though the key is active.
96
+ license_key_instance_id: instanceId,
89
97
  });
90
98
 
91
99
  return { valid: response.valid };
@@ -95,14 +103,14 @@ async function validateLicense(licenseKey: string) {
95
103
  }
96
104
  ```
97
105
 
98
- Optional: pass `license_key_instance_id` to validate a specific instance.
106
+ Without `license_key_instance_id`, validation only checks that the key is `active` and unexpired. Pass the instance ID you stored at activation.
99
107
 
100
108
  ### Deactivate a license
101
109
 
102
110
  ```typescript
103
111
  import DodoPayments from 'dodopayments';
104
112
 
105
- const client = new DodoPayments();
113
+ const client = new DodoPayments({ bearerToken: 'public', environment: 'test_mode' }); // public endpoints: placeholder token; use 'live_mode' in production
106
114
 
107
115
  async function deactivateLicense(licenseKey: string, instanceId: string) {
108
116
  try {
@@ -122,7 +130,9 @@ async function deactivateLicense(licenseKey: string, instanceId: string) {
122
130
 
123
131
  ## Merchant-Side Key Management
124
132
 
125
- ### List customer license keys
133
+ ### List a customer's license keys
134
+
135
+ `client.licenseKeys.list()`, `retrieve()`, and `update()` are deprecated. Read keys through the entitlement grant endpoints; each license-key grant carries a `license_key` object (`key`, `status`, `expires_at`, `activations_used`, `activations_limit`), which is `null` on a manual-mode grant still `Pending`.
126
136
 
127
137
  ```typescript
128
138
  import DodoPayments from 'dodopayments';
@@ -133,48 +143,39 @@ const client = new DodoPayments({
133
143
  });
134
144
 
135
145
  async function listCustomerKeys(customerId: string) {
136
- const keys = await client.licenseKeys.list({
137
- customer_id: customerId,
138
- });
139
-
140
- return keys.items.map(key => ({
141
- id: key.id,
142
- key: key.key,
143
- status: key.status,
144
- expiresAt: key.expires_at,
145
- activationsUsed: key.instances_count,
146
- activationsLimit: key.activations_limit,
147
- }));
146
+ const keys = [];
147
+ for await (const grant of client.customers.listEntitlementGrants(customerId, {
148
+ integration_type: 'license_key',
149
+ status: 'Delivered',
150
+ })) {
151
+ if (!grant.license_key) continue;
152
+ keys.push({
153
+ grantId: grant.id,
154
+ entitlementId: grant.entitlement_id,
155
+ key: grant.license_key.key,
156
+ status: grant.license_key.status,
157
+ expiresAt: grant.license_key.expires_at,
158
+ activationsUsed: grant.license_key.activations_used,
159
+ activationsLimit: grant.license_key.activations_limit,
160
+ });
161
+ }
162
+ return keys;
148
163
  }
149
164
  ```
150
165
 
151
- ### Retrieve a single license key
152
-
153
- ```typescript
154
- const key = await client.licenseKeys.retrieve('lk_abc123');
155
-
156
- console.log({
157
- id: key.id,
158
- key: key.key,
159
- status: key.status,
160
- expiresAt: key.expires_at,
161
- activationsUsed: key.instances_count,
162
- activationsLimit: key.activations_limit,
163
- });
164
- ```
166
+ To list every key issued for one License Key entitlement, use `client.entitlements.grants.list('ent_...', { status: 'Delivered' })`.
165
167
 
166
- ### Update a license key
168
+ ### Revoke a license key
167
169
 
168
170
  ```typescript
169
- // Disable a key or adjust its activation limit
170
- await client.licenseKeys.update('lk_abc123', {
171
- disabled: true,
172
- // or adjust activations_limit
173
- activations_limit: 10,
174
- });
171
+ // Disables the key with revocation_reason: manual. Manually revoked keys are
172
+ // not re-granted on subscription renewal.
173
+ await client.entitlements.grants.revoke('entg_abc123', { id: 'ent_license_key_id' });
175
174
  ```
176
175
 
177
- ### Create a license key (manual issuance)
176
+ ### Import a license key (`POST /license_keys`)
177
+
178
+ Use this to migrate keys from another system. Imported keys do **not** trigger a customer email; notify the customer yourself. For keys Dodo issues, Dodo emails them.
178
179
 
179
180
  ```typescript
180
181
  const newKey = await client.licenseKeys.create({
@@ -227,12 +228,13 @@ import DodoPayments from 'dodopayments';
227
228
  import os from 'os';
228
229
 
229
230
  const store = new Store();
230
- const client = new DodoPayments();
231
+ const client = new DodoPayments({ bearerToken: 'public', environment: 'test_mode' }); // public endpoints: placeholder token; use 'live_mode' in production
231
232
 
232
233
  interface LicenseInfo {
233
234
  key: string;
234
235
  instanceId: string;
235
236
  activatedAt: string;
237
+ lastValidatedAt?: string; // last time the server confirmed the license (absent on records saved before it was added)
236
238
  }
237
239
 
238
240
  export async function activateLicense(licenseKey: string): Promise<boolean> {
@@ -244,10 +246,12 @@ export async function activateLicense(licenseKey: string): Promise<boolean> {
244
246
  name: deviceName,
245
247
  });
246
248
 
249
+ const now = new Date().toISOString();
247
250
  const licenseInfo: LicenseInfo = {
248
251
  key: licenseKey,
249
252
  instanceId: response.id,
250
- activatedAt: new Date().toISOString(),
253
+ activatedAt: now,
254
+ lastValidatedAt: now,
251
255
  };
252
256
 
253
257
  store.set('license', licenseInfo);
@@ -268,16 +272,37 @@ export async function checkLicense(): Promise<boolean> {
268
272
  try {
269
273
  const response = await client.licenses.validate({
270
274
  license_key: license.key,
275
+ license_key_instance_id: license.instanceId,
271
276
  });
272
277
 
273
- return response.valid;
278
+ if (!response.valid) {
279
+ // An explicit "invalid" from the server (revoked key, deactivated
280
+ // instance) ends the license: drop the cache so a later offline start
281
+ // cannot fall back to the grace period.
282
+ store.delete('license');
283
+ return false;
284
+ }
285
+ store.set('license', { ...license, lastValidatedAt: new Date().toISOString() });
286
+ return true;
274
287
  } catch (error) {
275
- // If offline, trust local license with a grace period
276
- const activatedAt = new Date(license.activatedAt);
277
- const daysSinceActivation = (Date.now() - activatedAt.getTime()) / (1000 * 60 * 60 * 24);
288
+ // Only a connection failure (offline, DNS, timeout) earns the grace period.
289
+ // Any other API error - a revoked key, a deactivated instance - is a real
290
+ // answer from the server, so fail closed.
291
+ if (!(error instanceof DodoPayments.APIConnectionError)) {
292
+ return false;
293
+ }
294
+
295
+ // Trust the cached result only within a grace period measured from the
296
+ // LAST SUCCESSFUL validation, not from activation - otherwise a key revoked
297
+ // yesterday keeps working offline for 30 days after activation, and an old
298
+ // activation gets no grace at all.
299
+ // Records saved before lastValidatedAt existed fall back to activatedAt
300
+ // until their first successful validation.
301
+ const lastValidated = new Date(license.lastValidatedAt ?? license.activatedAt);
302
+ const daysSinceValidation = (Date.now() - lastValidated.getTime()) / (1000 * 60 * 60 * 24);
278
303
 
279
304
  // Allow 30-day offline grace period
280
- return daysSinceActivation < 30;
305
+ return daysSinceValidation < 30;
281
306
  }
282
307
  }
283
308
 
@@ -384,7 +409,7 @@ interface CliStore {
384
409
 
385
410
  // Typing the store keeps `config.get('license')` strongly typed at every call site.
386
411
  const config = new Conf<CliStore>({ projectName: 'your-cli' });
387
- const client = new DodoPayments();
412
+ const client = new DodoPayments({ bearerToken: 'public', environment: 'test_mode' }); // public endpoints: placeholder token; use 'live_mode' in production
388
413
 
389
414
  export async function activate(licenseKey: string): Promise<void> {
390
415
  const machineId = machineIdSync();
@@ -423,6 +448,7 @@ export async function checkLicense(): Promise<boolean> {
423
448
  try {
424
449
  const response = await client.licenses.validate({
425
450
  license_key: license.key,
451
+ license_key_instance_id: license.instanceId,
426
452
  });
427
453
 
428
454
  return response.valid;
@@ -508,9 +534,9 @@ program.parse();
508
534
 
509
535
  ### Handle license key delivery
510
536
 
511
- When a product with licensing enabled is purchased, an `entitlement_grant.delivered` webhook fires with the license key details:
537
+ Dodo generates **and emails** each auto-fulfilled key to the customer (including the entitlement's activation message), and it disables, re-enables, and replaces keys as the subscription changes state. Your webhook only needs to mirror that state for your own records - do not email keys yourself and do not disable keys on cancellation.
512
538
 
513
- Persist a local license-to-subscription association during fulfillment. Cancellation handling must query that association by `subscription_id`; filtering only by customer would also revoke keys for unrelated products or subscriptions.
539
+ When a product with licensing enabled is purchased, an auto-fulfilled key arrives as `entitlement_grant.created` with `status: "Delivered"` and a `license_key` object; no separate `entitlement_grant.delivered` follows. `entitlement_grant.delivered` fires only when a grant moves to `Delivered` later (for example, when you manually fulfill a `Pending` grant, or a revoked grant is restored), so handle both:
514
540
 
515
541
  ```typescript
516
542
  // app/api/webhooks/dodo/route.ts
@@ -520,6 +546,7 @@ import DodoPayments from 'dodopayments';
520
546
  const client = new DodoPayments({
521
547
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
522
548
  webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
549
+ environment: process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode',
523
550
  });
524
551
 
525
552
  export async function POST(req: NextRequest) {
@@ -533,46 +560,19 @@ export async function POST(req: NextRequest) {
533
560
  },
534
561
  });
535
562
 
536
- if (event.type === 'entitlement_grant.delivered') {
537
- const { customer_id, id, license_key } = event.data;
538
-
539
- // Delivered grants for other entitlement types do not include a license key.
540
- if (!license_key) {
541
- return NextResponse.json({ received: true });
542
- }
543
-
544
- // Store in your database
545
- await prisma.license.create({
546
- data: {
547
- externalId: id,
548
- key: license_key.key,
549
- customerId: customer_id,
550
- expiresAt: license_key.expires_at ? new Date(license_key.expires_at) : null,
551
- activationsLimit: license_key.activations_limit,
552
- status: 'active',
553
- },
554
- });
555
-
556
- // Send email with activation instructions
557
- await sendLicenseEmail(customer_id, license_key.key);
563
+ if (
564
+ (event.type === 'entitlement_grant.created' || event.type === 'entitlement_grant.delivered') &&
565
+ event.data.status === 'Delivered' &&
566
+ event.data.license_key // other entitlement types (and Pending manual grants) have no key
567
+ ) {
568
+ // Dodo emails the key to the customer itself, so there is no email to send here.
569
+ await mirrorGrant(event.data, 'active');
558
570
  }
559
571
 
560
- if (event.type === 'subscription.cancelled' || event.type === 'subscription.expired') {
561
- const { subscription_id, cancel_at_next_billing_date } = event.data;
562
-
563
- // End-of-period cancellation keeps access active until the term expires.
564
- if (event.type === 'subscription.cancelled' && cancel_at_next_billing_date) {
565
- return NextResponse.json({ received: true });
566
- }
567
-
568
- // Query your persisted license-to-subscription mapping.
569
- const licenseKeyIds = await getLicenseKeyIdsForSubscription(subscription_id);
570
-
571
- for (const licenseKeyId of licenseKeyIds) {
572
- await client.licenseKeys.update(licenseKeyId, {
573
- disabled: true,
574
- });
575
- }
572
+ if (event.type === 'entitlement_grant.revoked') {
573
+ // Dodo already disabled the key (cancellation, expiry, refund, on_hold,
574
+ // pause, plan change, or manual revoke). Mirror it locally.
575
+ await mirrorGrant(event.data, 'revoked');
576
576
  }
577
577
 
578
578
  return NextResponse.json({ received: true });
@@ -581,15 +581,58 @@ export async function POST(req: NextRequest) {
581
581
  return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
582
582
  }
583
583
  }
584
+
585
+ type Grant = DodoPayments.Entitlements.EntitlementGrant;
586
+
587
+ // Webhooks can arrive out of order and be retried concurrently. Apply a grant
588
+ // only when it is newer than the stored copy (by the grant's own updated_at),
589
+ // using a conditional UPDATE so a stale "delivered" can never overwrite a newer
590
+ // "revoked". If no row exists yet - including a revocation that arrives before
591
+ // the delivery - insert one; the unique externalId keeps concurrent inserts
592
+ // safe, and the loser retries the conditional update so the newest grant wins.
593
+ async function mirrorGrant(grant: Grant, status: 'active' | 'revoked') {
594
+ const grantUpdatedAt = new Date(grant.updated_at);
595
+ const fields = {
596
+ customerId: grant.customer_id,
597
+ status,
598
+ grantUpdatedAt,
599
+ ...(grant.license_key && {
600
+ key: grant.license_key.key,
601
+ expiresAt: grant.license_key.expires_at ? new Date(grant.license_key.expires_at) : null,
602
+ activationsLimit: grant.license_key.activations_limit,
603
+ }),
604
+ };
605
+
606
+ const applyIfNewer = () =>
607
+ prisma.license.updateMany({
608
+ where: { externalId: grant.id, grantUpdatedAt: { lt: grantUpdatedAt } },
609
+ data: fields,
610
+ });
611
+
612
+ const updated = await applyIfNewer();
613
+ if (updated.count > 0) return;
614
+
615
+ // Either no row yet, or the stored copy is already as new or newer.
616
+ const inserted = await prisma.license.createMany({
617
+ data: [{ externalId: grant.id, ...fields }],
618
+ skipDuplicates: true,
619
+ });
620
+ if (inserted.count === 0) {
621
+ // A concurrent webhook inserted the row first. It may hold an OLDER grant,
622
+ // so re-run the conditional update; it is a no-op if the row is newer.
623
+ await applyIfNewer();
624
+ }
625
+ }
584
626
  ```
585
627
 
586
628
  **Webhook events:**
587
629
 
588
- - `entitlement_grant.delivered` — license key issued (current, recommended)
589
- - `entitlement_grant.revoked` — license key revoked
630
+ - `entitlement_grant.created` — grant created; `status: "Delivered"` with a `license_key` for auto-fulfilled keys (the primary issuance event), `status: "Pending"` with no key for manual fulfillment
631
+ - `entitlement_grant.delivered` — an existing grant moved to `Delivered` (manual fulfillment, or a revoked grant restored); not sent for auto-fulfilled keys
632
+ - `entitlement_grant.revoked` — license key revoked (Dodo already disabled it; mirror the state, inspect `revocation_reason`)
590
633
  - `license_key.created` — legacy event (still fires, but use `entitlement_grant.*` for new integrations)
591
- - `subscription.cancelled` — disable only this subscription's keys for immediate cancellation
592
- - `subscription.expired` — disable this subscription's keys when its term ends
634
+
635
+ Subscription `cancelled`/`expired`/`on_hold`/`paused` events need no license action from you: Dodo disables the keys itself, and `licenses.validate()` reflects it immediately.
593
636
 
594
637
  ---
595
638
 
@@ -675,6 +718,7 @@ store.set('license', {
675
718
  key,
676
719
  instanceId: response.id,
677
720
  activatedAt: new Date().toISOString(),
721
+ lastValidatedAt: new Date().toISOString(),
678
722
  });
679
723
  ```
680
724
 
@@ -712,5 +756,6 @@ try {
712
756
  - [Validate License API](https://docs.dodopayments.com/api-reference/licenses/validate-license)
713
757
  - [Deactivate License API](https://docs.dodopayments.com/api-reference/licenses/deactivate-license)
714
758
  - [License Key Instances API](https://docs.dodopayments.com/api-reference/licenses/get-license-key-instances)
759
+ - [List Customer Grants API](https://docs.dodopayments.com/api-reference/entitlements/list-customer-grants)
715
760
  - [Entitlement Grant Webhooks](https://docs.dodopayments.com/developer-resources/webhooks/intents/entitlement-grant)
716
761
  - [Webhook Verification](https://docs.dodopayments.com/developer-resources/webhooks/verification)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: localized-pricing
3
- description: Guide for implementing localized pricing, adaptive currency, and purchasing power parity with Dodo Payments
3
+ description: Dodo Payments multi-currency pricing, covering fixed localized prices per country or currency, Adaptive Currency live FX conversion at checkout, and purchasing power parity (PPP) percentages. Use when pricing in INR, EUR, or other local currencies, offering regional or PPP discounts, or resolving billing country and currency fallback.
4
4
  ---
5
5
 
6
6
  # Localized Pricing, Adaptive Currency, and PPP
@@ -23,9 +23,9 @@ This skill covers three distinct mechanisms for pricing in multiple countries an
23
23
  |---|---|---|---|
24
24
  | **Localized Pricing** | Developer sets fixed prices per country/currency | You (via API) | Only when you update them |
25
25
  | **Adaptive Currency** | Live FX conversion at checkout time | Dodo (live rates) | Every transaction, based on current rates |
26
- | **PPP Discounts** | Third-party tools generate discount codes for low-income regions | ParityDeals, Evendeals, or similar | Per tool's schedule |
26
+ | **Purchasing Power Parity (PPP)** | Charges a per-country percentage of the base price | You (business-wide country percentages in the dashboard, per-product opt-in) | Only when you change the percentages |
27
27
 
28
- **Key distinction:** Localized prices are static. Adaptive currency is dynamic. PPP is a discount mechanism, not a pricing mode.
28
+ **Key distinction:** Localized prices are static. Adaptive currency is dynamic. PPP is a native per-country percentage applied to the base price before Adaptive Currency converts it.
29
29
 
30
30
  ### Localized Pricing
31
31
 
@@ -41,9 +41,23 @@ Use adaptive currency when you want to offer checkout in any currency without ma
41
41
 
42
42
  ### PPP (Purchasing Power Parity)
43
43
 
44
- Third-party services like ParityDeals or Evendeals generate discount codes for customers in low-income countries. These are not a native Dodo API feature. You integrate them by accepting the discount codes they generate and passing them to Dodo at checkout.
44
+ PPP is native ([docs](https://docs.dodopayments.com/features/purchasing-power-parity)). Dodo keeps one business-wide percentage per country (30 to 100, managed in the dashboard only), and the customer pays that share of your list price based on their billing country. Each product opts in with `price.purchasing_power_parity: true` (default `false`).
45
45
 
46
- Use PPP when you want to offer affordability-based discounts without building your own detection logic.
46
+ PPP requires Adaptive Currency: enable it under **Settings → Business** first, or product opt-in is rejected. A matching localized price always wins over PPP, and discount codes apply to the PPP-reduced price.
47
+
48
+ ```typescript
49
+ await client.products.create({
50
+ name: 'Starter Plan',
51
+ tax_category: 'saas',
52
+ price: {
53
+ type: 'one_time_price',
54
+ currency: 'USD',
55
+ price: 2000, // $20.00; a customer in India at 30% pays $6.00
56
+ discount: 0,
57
+ purchasing_power_parity: true,
58
+ },
59
+ });
60
+ ```
47
61
 
48
62
  ## Product pricing mode
49
63
 
@@ -125,24 +139,40 @@ Archived prices are soft-deleted. They no longer apply to new checkouts but rema
125
139
 
126
140
  ## Adaptive Currency at checkout
127
141
 
128
- Enable adaptive currency by passing `billing_currency` to a checkout session. Dodo converts the base product price using live rates.
142
+ Enable Adaptive Currency in the dashboard under **Settings → Business** ([docs](https://docs.dodopayments.com/features/adaptive-currency)). Dodo then converts the base product price at live rates. Passing `billing_currency` and `billing_address.country` on a checkout session only pins the currency you charge in; if you omit them, Adaptive Currency picks the currency and country from the customer's IP address.
129
143
 
130
144
  ```typescript
131
- const session = await client.checkoutSessions.create({
132
- product_cart: [
133
- {
134
- product_id: 'pdt_premium_plan',
135
- quantity: 1,
136
- },
137
- ],
138
- billing_currency: 'AED', // Customer sees price in AED
139
- return_url: 'https://example.com/return',
140
- });
145
+ // Server route (e.g. POST /api/checkout): create the session and return its URL
146
+ export async function POST() {
147
+ const session = await client.checkoutSessions.create({
148
+ product_cart: [
149
+ {
150
+ product_id: 'pdt_premium_plan',
151
+ quantity: 1,
152
+ },
153
+ ],
154
+ billing_currency: 'AED', // Pin the charge currency (requires Adaptive Currency)
155
+ billing_address: { country: 'AE' },
156
+ return_url: 'https://example.com/return',
157
+ });
158
+
159
+ return Response.json({ checkout_url: session.checkout_url });
160
+ }
161
+ ```
162
+
163
+ Never call the SDK or expose the API key in browser code. The browser only receives the URL and redirects:
141
164
 
142
- // Redirect customer to checkout
143
- window.location.href = session.checkout_url;
165
+ ```typescript
166
+ // Browser
167
+ const res = await fetch('/api/checkout', { method: 'POST' });
168
+ const { checkout_url } = (await res.json()) as { checkout_url: string };
169
+ window.location.href = checkout_url;
144
170
  ```
145
171
 
172
+ **Fees:** Adaptive Currency adds a tiered conversion fee (4% under $500, 3% from $500 to $1,500, 2% above $1,500) on top of the displayed price by default. Turn on **Fees Inclusive** in **Settings → Business** to absorb the fee from your settlement instead. When a localized price matches, the transaction is always treated as fees-inclusive.
173
+
174
+ **Charm Pricing:** With Adaptive Currency on, [Charm Pricing](https://docs.dodopayments.com/features/charm-pricing) can round the converted price to a clean ending such as 49.99. It requires Adaptive Currency.
175
+
146
176
  Adaptive currency is also available on subscription creation and plan changes. Check the API reference for `billing_currency` support on the specific endpoint you're using.
147
177
 
148
178
  ## Billing country and currency resolution
@@ -195,7 +225,7 @@ A customer in India using a USD card will match `by_currency: USD` but not `by_c
195
225
 
196
226
  ### Mistake 3: Forgetting a fallback price
197
227
 
198
- If you set `pricing_mode: by_country` but don't create a localized price for a customer's country, Dodo falls back to the base price in the base currency. This is usually not what you want.
228
+ If you set `pricing_mode: by_country` but don't create a localized price for a customer's country, Dodo falls back to the base price: charged directly if the customer is already in your base currency, or converted through Adaptive Currency when that is enabled ([docs](https://docs.dodopayments.com/features/localized-pricing)). Without Adaptive Currency, that is the base price in the base currency, which is usually not what you want.
199
229
 
200
230
  Always create localized prices for your target markets before enabling the mode.
201
231
 
@@ -205,17 +235,9 @@ You can use both on the same product, but they serve different purposes. Localiz
205
235
 
206
236
  If you set a localized price for India and also enable adaptive currency, the localized price takes precedence for customers in India.
207
237
 
208
- ### Mistake 5: Expecting PPP to be a native Dodo API
209
-
210
- PPP discounts are generated by third-party services like ParityDeals or Evendeals. Dodo does not have a built-in PPP API. You integrate PPP by accepting discount codes from these services and passing them to Dodo at checkout.
238
+ ### Mistake 5: Reaching for a third-party tool for PPP
211
239
 
212
- ```typescript
213
- const session = await client.checkoutSessions.create({
214
- product_cart: [{ product_id: 'pdt_premium_plan', quantity: 1 }],
215
- discount_codes: ['PPP_DISCOUNT_CODE_FROM_PARITY_DEALS'],
216
- return_url: 'https://example.com/return',
217
- });
218
- ```
240
+ PPP is native: opt products in with `price.purchasing_power_parity: true` and set country percentages in the dashboard (Adaptive Currency must be on). Third-party tools like ParityDeals are only an alternative if you want their site banners; their codes are passed as ordinary `discount_codes` at checkout.
219
241
 
220
242
  ### Mistake 6: Not handling missing localized prices in production
221
243