@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.
Files changed (47) hide show
  1. package/README.md +1 -0
  2. package/opencode-plugin/README.md +1 -1
  3. package/package.json +1 -1
  4. package/skills/better-auth-integration/SKILL.md +12 -3
  5. package/skills/billing-sdk/SKILL.md +15 -167
  6. package/skills/billing-sdk/references/pricing-page.md +197 -0
  7. package/skills/checkout-integration/SKILL.md +31 -460
  8. package/skills/checkout-integration/references/overlay-checkout.md +148 -0
  9. package/skills/checkout-integration/references/server-examples.md +317 -0
  10. package/skills/credit-based-billing/SKILL.md +11 -158
  11. package/skills/credit-based-billing/references/ai-token-example.md +108 -0
  12. package/skills/credit-based-billing/references/credit-webhooks.md +51 -0
  13. package/skills/customer-management/SKILL.md +25 -17
  14. package/skills/discounts-and-promotions/SKILL.md +20 -17
  15. package/skills/dodo-best-practices/SKILL.md +18 -5
  16. package/skills/dodo-mcp-usage/SKILL.md +152 -0
  17. package/skills/framework-adapters/SKILL.md +64 -421
  18. package/skills/framework-adapters/references/astro.md +52 -0
  19. package/skills/framework-adapters/references/bun.md +45 -0
  20. package/skills/framework-adapters/references/convex.md +91 -0
  21. package/skills/framework-adapters/references/express.md +72 -0
  22. package/skills/framework-adapters/references/fastify.md +54 -0
  23. package/skills/framework-adapters/references/hono.md +63 -0
  24. package/skills/framework-adapters/references/nuxt.md +53 -0
  25. package/skills/framework-adapters/references/remix.md +65 -0
  26. package/skills/framework-adapters/references/sveltekit.md +49 -0
  27. package/skills/framework-adapters/references/tanstack-start.md +31 -0
  28. package/skills/license-keys/SKILL.md +117 -367
  29. package/skills/license-keys/references/cli-tool.md +143 -0
  30. package/skills/license-keys/references/desktop-app.md +170 -0
  31. package/skills/localized-pricing/SKILL.md +51 -29
  32. package/skills/mobile-checkout/SKILL.md +72 -310
  33. package/skills/mobile-checkout/references/android.md +69 -0
  34. package/skills/mobile-checkout/references/flutter.md +97 -0
  35. package/skills/mobile-checkout/references/ios.md +66 -0
  36. package/skills/mobile-checkout/references/react-native.md +100 -0
  37. package/skills/product-catalog-management/SKILL.md +39 -16
  38. package/skills/refunds-and-disputes/SKILL.md +91 -71
  39. package/skills/subscription-integration/SKILL.md +43 -64
  40. package/skills/subscription-integration/references/webhook-events.md +82 -0
  41. package/skills/testing-and-go-live/SKILL.md +22 -15
  42. package/skills/usage-based-billing/SKILL.md +16 -122
  43. package/skills/usage-based-billing/references/instrumentation.md +119 -0
  44. package/skills/webhook-integration/SKILL.md +31 -263
  45. package/skills/webhook-integration/references/common-mistakes.md +146 -0
  46. package/skills/webhook-integration/references/event-catalog.md +103 -0
  47. package/skills/webhook-integration/references/local-testing.md +42 -0
@@ -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
166
+ To list every key issued for one License Key entitlement, use `client.entitlements.grants.list('ent_...', { status: 'Delivered' })`.
152
167
 
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
- ```
165
-
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({
@@ -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
- ### Electron app example
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
- ```tsx
309
- // components/LicenseActivation.tsx
310
- import { useState } from 'react';
224
+ Covers:
311
225
 
312
- interface Props {
313
- onActivated: () => void;
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
- ### Node.js CLI example
231
+ Full guide: [references/cli-tool.md](references/cli-tool.md).
368
232
 
369
- ```typescript
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
- interface StoredLicense {
376
- key: string;
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
- When a product with licensing enabled is purchased, an `entitlement_grant.delivered` webhook fires with the license key details:
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
- 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.
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 (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);
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 === '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
- }
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.delivered` — license key issued (current, recommended)
589
- - `entitlement_grant.revoked` — license key revoked
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
- - `subscription.cancelled` — disable only this subscription's keys for immediate cancellation
592
- - `subscription.expired` — disable this subscription's keys when its term ends
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)