@dodopayments/opencode-plugin 0.6.1 → 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/package.json +1 -1
- package/skills/billing-sdk/SKILL.md +8 -193
- package/skills/billing-sdk/references/pricing-page.md +197 -0
- package/skills/checkout-integration/SKILL.md +14 -449
- 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 +5 -151
- 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/framework-adapters/SKILL.md +38 -527
- 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 +8 -303
- package/skills/license-keys/references/cli-tool.md +143 -0
- package/skills/license-keys/references/desktop-app.md +170 -0
- package/skills/mobile-checkout/SKILL.md +25 -313
- 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/subscription-integration/SKILL.md +3 -77
- package/skills/subscription-integration/references/webhook-events.md +82 -0
- package/skills/usage-based-billing/SKILL.md +5 -114
- package/skills/usage-based-billing/references/instrumentation.md +119 -0
- package/skills/webhook-integration/SKILL.md +28 -277
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
+
Covers:
|
|
570
468
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
+
---
|