@dodopayments/opencode-plugin 0.6.1 → 0.6.3

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 (37) hide show
  1. package/package.json +1 -1
  2. package/skills/billing-sdk/SKILL.md +8 -193
  3. package/skills/billing-sdk/references/pricing-page.md +197 -0
  4. package/skills/checkout-integration/SKILL.md +14 -449
  5. package/skills/checkout-integration/references/overlay-checkout.md +148 -0
  6. package/skills/checkout-integration/references/server-examples.md +317 -0
  7. package/skills/credit-based-billing/SKILL.md +5 -151
  8. package/skills/credit-based-billing/references/ai-token-example.md +108 -0
  9. package/skills/credit-based-billing/references/credit-webhooks.md +51 -0
  10. package/skills/framework-adapters/SKILL.md +38 -527
  11. package/skills/framework-adapters/references/astro.md +52 -0
  12. package/skills/framework-adapters/references/bun.md +45 -0
  13. package/skills/framework-adapters/references/convex.md +91 -0
  14. package/skills/framework-adapters/references/express.md +72 -0
  15. package/skills/framework-adapters/references/fastify.md +54 -0
  16. package/skills/framework-adapters/references/hono.md +63 -0
  17. package/skills/framework-adapters/references/nuxt.md +53 -0
  18. package/skills/framework-adapters/references/remix.md +65 -0
  19. package/skills/framework-adapters/references/sveltekit.md +49 -0
  20. package/skills/framework-adapters/references/tanstack-start.md +31 -0
  21. package/skills/license-keys/SKILL.md +8 -303
  22. package/skills/license-keys/references/cli-tool.md +143 -0
  23. package/skills/license-keys/references/desktop-app.md +170 -0
  24. package/skills/mobile-checkout/SKILL.md +25 -313
  25. package/skills/mobile-checkout/references/android.md +69 -0
  26. package/skills/mobile-checkout/references/flutter.md +97 -0
  27. package/skills/mobile-checkout/references/ios.md +66 -0
  28. package/skills/mobile-checkout/references/react-native.md +100 -0
  29. package/skills/refunds-and-disputes/SKILL.md +1 -1
  30. package/skills/subscription-integration/SKILL.md +3 -77
  31. package/skills/subscription-integration/references/webhook-events.md +82 -0
  32. package/skills/usage-based-billing/SKILL.md +5 -114
  33. package/skills/usage-based-billing/references/instrumentation.md +119 -0
  34. package/skills/webhook-integration/SKILL.md +28 -277
  35. package/skills/webhook-integration/references/common-mistakes.md +146 -0
  36. package/skills/webhook-integration/references/event-catalog.md +103 -0
  37. package/skills/webhook-integration/references/local-testing.md +42 -0
@@ -272,106 +272,19 @@ Signature verification requires the exact bytes Dodo sent. If you parse the JSON
272
272
 
273
273
  ## Webhook Event Catalog
274
274
 
