@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: license-keys
|
|
3
|
-
description:
|
|
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.
|
|
30
|
-
- `
|
|
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
|
|
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
|
-
//
|
|
52
|
-
|
|
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
|
-
|
|
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 =
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
168
|
+
### Revoke a license key
|
|
167
169
|
|
|
168
170
|
```typescript
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
###
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
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 === '
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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.
|
|
589
|
-
- `entitlement_grant.
|
|
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
|
-
|
|
592
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|