@dodopayments/opencode-plugin 0.6.0 → 0.6.2
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/README.md +1 -0
- package/opencode-plugin/README.md +1 -1
- package/package.json +1 -1
- package/skills/better-auth-integration/SKILL.md +12 -3
- package/skills/billing-sdk/SKILL.md +15 -167
- package/skills/billing-sdk/references/pricing-page.md +197 -0
- package/skills/checkout-integration/SKILL.md +31 -460
- package/skills/checkout-integration/references/overlay-checkout.md +148 -0
- package/skills/checkout-integration/references/server-examples.md +317 -0
- package/skills/credit-based-billing/SKILL.md +11 -158
- package/skills/credit-based-billing/references/ai-token-example.md +108 -0
- package/skills/credit-based-billing/references/credit-webhooks.md +51 -0
- 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 +64 -421
- package/skills/framework-adapters/references/astro.md +52 -0
- package/skills/framework-adapters/references/bun.md +45 -0
- package/skills/framework-adapters/references/convex.md +91 -0
- package/skills/framework-adapters/references/express.md +72 -0
- package/skills/framework-adapters/references/fastify.md +54 -0
- package/skills/framework-adapters/references/hono.md +63 -0
- package/skills/framework-adapters/references/nuxt.md +53 -0
- package/skills/framework-adapters/references/remix.md +65 -0
- package/skills/framework-adapters/references/sveltekit.md +49 -0
- package/skills/framework-adapters/references/tanstack-start.md +31 -0
- package/skills/license-keys/SKILL.md +117 -367
- package/skills/license-keys/references/cli-tool.md +143 -0
- package/skills/license-keys/references/desktop-app.md +170 -0
- package/skills/localized-pricing/SKILL.md +51 -29
- package/skills/mobile-checkout/SKILL.md +72 -310
- package/skills/mobile-checkout/references/android.md +69 -0
- package/skills/mobile-checkout/references/flutter.md +97 -0
- package/skills/mobile-checkout/references/ios.md +66 -0
- package/skills/mobile-checkout/references/react-native.md +100 -0
- package/skills/product-catalog-management/SKILL.md +39 -16
- package/skills/refunds-and-disputes/SKILL.md +91 -71
- package/skills/subscription-integration/SKILL.md +43 -64
- package/skills/subscription-integration/references/webhook-events.md +82 -0
- package/skills/testing-and-go-live/SKILL.md +22 -15
- package/skills/usage-based-billing/SKILL.md +16 -122
- package/skills/usage-based-billing/references/instrumentation.md +119 -0
- package/skills/webhook-integration/SKILL.md +31 -263
- package/skills/webhook-integration/references/common-mistakes.md +146 -0
- package/skills/webhook-integration/references/event-catalog.md +103 -0
- package/skills/webhook-integration/references/local-testing.md +42 -0
|
@@ -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
|
-
|
|
166
|
+
To list every key issued for one License Key entitlement, use `client.entitlements.grants.list('ent_...', { status: 'Delivered' })`.
|
|
152
167
|
|
|
153
|
-
|
|
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
|
-
```
|
|
165
|
-
|
|
166
|
-
### Update a license key
|
|
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({
|
|
@@ -218,299 +219,29 @@ Optional query fields: `page_size` (default 10, max 100), `page_number` (default
|
|
|
218
219
|
|
|
219
220
|
## Desktop App Integration
|
|
220
221
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
```typescript
|
|
224
|
-
// main/license.ts
|
|
225
|
-
import Store from 'electron-store';
|
|
226
|
-
import DodoPayments from 'dodopayments';
|
|
227
|
-
import os from 'os';
|
|
228
|
-
|
|
229
|
-
const store = new Store();
|
|
230
|
-
const client = new DodoPayments();
|
|
231
|
-
|
|
232
|
-
interface LicenseInfo {
|
|
233
|
-
key: string;
|
|
234
|
-
instanceId: string;
|
|
235
|
-
activatedAt: string;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
export async function activateLicense(licenseKey: string): Promise<boolean> {
|
|
239
|
-
try {
|
|
240
|
-
const deviceName = `${os.hostname()} - ${os.platform()}`;
|
|
241
|
-
|
|
242
|
-
const response = await client.licenses.activate({
|
|
243
|
-
license_key: licenseKey,
|
|
244
|
-
name: deviceName,
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
const licenseInfo: LicenseInfo = {
|
|
248
|
-
key: licenseKey,
|
|
249
|
-
instanceId: response.id,
|
|
250
|
-
activatedAt: new Date().toISOString(),
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
store.set('license', licenseInfo);
|
|
254
|
-
return true;
|
|
255
|
-
} catch (error) {
|
|
256
|
-
console.error('Activation failed:', error);
|
|
257
|
-
return false;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export async function checkLicense(): Promise<boolean> {
|
|
262
|
-
const license = store.get('license') as LicenseInfo | undefined;
|
|
263
|
-
|
|
264
|
-
if (!license) {
|
|
265
|
-
return false;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
try {
|
|
269
|
-
const response = await client.licenses.validate({
|
|
270
|
-
license_key: license.key,
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
return response.valid;
|
|
274
|
-
} 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);
|
|
278
|
-
|
|
279
|
-
// Allow 30-day offline grace period
|
|
280
|
-
return daysSinceActivation < 30;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
export async function deactivateLicense(): Promise<boolean> {
|
|
285
|
-
const license = store.get('license') as LicenseInfo | undefined;
|
|
286
|
-
|
|
287
|
-
if (!license) {
|
|
288
|
-
return true;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
try {
|
|
292
|
-
await client.licenses.deactivate({
|
|
293
|
-
license_key: license.key,
|
|
294
|
-
license_key_instance_id: license.instanceId,
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
store.delete('license');
|
|
298
|
-
return true;
|
|
299
|
-
} catch (error) {
|
|
300
|
-
console.error('Deactivation failed:', error);
|
|
301
|
-
return false;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
### React component for license input
|
|
222
|
+
Full guide: [references/desktop-app.md](references/desktop-app.md).
|
|
307
223
|
|
|
308
|
-
|
|
309
|
-
// components/LicenseActivation.tsx
|
|
310
|
-
import { useState } from 'react';
|
|
224
|
+
Covers:
|
|
311
225
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
export function LicenseActivation({ onActivated }: Props) {
|
|
317
|
-
const [licenseKey, setLicenseKey] = useState('');
|
|
318
|
-
const [loading, setLoading] = useState(false);
|
|
319
|
-
const [error, setError] = useState<string | null>(null);
|
|
320
|
-
|
|
321
|
-
const handleActivate = async () => {
|
|
322
|
-
setLoading(true);
|
|
323
|
-
setError(null);
|
|
324
|
-
|
|
325
|
-
try {
|
|
326
|
-
const success = await window.electronAPI.activateLicense(licenseKey);
|
|
327
|
-
|
|
328
|
-
if (success) {
|
|
329
|
-
onActivated();
|
|
330
|
-
} else {
|
|
331
|
-
setError('Invalid license key. Please check and try again.');
|
|
332
|
-
}
|
|
333
|
-
} catch (err) {
|
|
334
|
-
setError('Activation failed. Please try again.');
|
|
335
|
-
} finally {
|
|
336
|
-
setLoading(false);
|
|
337
|
-
}
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
return (
|
|
341
|
-
<div className="license-form">
|
|
342
|
-
<h2>Activate Your License</h2>
|
|
343
|
-
<p>Enter your license key to unlock all features.</p>
|
|
344
|
-
|
|
345
|
-
<input
|
|
346
|
-
type="text"
|
|
347
|
-
value={licenseKey}
|
|
348
|
-
onChange={(e) => setLicenseKey(e.target.value)}
|
|
349
|
-
placeholder="XXXX-XXXX-XXXX-XXXX"
|
|
350
|
-
disabled={loading}
|
|
351
|
-
/>
|
|
352
|
-
|
|
353
|
-
{error && <p className="error">{error}</p>}
|
|
354
|
-
|
|
355
|
-
<button onClick={handleActivate} disabled={loading || !licenseKey}>
|
|
356
|
-
{loading ? 'Activating...' : 'Activate License'}
|
|
357
|
-
</button>
|
|
358
|
-
</div>
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
---
|
|
226
|
+
- Electron app example
|
|
227
|
+
- React component for license input
|
|
364
228
|
|
|
365
229
|
## CLI Tool Integration
|
|
366
230
|
|
|
367
|
-
|
|
231
|
+
Full guide: [references/cli-tool.md](references/cli-tool.md).
|
|
368
232
|
|
|
369
|
-
|
|
370
|
-
// src/license.ts
|
|
371
|
-
import Conf from 'conf';
|
|
372
|
-
import DodoPayments from 'dodopayments';
|
|
373
|
-
import { machineIdSync } from 'node-machine-id';
|
|
233
|
+
Covers:
|
|
374
234
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
instanceId: string;
|
|
378
|
-
machineId: string;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
interface CliStore {
|
|
382
|
-
license?: StoredLicense;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Typing the store keeps `config.get('license')` strongly typed at every call site.
|
|
386
|
-
const config = new Conf<CliStore>({ projectName: 'your-cli' });
|
|
387
|
-
const client = new DodoPayments();
|
|
388
|
-
|
|
389
|
-
export async function activate(licenseKey: string): Promise<void> {
|
|
390
|
-
const machineId = machineIdSync();
|
|
391
|
-
const deviceName = `CLI - ${process.platform} - ${machineId.substring(0, 8)}`;
|
|
392
|
-
|
|
393
|
-
try {
|
|
394
|
-
const response = await client.licenses.activate({
|
|
395
|
-
license_key: licenseKey,
|
|
396
|
-
name: deviceName,
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
config.set('license', {
|
|
400
|
-
key: licenseKey,
|
|
401
|
-
instanceId: response.id,
|
|
402
|
-
machineId,
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
console.log('License activated successfully!');
|
|
406
|
-
} catch (error: any) {
|
|
407
|
-
if (error.status === 422) {
|
|
408
|
-
console.error('Activation limit reached. Deactivate another device first.');
|
|
409
|
-
} else {
|
|
410
|
-
console.error('Activation failed:', error.message);
|
|
411
|
-
}
|
|
412
|
-
process.exit(1);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
export async function checkLicense(): Promise<boolean> {
|
|
417
|
-
const license = config.get('license');
|
|
418
|
-
|
|
419
|
-
if (!license) {
|
|
420
|
-
return false;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
try {
|
|
424
|
-
const response = await client.licenses.validate({
|
|
425
|
-
license_key: license.key,
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
return response.valid;
|
|
429
|
-
} catch {
|
|
430
|
-
return false;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
export async function deactivate(): Promise<void> {
|
|
435
|
-
const license = config.get('license');
|
|
436
|
-
|
|
437
|
-
if (!license) {
|
|
438
|
-
console.log('No active license found.');
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
try {
|
|
443
|
-
await client.licenses.deactivate({
|
|
444
|
-
license_key: license.key,
|
|
445
|
-
license_key_instance_id: license.instanceId,
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
config.delete('license');
|
|
449
|
-
console.log('License deactivated.');
|
|
450
|
-
} catch (error: any) {
|
|
451
|
-
console.error('Deactivation failed:', error.message);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
export function requireLicense() {
|
|
456
|
-
return async () => {
|
|
457
|
-
const valid = await checkLicense();
|
|
458
|
-
if (!valid) {
|
|
459
|
-
console.error('This command requires a valid license.');
|
|
460
|
-
console.error('Run: your-cli activate <license-key>');
|
|
461
|
-
process.exit(1);
|
|
462
|
-
}
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
```
|
|
466
|
-
|
|
467
|
-
### CLI commands
|
|
468
|
-
|
|
469
|
-
```typescript
|
|
470
|
-
// src/cli.ts
|
|
471
|
-
import { Command } from 'commander';
|
|
472
|
-
import { activate, deactivate, checkLicense, requireLicense } from './license';
|
|
473
|
-
|
|
474
|
-
const program = new Command();
|
|
475
|
-
|
|
476
|
-
program
|
|
477
|
-
.command('activate <license-key>')
|
|
478
|
-
.description('Activate your license')
|
|
479
|
-
.action(activate);
|
|
480
|
-
|
|
481
|
-
program
|
|
482
|
-
.command('deactivate')
|
|
483
|
-
.description('Deactivate license on this device')
|
|
484
|
-
.action(deactivate);
|
|
485
|
-
|
|
486
|
-
program
|
|
487
|
-
.command('status')
|
|
488
|
-
.description('Check license status')
|
|
489
|
-
.action(async () => {
|
|
490
|
-
const valid = await checkLicense();
|
|
491
|
-
console.log(valid ? 'License: Active' : 'License: Not activated');
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
program
|
|
495
|
-
.command('generate')
|
|
496
|
-
.description('Generate something (requires license)')
|
|
497
|
-
.hook('preAction', requireLicense())
|
|
498
|
-
.action(async () => {
|
|
499
|
-
// Premium feature
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
program.parse();
|
|
503
|
-
```
|
|
504
|
-
|
|
505
|
-
---
|
|
235
|
+
- Node.js CLI example
|
|
236
|
+
- CLI commands
|
|
506
237
|
|
|
507
238
|
## Webhook Integration
|
|
508
239
|
|
|
509
240
|
### Handle license key delivery
|
|
510
241
|
|
|
511
|
-
|
|
242
|
+
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
243
|
|
|
513
|
-
|
|
244
|
+
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
245
|
|
|
515
246
|
```typescript
|
|
516
247
|
// app/api/webhooks/dodo/route.ts
|
|
@@ -520,6 +251,7 @@ import DodoPayments from 'dodopayments';
|
|
|
520
251
|
const client = new DodoPayments({
|
|
521
252
|
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
|
|
522
253
|
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
|
|
254
|
+
environment: process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode',
|
|
523
255
|
});
|
|
524
256
|
|
|
525
257
|
export async function POST(req: NextRequest) {
|
|
@@ -533,46 +265,19 @@ export async function POST(req: NextRequest) {
|
|
|
533
265
|
},
|
|
534
266
|
});
|
|
535
267
|
|
|
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);
|
|
268
|
+
if (
|
|
269
|
+
(event.type === 'entitlement_grant.created' || event.type === 'entitlement_grant.delivered') &&
|
|
270
|
+
event.data.status === 'Delivered' &&
|
|
271
|
+
event.data.license_key // other entitlement types (and Pending manual grants) have no key
|
|
272
|
+
) {
|
|
273
|
+
// Dodo emails the key to the customer itself, so there is no email to send here.
|
|
274
|
+
await mirrorGrant(event.data, 'active');
|
|
558
275
|
}
|
|
559
276
|
|
|
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
|
-
}
|
|
277
|
+
if (event.type === 'entitlement_grant.revoked') {
|
|
278
|
+
// Dodo already disabled the key (cancellation, expiry, refund, on_hold,
|
|
279
|
+
// pause, plan change, or manual revoke). Mirror it locally.
|
|
280
|
+
await mirrorGrant(event.data, 'revoked');
|
|
576
281
|
}
|
|
577
282
|
|
|
578
283
|
return NextResponse.json({ received: true });
|
|
@@ -581,15 +286,58 @@ export async function POST(req: NextRequest) {
|
|
|
581
286
|
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
|
|
582
287
|
}
|
|
583
288
|
}
|
|
289
|
+
|
|
290
|
+
type Grant = DodoPayments.Entitlements.EntitlementGrant;
|
|
291
|
+
|
|
292
|
+
// Webhooks can arrive out of order and be retried concurrently. Apply a grant
|
|
293
|
+
// only when it is newer than the stored copy (by the grant's own updated_at),
|
|
294
|
+
// using a conditional UPDATE so a stale "delivered" can never overwrite a newer
|
|
295
|
+
// "revoked". If no row exists yet - including a revocation that arrives before
|
|
296
|
+
// the delivery - insert one; the unique externalId keeps concurrent inserts
|
|
297
|
+
// safe, and the loser retries the conditional update so the newest grant wins.
|
|
298
|
+
async function mirrorGrant(grant: Grant, status: 'active' | 'revoked') {
|
|
299
|
+
const grantUpdatedAt = new Date(grant.updated_at);
|
|
300
|
+
const fields = {
|
|
301
|
+
customerId: grant.customer_id,
|
|
302
|
+
status,
|
|
303
|
+
grantUpdatedAt,
|
|
304
|
+
...(grant.license_key && {
|
|
305
|
+
key: grant.license_key.key,
|
|
306
|
+
expiresAt: grant.license_key.expires_at ? new Date(grant.license_key.expires_at) : null,
|
|
307
|
+
activationsLimit: grant.license_key.activations_limit,
|
|
308
|
+
}),
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const applyIfNewer = () =>
|
|
312
|
+
prisma.license.updateMany({
|
|
313
|
+
where: { externalId: grant.id, grantUpdatedAt: { lt: grantUpdatedAt } },
|
|
314
|
+
data: fields,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const updated = await applyIfNewer();
|
|
318
|
+
if (updated.count > 0) return;
|
|
319
|
+
|
|
320
|
+
// Either no row yet, or the stored copy is already as new or newer.
|
|
321
|
+
const inserted = await prisma.license.createMany({
|
|
322
|
+
data: [{ externalId: grant.id, ...fields }],
|
|
323
|
+
skipDuplicates: true,
|
|
324
|
+
});
|
|
325
|
+
if (inserted.count === 0) {
|
|
326
|
+
// A concurrent webhook inserted the row first. It may hold an OLDER grant,
|
|
327
|
+
// so re-run the conditional update; it is a no-op if the row is newer.
|
|
328
|
+
await applyIfNewer();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
584
331
|
```
|
|
585
332
|
|
|
586
333
|
**Webhook events:**
|
|
587
334
|
|
|
588
|
-
- `entitlement_grant.
|
|
589
|
-
- `entitlement_grant.
|
|
335
|
+
- `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
|
|
336
|
+
- `entitlement_grant.delivered` — an existing grant moved to `Delivered` (manual fulfillment, or a revoked grant restored); not sent for auto-fulfilled keys
|
|
337
|
+
- `entitlement_grant.revoked` — license key revoked (Dodo already disabled it; mirror the state, inspect `revocation_reason`)
|
|
590
338
|
- `license_key.created` — legacy event (still fires, but use `entitlement_grant.*` for new integrations)
|
|
591
|
-
|
|
592
|
-
|
|
339
|
+
|
|
340
|
+
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
341
|
|
|
594
342
|
---
|
|
595
343
|
|
|
@@ -675,6 +423,7 @@ store.set('license', {
|
|
|
675
423
|
key,
|
|
676
424
|
instanceId: response.id,
|
|
677
425
|
activatedAt: new Date().toISOString(),
|
|
426
|
+
lastValidatedAt: new Date().toISOString(),
|
|
678
427
|
});
|
|
679
428
|
```
|
|
680
429
|
|
|
@@ -712,5 +461,6 @@ try {
|
|
|
712
461
|
- [Validate License API](https://docs.dodopayments.com/api-reference/licenses/validate-license)
|
|
713
462
|
- [Deactivate License API](https://docs.dodopayments.com/api-reference/licenses/deactivate-license)
|
|
714
463
|
- [License Key Instances API](https://docs.dodopayments.com/api-reference/licenses/get-license-key-instances)
|
|
464
|
+
- [List Customer Grants API](https://docs.dodopayments.com/api-reference/entitlements/list-customer-grants)
|
|
715
465
|
- [Entitlement Grant Webhooks](https://docs.dodopayments.com/developer-resources/webhooks/intents/entitlement-grant)
|
|
716
466
|
- [Webhook Verification](https://docs.dodopayments.com/developer-resources/webhooks/verification)
|