275
- Dodo delivers 48 event types across ten families (payment, subscription, refund, dispute, license key, entitlement grant, credit, abandoned checkout, dunning, payout). Subscribe to only the events you need.
276
-
277
- ### Payment events
278
-
279
- | Event | When it fires | What to do |
280
- |-------|---|---|
281
- | `payment.succeeded` | Payment completed successfully | Grant access, send confirmation, update order status |
282
- | `payment.failed` | Payment attempt failed | Notify customer, suggest retry or alternative payment method |
283
- | `payment.processing` | Payment is still being processed | Acknowledge receipt, wait for `payment.succeeded` or `payment.failed` |
284
- | `payment.cancelled` | Payment was cancelled before completion | Update order status, notify customer if applicable |
285
-
286
- ### Subscription events
287
-
288
- | Event | When it fires | What to do |
289
- |-------|---|---|
290
- | `subscription.active` | Subscription becomes active; recurring charges are scheduled | Grant subscription access, send welcome email |
291
- | `subscription.updated` | Any field on the subscription changes | Sync changes to your database |
292
- | `subscription.past_due` | Renewal failed and the grace period opened; customer keeps access (payload has `past_due_ends_at`) | Prompt for payment before the deadline |
293
- | `subscription.on_hold` | Failed renewal stops renewals and revokes access | Notify customer, prompt payment method update |
294
- | `subscription.paused` | Subscription is deliberately paused | Suspend access while paused |
295
- | `subscription.unpaused` | Paused subscription is resumed | Restore access |
296
- | `subscription.renewed` | Subscription amount successfully deducted for a billing period | Log renewal, update next billing date |
297
- | `subscription.plan_changed` | Plan upgraded, downgraded, or modified | Update customer's access level or feature set |
298
- | `subscription.update_payment_method` | Payment method is updated | Sync the new payment method to your records |
299
- | `subscription.cancelled` | Merchant or customer cancels the subscription | Revoke access, send cancellation confirmation |
300
- | `subscription.failed` | Subscription creation fails (mandate creation failed) | Notify customer, suggest alternative payment method |
301
- | `subscription.expired` | Subscription reaches the end of its term | Revoke access, offer renewal or upgrade |
302
-
303
- ### Refund events
304
-
305
- | Event | When it fires | What to do |
306
- |-------|---|---|
307
- | `refund.succeeded` | Refund successfully processed | Update order status, revoke access if applicable |
308
- | `refund.failed` | Refund processing fails | Alert team, investigate reason |
309
-
310
- ### Dispute events
311
-
312
- | Event | When it fires | What to do |
313
- |-------|---|---|
314
- | `dispute.opened` | Customer initiates a dispute | Alert team, prepare evidence |
315
- | `dispute.expired` | Dispute expires without resolution | Log outcome |
316
- | `dispute.accepted` | Merchant accepts the dispute | Process refund if not already done |
317
- | `dispute.cancelled` | Customer or system cancels the dispute | Log outcome |
318
- | `dispute.challenged` | Merchant challenges the dispute | Prepare additional evidence |
319
- | `dispute.won` | Merchant wins the dispute | Log outcome, retain funds |
320
- | `dispute.lost` | Merchant loses the dispute | Process refund, log outcome |
321
-
322
- ### License key events
323
-
324
- | Event | When it fires | What to do |
325
- |-------|---|---|
326
- | `license_key.created` | License key is generated | Legacy; prefer `entitlement_grant.created` with `status: "Delivered"` |
327
-
328
- ### Entitlement grant events
329
-
330
- | Event | When it fires | What to do |
331
- |-------|---|---|
332
- | `entitlement_grant.created` | Grant row is created. Auto-fulfilled license keys arrive here with `status: "Delivered"` and a `license_key`; no `.delivered` follows | Grant access when `status` is `Delivered`; otherwise prepare for fulfillment |
333
- | `entitlement_grant.delivered` | An existing grant moves to `Delivered` (manual fulfillment, or a revoked grant restored) | Grant platform, file, or license-key access |
334
- | `entitlement_grant.failed` | Delivery fails and is no longer retried | Alert team, inspect `error_code` and `error_message` |
335
- | `entitlement_grant.revoked` | Access is withdrawn | Revoke customer access, inspect `revocation_reason` |
336
-
337
- ### Credit events
338
-
339
- These concern virtual credit entitlements, not monetary wallet balances.
340
-
341
- | Event | When it fires | What to do |
342
- |-------|---|---|
343
- | `credit.added` | Credits granted via subscription, purchase, add-on, or API | Update internal credit balance, log grant |
344
- | `credit.deducted` | Usage or manual debit consumes credits | Update internal credit balance |
345
- | `credit.expired` | Unused credits reach expiry | Log expiration, notify customer if applicable |
346
- | `credit.rolled_over` | Unused credits carried into a new grant | Update internal balance |
347
- | `credit.rollover_forfeited` | Credits forfeited at max rollover count | Log forfeiture |
348
- | `credit.overage_charged` | Overage charged after usage exceeds balance | Update internal balance, notify customer |
349
- | `credit.overage_reset` | Accumulated overage reset (e.g., new billing cycle) | Update internal balance |
350
- | `credit.manual_adjustment` | Manual credit or debit adjustment made | Update internal balance, log adjustment |
351
- | `credit.balance_low` | Balance falls below configured threshold | Notify customer, suggest purchase |
352
-
353
- ### Recovery and dunning events
354
-
355
- | Event | When it fires | What to do |
356
- |-------|---|---|
357
- | `abandoned_checkout.detected` | Failed or incomplete checkout classified as abandoned (after 60 min) | Monitor recovery link usage |
358
- | `abandoned_checkout.recovered` | Customer pays through recovery link | Log recovery, update order status |
359
- | `dunning.started` | Dunning attempt begins after subscription enters `on_hold` | Monitor dunning progress |
360
- | `dunning.recovered` | Customer updates payment method and charge succeeds | Reactivate subscription, send confirmation |
361
-
362
- ### Payout events
363
-
364
- These concern your own funds moving to your bank account - use them for bookkeeping, not customer access.
365
-
366
- | Event | When it fires | What to do |
367
- |-------|---|---|
368
- | `payout.created` | Payout is created (formerly emitted as `payout.not_initiated`) | Record the pending payout |
369
- | `payout.in_progress` | Payout processing starts | Mark as in transit |
370
- | `payout.on_hold` | Payout is paused or under review | Check whether more information is required |
371
- | `payout.success` | Payout settles to your bank account | Reconcile in accounting |
372
- | `payout.failed` | Payout fails; amount and fees return to your wallet | Alert finance, check bank details |
275
+ Full guide: [references/event-catalog.md](references/event-catalog.md).
373
276
 
374
- ---
277
+ Covers:
278
+
279
+ - Payment events
280
+ - Subscription events
281
+ - Refund events
282
+ - Dispute events
283
+ - License key events
284
+ - Entitlement grant events
285
+ - Credit events
286
+ - Recovery and dunning events
287
+ - Payout events
375
288
 
376
289
  ## Production-Grade Handler Pattern
377
290
 
@@ -549,191 +462,29 @@ Handle `subscription.active` only in a handler that exposes `webhook-id`, then c
549
462
 
550
463
  ## Local Testing
551
464
 
552
- ### Dashboard test tool
553
-
554
- 1. Go to **Developer → Webhooks → [your endpoint] → Testing**
555
- 2. Select an event type
556
- 3. Click **Send Example**
557
- 4. Verify your endpoint returns `200` and the signature verifies
558
-
559
- ### CLI: Live forwarding
560
-
561
- Forward real test-mode events to localhost:
562
-
563
- ```bash
564
- dodo wh listen http://localhost:3000/webhook
565
- ```
566
-
567
- This creates a test webhook, opens a WebSocket relay, and forwards events with the original `webhook-id`/`webhook-signature`/`webhook-timestamp` headers to your local URL. Requires a test-mode API key. The relay and CLI parse and **re-serialize** the JSON body, so if it differs byte-for-byte from the original (for example number formatting), signature verification fails even though the headers are intact - that is a relay artifact, not a bug in your verifier. The URL argument is required in direct mode — bare `dodo wh listen` only works as `/wh listen` inside the TUI.
465
+ Full guide: [references/local-testing.md](references/local-testing.md).
568
466
 
569
- ### CLI: Unsigned mock events
467
+ Covers:
570
468
 
571
- Generate realistic unsigned payloads for testing without signature verification:
572
-
573
- ```bash
574
- dodo wh trigger payment.success http://localhost:3000/webhook
575
- ```
576
-
577
- Use `unsafeUnwrap()` only for these unsigned payloads. Both arguments are required in direct mode.
578
-
579
- Trigger names are not always the payload `type`: `payment.success` sends `type: "payment.succeeded"`, `refund.success` sends `refund.succeeded`, and `licence.created` sends `license_key.created`. Your handler must switch on the payload types. `subscription.past_due` and `subscription.unpaused` cannot be triggered.
580
-
581
- ### Tunnel
582
-
583
- Expose localhost with ngrok and register the HTTPS URL in the dashboard:
584
-
585
- ```bash
586
- ngrok http 3000
587
- # Register https://xxxx.ngrok.io/webhook in the dashboard
588
- ```
589
-
590
- ---
469
+ - Dashboard test tool
470
+ - CLI: Live forwarding
471
+ - CLI: Unsigned mock events
472
+ - Tunnel
591
473
 
592
474
  ## Common Mistakes
593
475
 
594
- ### 1. Signing only the payload or `timestamp.payload`
595
-
596
- **Wrong:**
597
- ```typescript
598
- const signed = crypto.createHmac('sha256', secret)
599
- .update(payload)
600
- .digest('base64');
601
-
602
- // or
603
- const signed = crypto.createHmac('sha256', secret)
604
- .update(`${timestamp}.${payload}`)
605
- .digest('base64');
606
- ```
607
-
608
- **Correct:** The signed message is `webhook-id.webhook-timestamp.raw_body`, all three parts joined by periods. Use the SDK helper to avoid this entirely.
609
-
610
- ### 2. Re-serializing the parsed body
611
-
612
- **Wrong:**
613
- ```typescript
614
- const body = await req.json();
615
- const signed = JSON.stringify(body); // Reordered, reformatted, breaks signature
616
- ```
617
-
618
- **Correct:** Always use the raw bytes:
619
- ```typescript
620
- const body = await req.text(); // or req.body.toString() in Express
621
- // Pass body directly to unwrap() or webhook.verify()
622
- ```
623
-
624
- ### 3. Naive comma-splitting of the signature header
625
-
626
- **Wrong:**
627
- ```typescript
628
- const sig = signature.split(',')[1]; // Assumes exactly one comma
629
- ```
630
-
631
- The header is versioned and can contain multiple values. Use the SDK helper.
632
-
633
- ### 4. `timingSafeEqual` throwing on length mismatch
634
-
635
- **Wrong:**
636
- ```typescript
637
- crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
638
- // Throws if lengths differ; doesn't return false
639
- ```
640
-
641
- **Correct:** Use the SDK helper, which handles this safely.
642
-
643
- ### 5. Granting access from `return_url` instead of verified webhooks
644
-
645
- **Wrong:**
646
- ```typescript
647
- // User visits return_url after checkout; you grant access
648
- app.get('/checkout/return', (req, res) => {
649
- grantAccess(req.query.customer_id); // Unverified!
650
- });
651
- ```
652
-
653
- **Correct:** Grant access only after receiving and verifying a webhook:
654
- ```typescript
655
- const event = client.webhooks.unwrap(rawBody, { headers });
656
-
657
- if (event.type === 'payment.succeeded') {
658
- grantAccess(event.data.customer.customer_id);
659
- }
660
- ```
661
-
662
- ### 6. Doing slow work before responding 2xx
663
-
664
- **Wrong:**
665
- ```typescript
666
- app.post('/webhook', async (req, res) => {
667
- const unwrapped = client.webhooks.unwrap(...);
668
-
669
- // Slow database writes, email sends, etc.
670
- await db.payment.create(...);
671
- await sendEmail(...);
672
-
673
- res.json({ received: true }); // Dodo times out after 30s
674
- });
675
- ```
676
-
677
- **Correct:** Durably enqueue first, then respond immediately. Return non-`2xx` if persistence fails so Dodo retries:
678
- ```typescript
679
- app.post('/webhook', async (req, res) => {
680
- const unwrapped = client.webhooks.unwrap(...);
681
-
682
- try {
683
- await queue.add('process-event', unwrapped); // Durable write
684
- return res.json({ received: true });
685
- } catch (error) {
686
- console.error('Failed to persist webhook', error);
687
- return res.status(503).json({ error: 'Webhook persistence failed' });
688
- }
689
- });
690
- ```
691
-
692
- ### 7. Acknowledging before durable persistence
693
-
694
- **Wrong:**
695
- ```typescript
696
- res.json({ received: true });
697
- await queue.add('process-event', unwrapped); // A crash can lose an acknowledged event
698
- ```
699
-
700
- **Correct:** Verify the signature, durably insert or enqueue the event, and only then return `2xx`. If persistence fails, return non-`2xx` so Dodo retries.
701
-
702
- ### 8. Committing an idempotency claim before fulfillment
476
+ Full guide: [references/common-mistakes.md](references/common-mistakes.md).
703
477
 
704
- **Wrong:**
705
- ```typescript
706
- const claim = await db.webhookLog.createMany({
707
- data: [{ webhookId }],
708
- skipDuplicates: true,
709
- });
710
- if (claim.count === 0) return;
478
+ Covers:
711
479
 
712
- await grantSubscriptionEntitlements(event.data); // A failure leaves the claim behind
713
- ```
714
-
715
- The retry sees the existing claim and skips fulfillment, permanently dropping the event.
716
-
717
- **Correct:** Commit the claim and all durable fulfillment changes in one transaction. A failure rolls both back, so the retry can claim the event again:
718
- ```typescript
719
- const event = client.webhooks.unwrap(rawBody, { headers });
720
-
721
- await db.$transaction(async (tx) => {
722
- const claim = await tx.webhookLog.createMany({
723
- data: [{ webhookId, eventType: event.type }],
724
- skipDuplicates: true,
725
- });
726
- if (claim.count === 0) return;
727
-
728
- if (event.type === 'subscription.active') {
729
- await grantSubscriptionEntitlements(event.data, tx);
730
- }
731
- });
732
- ```
733
-
734
- Use a transactional outbox for email or other external effects that must follow the database commit.
735
-
736
- ---
480
+ - Signing anything other than `webhook-id.webhook-timestamp.raw_body`
481
+ - Re-serializing the parsed body
482
+ - Naive comma-splitting of the signature header
483
+ - `timingSafeEqual` throwing on length mismatch
484
+ - Granting access from `return_url` instead of verified webhooks
485
+ - Doing slow work before responding 2xx
486
+ - Acknowledging before durable persistence
487
+ - Committing an idempotency claim before fulfillment
737
488
 
738
489
  ## Resources
739
490
 
@@ -0,0 +1,146 @@
1
+ # Dodo Payments Webhook Integration: Common Mistakes
2
+
3
+
4
+ ### 1. Signing only the payload or `timestamp.payload`
5
+
6
+ **Wrong:**
7
+ ```typescript
8
+ const signed = crypto.createHmac('sha256', secret)
9
+ .update(payload)
10
+ .digest('base64');
11
+
12
+ // or
13
+ const signed = crypto.createHmac('sha256', secret)
14
+ .update(`${timestamp}.${payload}`)
15
+ .digest('base64');
16
+ ```
17
+
18
+ **Correct:** The signed message is `webhook-id.webhook-timestamp.raw_body`, all three parts joined by periods. Use the SDK helper to avoid this entirely.
19
+
20
+ ### 2. Re-serializing the parsed body
21
+
22
+ **Wrong:**
23
+ ```typescript
24
+ const body = await req.json();
25
+ const signed = JSON.stringify(body); // Reordered, reformatted, breaks signature
26
+ ```
27
+
28
+ **Correct:** Always use the raw bytes:
29
+ ```typescript
30
+ const body = await req.text(); // or req.body.toString() in Express
31
+ // Pass body directly to unwrap() or webhook.verify()
32
+ ```
33
+
34
+ ### 3. Naive comma-splitting of the signature header
35
+
36
+ **Wrong:**
37
+ ```typescript
38
+ const sig = signature.split(',')[1]; // Assumes exactly one comma
39
+ ```
40
+
41
+ The header is versioned and can contain multiple values. Use the SDK helper.
42
+
43
+ ### 4. `timingSafeEqual` throwing on length mismatch
44
+
45
+ **Wrong:**
46
+ ```typescript
47
+ crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
48
+ // Throws if lengths differ; doesn't return false
49
+ ```
50
+
51
+ **Correct:** Use the SDK helper, which handles this safely.
52
+
53
+ ### 5. Granting access from `return_url` instead of verified webhooks
54
+
55
+ **Wrong:**
56
+ ```typescript
57
+ // User visits return_url after checkout; you grant access
58
+ app.get('/checkout/return', (req, res) => {
59
+ grantAccess(req.query.customer_id); // Unverified!
60
+ });
61
+ ```
62
+
63
+ **Correct:** Grant access only after receiving and verifying a webhook:
64
+ ```typescript
65
+ const event = client.webhooks.unwrap(rawBody, { headers });
66
+
67
+ if (event.type === 'payment.succeeded') {
68
+ grantAccess(event.data.customer.customer_id);
69
+ }
70
+ ```
71
+
72
+ ### 6. Doing slow work before responding 2xx
73
+
74
+ **Wrong:**
75
+ ```typescript
76
+ app.post('/webhook', async (req, res) => {
77
+ const unwrapped = client.webhooks.unwrap(...);
78
+
79
+ // Slow database writes, email sends, etc.
80
+ await db.payment.create(...);
81
+ await sendEmail(...);
82
+
83
+ res.json({ received: true }); // Dodo times out after 30s
84
+ });
85
+ ```
86
+
87
+ **Correct:** Durably enqueue first, then respond immediately. Return non-`2xx` if persistence fails so Dodo retries:
88
+ ```typescript
89
+ app.post('/webhook', async (req, res) => {
90
+ const unwrapped = client.webhooks.unwrap(...);
91
+
92
+ try {
93
+ await queue.add('process-event', unwrapped); // Durable write
94
+ return res.json({ received: true });
95
+ } catch (error) {
96
+ console.error('Failed to persist webhook', error);
97
+ return res.status(503).json({ error: 'Webhook persistence failed' });
98
+ }
99
+ });
100
+ ```
101
+
102
+ ### 7. Acknowledging before durable persistence
103
+
104
+ **Wrong:**
105
+ ```typescript
106
+ res.json({ received: true });
107
+ await queue.add('process-event', unwrapped); // A crash can lose an acknowledged event
108
+ ```
109
+
110
+ **Correct:** Verify the signature, durably insert or enqueue the event, and only then return `2xx`. If persistence fails, return non-`2xx` so Dodo retries.
111
+
112
+ ### 8. Committing an idempotency claim before fulfillment
113
+
114
+ **Wrong:**
115
+ ```typescript
116
+ const claim = await db.webhookLog.createMany({
117
+ data: [{ webhookId }],
118
+ skipDuplicates: true,
119
+ });
120
+ if (claim.count === 0) return;
121
+
122
+ await grantSubscriptionEntitlements(event.data); // A failure leaves the claim behind
123
+ ```
124
+
125
+ The retry sees the existing claim and skips fulfillment, permanently dropping the event.
126
+
127
+ **Correct:** Commit the claim and all durable fulfillment changes in one transaction. A failure rolls both back, so the retry can claim the event again:
128
+ ```typescript
129
+ const event = client.webhooks.unwrap(rawBody, { headers });
130
+
131
+ await db.$transaction(async (tx) => {
132
+ const claim = await tx.webhookLog.createMany({
133
+ data: [{ webhookId, eventType: event.type }],
134
+ skipDuplicates: true,
135
+ });
136
+ if (claim.count === 0) return;
137
+
138
+ if (event.type === 'subscription.active') {
139
+ await grantSubscriptionEntitlements(event.data, tx);
140
+ }
141
+ });
142
+ ```
143
+
144
+ Use a transactional outbox for email or other external effects that must follow the database commit.
145
+
146
+ ---
@@ -0,0 +1,103 @@
1
+ # Dodo Payments Webhook Integration: Webhook Event Catalog
2
+
3
+
4
+ Dodo delivers 48 event types across ten families (payment, subscription, refund, dispute, license key, entitlement grant, credit, abandoned checkout, dunning, payout). Subscribe to only the events you need.
5
+
6
+ ### Payment events
7
+
8
+ | Event | When it fires | What to do |
9
+ |-------|---|---|
10
+ | `payment.succeeded` | Payment completed successfully | Grant access, send confirmation, update order status |
11
+ | `payment.failed` | Payment attempt failed | Notify customer, suggest retry or alternative payment method |
12
+ | `payment.processing` | Payment is still being processed | Acknowledge receipt, wait for `payment.succeeded` or `payment.failed` |
13
+ | `payment.cancelled` | Payment was cancelled before completion | Update order status, notify customer if applicable |
14
+
15
+ ### Subscription events
16
+
17
+ | Event | When it fires | What to do |
18
+ |-------|---|---|
19
+ | `subscription.active` | Subscription becomes active; recurring charges are scheduled | Grant subscription access, send welcome email |
20
+ | `subscription.updated` | Any field on the subscription changes | Sync changes to your database |
21
+ | `subscription.past_due` | Renewal failed and the grace period opened; customer keeps access (payload has `past_due_ends_at`) | Prompt for payment before the deadline |
22
+ | `subscription.on_hold` | Failed renewal stops renewals and revokes access | Notify customer, prompt payment method update |
23
+ | `subscription.paused` | Subscription is deliberately paused | Suspend access while paused |
24
+ | `subscription.unpaused` | Paused subscription is resumed | Restore access |
25
+ | `subscription.renewed` | Subscription amount successfully deducted for a billing period | Log renewal, update next billing date |
26
+ | `subscription.plan_changed` | Plan upgraded, downgraded, or modified | Update customer's access level or feature set |
27
+ | `subscription.update_payment_method` | Payment method is updated | Sync the new payment method to your records |
28
+ | `subscription.cancelled` | Merchant or customer cancels the subscription | Revoke access, send cancellation confirmation |
29
+ | `subscription.failed` | Subscription creation fails (mandate creation failed) | Notify customer, suggest alternative payment method |
30
+ | `subscription.expired` | Subscription reaches the end of its term | Revoke access, offer renewal or upgrade |
31
+
32
+ ### Refund events
33
+
34
+ | Event | When it fires | What to do |
35
+ |-------|---|---|
36
+ | `refund.succeeded` | Refund successfully processed | Update order status, revoke access if applicable |
37
+ | `refund.failed` | Refund processing fails | Alert team, investigate reason |
38
+
39
+ ### Dispute events
40
+
41
+ | Event | When it fires | What to do |
42
+ |-------|---|---|
43
+ | `dispute.opened` | Customer initiates a dispute | Alert team, prepare evidence |
44
+ | `dispute.expired` | Dispute expires without resolution | Log outcome |
45
+ | `dispute.accepted` | Merchant accepts the dispute | Process refund if not already done |
46
+ | `dispute.cancelled` | Customer or system cancels the dispute | Log outcome |
47
+ | `dispute.challenged` | Merchant challenges the dispute | Prepare additional evidence |
48
+ | `dispute.won` | Merchant wins the dispute | Log outcome, retain funds |
49
+ | `dispute.lost` | Merchant loses the dispute | Process refund, log outcome |
50
+
51
+ ### License key events
52
+
53
+ | Event | When it fires | What to do |
54
+ |-------|---|---|
55
+ | `license_key.created` | License key is generated | Legacy; prefer `entitlement_grant.created` with `status: "Delivered"` |
56
+
57
+ ### Entitlement grant events
58
+
59
+ | Event | When it fires | What to do |
60
+ |-------|---|---|
61
+ | `entitlement_grant.created` | Grant row is created. Auto-fulfilled license keys arrive here with `status: "Delivered"` and a `license_key`; no `.delivered` follows | Grant access when `status` is `Delivered`; otherwise prepare for fulfillment |
62
+ | `entitlement_grant.delivered` | An existing grant moves to `Delivered` (manual fulfillment, or a revoked grant restored) | Grant platform, file, or license-key access |
63
+ | `entitlement_grant.failed` | Delivery fails and is no longer retried | Alert team, inspect `error_code` and `error_message` |
64
+ | `entitlement_grant.revoked` | Access is withdrawn | Revoke customer access, inspect `revocation_reason` |
65
+
66
+ ### Credit events
67
+
68
+ These concern virtual credit entitlements, not monetary wallet balances.
69
+
70
+ | Event | When it fires | What to do |
71
+ |-------|---|---|
72
+ | `credit.added` | Credits granted via subscription, purchase, add-on, or API | Update internal credit balance, log grant |
73
+ | `credit.deducted` | Usage or manual debit consumes credits | Update internal credit balance |
74
+ | `credit.expired` | Unused credits reach expiry | Log expiration, notify customer if applicable |
75
+ | `credit.rolled_over` | Unused credits carried into a new grant | Update internal balance |
76
+ | `credit.rollover_forfeited` | Credits forfeited at max rollover count | Log forfeiture |
77
+ | `credit.overage_charged` | Overage charged after usage exceeds balance | Update internal balance, notify customer |
78
+ | `credit.overage_reset` | Accumulated overage reset (e.g., new billing cycle) | Update internal balance |
79
+ | `credit.manual_adjustment` | Manual credit or debit adjustment made | Update internal balance, log adjustment |
80
+ | `credit.balance_low` | Balance falls below configured threshold | Notify customer, suggest purchase |
81
+
82
+ ### Recovery and dunning events
83
+
84
+ | Event | When it fires | What to do |
85
+ |-------|---|---|
86
+ | `abandoned_checkout.detected` | Failed or incomplete checkout classified as abandoned (after 60 min) | Monitor recovery link usage |
87
+ | `abandoned_checkout.recovered` | Customer pays through recovery link | Log recovery, update order status |
88
+ | `dunning.started` | Dunning attempt begins after subscription enters `on_hold` | Monitor dunning progress |
89
+ | `dunning.recovered` | Customer updates payment method and charge succeeds | Reactivate subscription, send confirmation |
90
+
91
+ ### Payout events
92
+
93
+ These concern your own funds moving to your bank account - use them for bookkeeping, not customer access.
94
+
95
+ | Event | When it fires | What to do |
96
+ |-------|---|---|
97
+ | `payout.created` | Payout is created (formerly emitted as `payout.not_initiated`) | Record the pending payout |
98
+ | `payout.in_progress` | Payout processing starts | Mark as in transit |
99
+ | `payout.on_hold` | Payout is paused or under review | Check whether more information is required |
100
+ | `payout.success` | Payout settles to your bank account | Reconcile in accounting |
101
+ | `payout.failed` | Payout fails; amount and fees return to your wallet | Alert finance, check bank details |
102
+
103
+ ---
@@ -0,0 +1,42 @@
1
+ # Dodo Payments Webhook Integration: Local Testing
2
+
3
+
4
+ ### Dashboard test tool
5
+
6
+ 1. Go to **Developer → Webhooks → [your endpoint] → Testing**
7
+ 2. Select an event type
8
+ 3. Click **Send Example**
9
+ 4. Verify your endpoint returns `200` and the signature verifies
10
+
11
+ ### CLI: Live forwarding
12
+
13
+ Forward real test-mode events to localhost:
14
+
15
+ ```bash
16
+ dodo wh listen http://localhost:3000/webhook
17
+ ```
18
+
19
+ This creates a test webhook, opens a WebSocket relay, and forwards events with the original `webhook-id`/`webhook-signature`/`webhook-timestamp` headers to your local URL. Requires a test-mode API key. The relay and CLI parse and **re-serialize** the JSON body, so if it differs byte-for-byte from the original (for example number formatting), signature verification fails even though the headers are intact - that is a relay artifact, not a bug in your verifier. The URL argument is required in direct mode — bare `dodo wh listen` only works as `/wh listen` inside the TUI.
20
+
21
+ ### CLI: Unsigned mock events
22
+
23
+ Generate realistic unsigned payloads for testing without signature verification:
24
+
25
+ ```bash
26
+ dodo wh trigger payment.success http://localhost:3000/webhook
27
+ ```
28
+
29
+ Use `unsafeUnwrap()` only for these unsigned payloads. Both arguments are required in direct mode.
30
+
31
+ Trigger names are not always the payload `type`: `payment.success` sends `type: "payment.succeeded"`, `refund.success` sends `refund.succeeded`, and `licence.created` sends `license_key.created`. Your handler must switch on the payload types. `subscription.past_due` and `subscription.unpaused` cannot be triggered.
32
+
33
+ ### Tunnel
34
+
35
+ Expose localhost with ngrok and register the HTTPS URL in the dashboard:
36
+
37
+ ```bash
38
+ ngrok http 3000
39
+ # Register https://xxxx.ngrok.io/webhook in the dashboard
40
+ ```
41
+
42
+ ---