@dodopayments/opencode-plugin 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: refunds-and-disputes
3
- description: Guide for issuing refunds, handling disputes and chargebacks, and reconciling customer access with Dodo Payments
3
+ description: Dodo Payments refunds and disputes, covering full and partial refunds, refund statuses, dispute and chargeback lifecycle events, evidence, and revoking or restoring customer access. Use when issuing a refund, handling refund or dispute webhooks, responding to a chargeback, or reconciling entitlements after a payment reversal.
4
4
  ---
5
5
 
6
6
  # Refunds and Disputes
@@ -23,7 +23,7 @@ This skill covers issuing refunds (full and partial), handling the dispute lifec
23
23
 
24
24
  **Amounts** are always in the smallest currency unit (cents for USD, paise for INR, etc.).
25
25
 
26
- **Access revocation** means removing the customer's ability to use the product or service. On a dispute, you typically revoke access while it's open. Restore it only on `dispute.won`; all other outcomes keep access revoked until you reconcile them separately.
26
+ **Access revocation** means removing the customer's ability to use the product or service. On a dispute, you typically revoke access while it's open. Restore it on `dispute.won` or `dispute.cancelled` (withdrawn, no further action needed); the losing outcomes (`dispute.accepted`, `dispute.lost`, `dispute.expired`) keep access revoked.
27
27
 
28
28
  ## Refunds
29
29
 
@@ -54,7 +54,7 @@ To refund only specific items from a payment, pass the `items` array with each i
54
54
  const partialRefund = await client.refunds.create({
55
55
  payment_id: 'pay_abc123',
56
56
  items: [
57
- { item_id: 'item_1', amount: 1000 },
57
+ { item_id: 'item_1' }, // no amount: the whole item is refunded
58
58
  { item_id: 'item_2', amount: 2500 },
59
59
  ],
60
60
  });
@@ -104,34 +104,63 @@ const client = new DodoPayments({
104
104
  webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
105
105
  });
106
106
 
107
+ type WebhookEvent = ReturnType<typeof client.webhooks.unwrap>;
108
+
109
+ // Register ONE endpoint for every Dodo event. A second app.post('/webhook')
110
+ // would never run, because the first handler answers every request.
107
111
  app.post('/webhook', async (req, res) => {
112
+ let event: WebhookEvent;
108
113
  try {
109
- const event = client.webhooks.unwrap(req.body.toString(), {
114
+ event = client.webhooks.unwrap(req.body.toString(), {
110
115
  headers: {
111
116
  'webhook-id': req.headers['webhook-id'] as string,
112
117
  'webhook-signature': req.headers['webhook-signature'] as string,
113
118
  'webhook-timestamp': req.headers['webhook-timestamp'] as string,
114
119
  },
115
120
  });
121
+ } catch {
122
+ // Only signature verification failures reach this branch
123
+ res.status(401).json({ error: 'Invalid signature' });
124
+ return;
125
+ }
116
126
 
117
- if (event.type === 'refund.succeeded') {
118
- const refund = event.data;
119
- // Refund succeeded; revoke access if needed
120
- await revokeCustomerAccess(refund.customer.customer_id);
121
- await updateRefundRecord(refund.refund_id, 'succeeded');
122
- }
123
-
124
- if (event.type === 'refund.failed') {
125
- const refund = event.data;
126
- // Refund failed; keep access active, alert support
127
- await logRefundFailure(refund.refund_id, refund.reason);
128
- }
129
-
127
+ try {
128
+ await handleRefundEvent(event);
129
+ await handleDisputeEvent(event); // defined under "Handle dispute webhooks"
130
130
  res.json({ received: true });
131
131
  } catch (error) {
132
- res.status(401).json({ error: 'Invalid signature' });
132
+ // Any non-2xx response makes Dodo retry the delivery
133
+ console.error('Webhook handler failed:', error);
134
+ res.status(500).json({ error: 'Webhook handler failed' });
133
135
  }
134
136
  });
137
+
138
+ async function handleRefundEvent(event: WebhookEvent) {
139
+ if (event.type === 'refund.succeeded') {
140
+ const refund = event.data;
141
+ if (!refund.is_partial) {
142
+ // Full refund: nothing in the payment is still paid for
143
+ await revokeCustomerAccess(refund.customer.customer_id);
144
+ } else {
145
+ // Partial refund: keep access to items that are still paid for, and
146
+ // remove it for any item with nothing left to refund. Reading the line
147
+ // items covers every case - an omitted amount, an explicit amount equal
148
+ // to the item price, or several partial refunds that add up to it.
149
+ const { items } = await client.payments.retrieveLineItems(refund.payment_id);
150
+ const fullyRefundedItemIds = items
151
+ .filter((item) => item.refundable_amount === 0)
152
+ .map((item) => item.items_id);
153
+ await revokeItemAccess(refund.customer.customer_id, fullyRefundedItemIds);
154
+ }
155
+ await updateRefundRecord(refund.refund_id, 'succeeded');
156
+ }
157
+
158
+ if (event.type === 'refund.failed') {
159
+ const refund = event.data;
160
+ // Refund failed; keep access active, alert support
161
+ await logRefundFailure(refund.refund_id, refund.reason);
162
+ }
163
+ }
135
164
  ```
136
165
 
137
166
  ## Disputes
@@ -159,7 +188,7 @@ A dispute moves through seven events. Each event requires a different action fro
159
188
  | `dispute.opened` | Customer initiated a chargeback | Record the dispute; consider revoking access immediately; gather evidence from your logs |
160
189
  | `dispute.challenged` | You submitted evidence | Wait for the card network to review |
161
190
  | `dispute.accepted` | You accepted (conceded) the dispute; funds go to the cardholder | Keep access revoked; mark the dispute as accepted in your records |
162
- | `dispute.cancelled` | Customer or system cancelled the dispute | Keep access revoked; reconcile the payment separately |
191
+ | `dispute.cancelled` | The dispute was withdrawn or cancelled | No further action needed; restore access you revoked on `dispute.opened` |
163
192
  | `dispute.expired` | Dispute window closed without resolution | Treat as lost; keep access revoked |
164
193
  | `dispute.won` | You won the dispute | Funds are retained; restore access; update customer records |
165
194
  | `dispute.lost` | You lost the dispute | Funds returned to cardholder; keep access revoked; reconcile your records |
@@ -174,66 +203,56 @@ async function resolveDisputeCustomerId(disputeId: string) {
174
203
  return dispute.customer.customer_id;
175
204
  }
176
205
 
177
- app.post('/webhook', async (req, res) => {
178
- try {
179
- const event = client.webhooks.unwrap(req.body.toString(), {
180
- headers: {
181
- 'webhook-id': req.headers['webhook-id'] as string,
182
- 'webhook-signature': req.headers['webhook-signature'] as string,
183
- 'webhook-timestamp': req.headers['webhook-timestamp'] as string,
184
- },
185
- });
186
-
187
- if (event.type === 'dispute.opened') {
188
- const dispute = event.data;
189
- // Record the dispute and revoke access
190
- await recordDispute(dispute.dispute_id, dispute.payment_id, dispute.amount);
191
- const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
192
- await revokeCustomerAccess(customerId);
193
- // Gather evidence from your system and submit via dashboard
194
- // (no evidence-submission API exists; use the Dodo dashboard)
195
- }
196
-
197
- if (event.type === 'dispute.won') {
198
- const dispute = event.data;
199
- // Funds retained; restore normal state
200
- await markDisputeResolved(dispute.dispute_id, 'won');
201
- const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
202
- await restoreCustomerAccess(customerId);
203
- }
206
+ // Called from the single /webhook handler above, after signature verification
207
+ async function handleDisputeEvent(event: WebhookEvent) {
208
+ if (event.type === 'dispute.opened') {
209
+ const dispute = event.data;
210
+ // Record the dispute and revoke access
211
+ await recordDispute(dispute.dispute_id, dispute.payment_id, dispute.amount);
212
+ const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
213
+ await revokeCustomerAccess(customerId);
214
+ // Gather evidence from your system and submit via dashboard
215
+ // (no evidence-submission API exists; use the Dodo dashboard)
216
+ }
204
217
 
205
- if (event.type === 'dispute.lost') {
206
- const dispute = event.data;
207
- // Funds returned to cardholder; keep access revoked
208
- await markDisputeResolved(dispute.dispute_id, 'lost');
209
- // Do NOT restore access
210
- }
218
+ if (event.type === 'dispute.won') {
219
+ const dispute = event.data;
220
+ // Funds retained; restore normal state
221
+ await markDisputeResolved(dispute.dispute_id, 'won');
222
+ const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
223
+ await restoreCustomerAccess(customerId);
224
+ }
211
225
 
212
- if (event.type === 'dispute.accepted') {
213
- const dispute = event.data;
214
- // Merchant conceded; funds go to the cardholder and access stays revoked
215
- await markDisputeResolved(dispute.dispute_id, 'accepted');
216
- // Do NOT restore access
217
- }
226
+ if (event.type === 'dispute.lost') {
227
+ const dispute = event.data;
228
+ // Funds returned to cardholder; keep access revoked
229
+ await markDisputeResolved(dispute.dispute_id, 'lost');
230
+ // Do NOT restore access
231
+ }
218
232
 
219
- if (event.type === 'dispute.cancelled') {
220
- const dispute = event.data;
221
- // Cancellation is not a win; keep access revoked and reconcile separately
222
- await markDisputeResolved(dispute.dispute_id, 'cancelled');
223
- }
233
+ if (event.type === 'dispute.accepted') {
234
+ const dispute = event.data;
235
+ // Merchant conceded; funds go to the cardholder and access stays revoked
236
+ await markDisputeResolved(dispute.dispute_id, 'accepted');
237
+ // Do NOT restore access
238
+ }
224
239
 
225
- res.json({ received: true });
226
- } catch (error) {
227
- res.status(401).json({ error: 'Invalid signature' });
240
+ if (event.type === 'dispute.cancelled') {
241
+ const dispute = event.data;
242
+ // The dispute was withdrawn; no further action is needed, so undo the
243
+ // revocation from dispute.opened
244
+ await markDisputeResolved(dispute.dispute_id, 'cancelled');
245
+ const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
246
+ await restoreCustomerAccess(customerId);
228
247
  }
229
- });
248
+ }
230
249
  ```
231
250
 
232
251
  ### Evidence submission
233
252
 
234
253
  Dodo handles the card-network dispute process as Merchant of Record. You submit evidence through the Dodo dashboard, not via API. No evidence-submission API exists.
235
254
 
236
- When a dispute opens, gather your evidence (order confirmation, delivery proof, customer communication, etc.) and upload it to the dashboard within the dispute window (typically 4 days). The card network reviews your evidence and makes a final decision.
255
+ When a dispute opens, gather your evidence (order confirmation, delivery proof, customer communication, etc.) and upload it to the dashboard within the dispute window (10 days from when the dispute is created). The card network reviews your evidence and makes a final decision.
237
256
 
238
257
  ## Access revocation pattern
239
258
 
@@ -266,7 +285,8 @@ async function handleDisputeLifecycle(
266
285
  break;
267
286
 
268
287
  case 'dispute_cancelled':
269
- // Cancellation is not a win; reconcile separately and keep access revoked
288
+ // Withdrawn; no further action needed, so restore access
289
+ await restoreCustomerAccess(customerId);
270
290
  break;
271
291
 
272
292
  case 'dispute_expired':
@@ -319,9 +339,9 @@ async function reconcileRefund(refund) {
319
339
 
320
340
  **Ignoring partial refunds when computing entitlements.** If a customer refunds only one item from a multi-item purchase, their entitlement to the other items remains valid. Track refunds by item, not just by payment.
321
341
 
322
- **Restoring access on any outcome except `dispute.won`.** `dispute.accepted` means you conceded and the cardholder receives the funds. A cancelled dispute is also not a win. Keep access revoked and reconcile separately unless you receive `dispute.won`.
342
+ **Restoring access on a losing outcome.** `dispute.accepted`, `dispute.lost`, and `dispute.expired` resolve (or typically resolve) in the cardholder's favor; keep access revoked. Restore access only on `dispute.won` or `dispute.cancelled` (the dispute was withdrawn and needs no further action).
323
343
 
324
- **Submitting evidence after the dispute window closes.** The card network typically gives you 4 days to respond. Set a calendar reminder and gather evidence immediately when a dispute opens.
344
+ **Submitting evidence after the dispute window closes.** You have 10 days from when the dispute is created to respond. Set a calendar reminder and gather evidence immediately when a dispute opens.
325
345
 
326
346
  **Assuming Dodo will handle access revocation.** Dodo handles the card-network process; you handle application access. Dodo won't revoke your customer's subscription or file access automatically.
327
347
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: subscription-integration
3
- description: Guide for managing recurring subscriptions after checkout, including trials, lifecycle states, plan changes, cancellation, failed-payment recovery, proration, mandates, and on-demand charges.
3
+ description: Dodo Payments subscription lifecycle after checkout, covering trials, statuses like active, on_hold, and cancelled, plan upgrades and downgrades, proration, cancellation, failed-payment recovery, mandates, and on-demand charges. Use when managing recurring billing, plan changes, dunning, or off-session charging; use checkout-integration to start the purchase.
4
4
  ---
5
5
 
6
6
  # Dodo Payments Subscription Integration
@@ -19,7 +19,7 @@ Implement recurring billing with trials, plan changes, and on-demand charging. S
19
19
 
20
20
  ## Core Concepts
21
21
 
22
- **Subscription lifecycle:** The six subscription statuses are `pending`, `active`, `on_hold`, `cancelled`, `failed`, and `expired`. A trialing subscription reports `active`; `subscription.renewed` is an event, not a status. Failed payments can move a subscription to `on_hold` (recoverable) or `failed` (terminal). Cancellation sets it to `cancelled` or schedules it to become `expired` at period end.
22
+ **Subscription lifecycle:** The eight subscription statuses are `pending`, `active`, `past_due`, `on_hold`, `paused`, `cancelled`, `failed`, and `expired`. A trialing subscription reports `active`; `subscription.renewed` is an event, not a status. A failed renewal moves a subscription to `past_due` (only if you configured a grace period; access is kept) or `on_hold` (access revoked); both are recoverable. `paused` is a deliberate freeze by you or the customer. `failed` is terminal and only happens when **initial creation** fails. Cancellation sets it to `cancelled` or schedules it to become `expired` at period end.
23
23
 
24
24
  **Checkout Sessions:** The recommended path for creating subscriptions. A single-use hosted checkout that collects payment and customer data, then creates the subscription server-side.
25
25
 
@@ -89,12 +89,16 @@ const session = await client.checkoutSessions.create({
89
89
  | Status | Meaning | Transitions |
90
90
  |--------|---------|-----------|
91
91
  | `pending` | Creation in progress | → `active`, `failed` |
92
- | `active` | Actively renewing | → `on_hold`, `cancelled`, `expired` |
93
- | `on_hold` | Renewal/plan-change payment failed; recoverable | → `active` (payment method updated), `failed` (retries exhausted), `cancelled` |
92
+ | `active` | Actively renewing | → `past_due`, `on_hold`, `paused`, `cancelled`, `expired` |
93
+ | `past_due` | Renewal failed and the grace period is open; customer **keeps** access until `past_due_ends_at` | → `active` (debt settled), `on_hold` or `cancelled` (grace period ends, per your setting) |
94
+ | `on_hold` | Renewal/plan-change payment failed; renewals stopped, access revoked; recoverable | → `active` (payment method updated / retry succeeds), `cancelled` |
95
+ | `paused` | Deliberately paused by you or the customer; billing frozen, access revoked | → `active` (resumed), `cancelled` |
94
96
  | `cancelled` | Will not renew | → `expired` (at period end) |
95
- | `failed` | Initial mandate/payment failed; terminal | (no recovery) |
97
+ | `failed` | Initial mandate/payment failed at creation; terminal. Never reached from `on_hold` or exhausted retries | (no recovery) |
96
98
  | `expired` | Subscription term ended | (terminal) |
97
99
 
100
+ `past_due` only occurs if you enable a grace period; without one, a failed renewal goes straight to `on_hold`.
101
+
98
102
  **Trial period:** If `trial_period_days` is set, the subscription enters `active` immediately but charges nothing until the trial ends. The first charge occurs on the trial end date.
99
103
 
100
104
  ---
@@ -108,6 +112,20 @@ const subscription = await client.subscriptions.retrieve('sub_xxxxx');
108
112
  console.log(subscription.status, subscription.next_billing_date);
109
113
  ```
110
114
 
115
+ ### Pause and Resume
116
+
117
+ Pause and resume run through `status` on the update endpoint. Send it on its own: combining `paused`/`active` with any other field is rejected with `422`.
118
+
119
+ ```typescript
120
+ // Pause: renewals stop, access is revoked, next_billing_date shifts by the pause length
121
+ await client.subscriptions.update('sub_xxxxx', { status: 'paused' });
122
+
123
+ // Resume
124
+ await client.subscriptions.update('sub_xxxxx', { status: 'active' });
125
+ ```
126
+
127
+ Pausing emits `subscription.paused`; resuming emits `subscription.unpaused`. Customer self-service pause in the portal is opt-in (**Settings → Subscriptions → Allow Subscription Pause**).
128
+
111
129
  ### Update Payment Method
112
130
 
113
131
  When a subscription is `on_hold` due to failed payment, the customer can update their payment method. This automatically charges any outstanding dues.
@@ -162,7 +180,7 @@ await client.subscriptions.changePlan('sub_xxxxx', {
162
180
 
163
181
  | Mode | Upgrade | Downgrade | Billing date |
164
182
  |------|---------|-----------|--------------|
165
- | `prorated_immediately` | Time-prorated charge | Time-prorated credit | Resets to change date |
183
+ | `prorated_immediately` | Credit for unused time on the old plan, then charge a **full** new cycle (net = full new cycle − unused credit) | Same formula; a credit only remains if the unused credit exceeds the new plan price | Resets to change date |
166
184
  | `difference_immediately` | Full new-plan charge | Difference becomes credit | Resets |
167
185
  | `full_immediately` | Full new-plan charge | Full new-plan charge, no credit | Resets |
168
186
  | `do_not_bill` | No charge | No credit | Preserved |
@@ -172,6 +190,19 @@ await client.subscriptions.changePlan('sub_xxxxx', {
172
190
  - `prevent_change`: Keep the old plan if the charge fails.
173
191
  - `apply_change`: Apply the new plan even if payment fails (subscription may become `on_hold`).
174
192
 
193
+ **Scheduling:** `effective_at` defaults to `'immediately'`. Pass `effective_at: 'next_billing_date'` to schedule the change (typical for downgrades) - nothing is charged now, the customer keeps the current plan until the period ends, and the pending change appears on the subscription as `scheduled_change` (cancel it with `cancelChangePlan`).
194
+
195
+ ```typescript
196
+ await client.subscriptions.changePlan('sub_xxxxx', {
197
+ product_id: 'pdt_lower_tier',
198
+ quantity: 1,
199
+ proration_billing_mode: 'prorated_immediately',
200
+ effective_at: 'next_billing_date',
201
+ });
202
+ ```
203
+
204
+ **Plan changes are rejected while the subscription is `past_due`.** Settle the renewal debt first.
205
+
175
206
  ### Preview Plan Change
176
207
 
177
208
  Show the customer a quote before committing:
@@ -319,11 +350,16 @@ For full customer management (creating, updating, listing), see the `customer-ma
319
350
  | Event | When | Action |
320
351
  |-------|------|--------|
321
352
  | `subscription.active` | Subscription becomes active, including a trial start or recovery | Grant access |
353
+ | `subscription.updated` | Any subscription field changes | Sync your stored copy (status, dates, metadata) |
322
354
  | `subscription.renewed` | Successful renewal | Log renewal, send receipt |
355
+ | `subscription.past_due` | Renewal failed and the grace period opened (payload has `past_due_ends_at`) | Keep access, prompt for payment before the deadline |
323
356
  | `subscription.on_hold` | Renewal/plan-change payment failed | Notify customer, offer recovery |
357
+ | `subscription.paused` | Subscription paused | Revoke access while paused |
358
+ | `subscription.unpaused` | Paused subscription resumed | Restore access |
324
359
  | `subscription.plan_changed` | Plan upgraded/downgraded or add-ons changed | Update entitlements |
360
+ | `subscription.update_payment_method` | Payment method updated | Update stored payment details |
325
361
  | `subscription.cancelled` | Customer cancels | Schedule access revocation per `cancel_at_next_billing_date` |
326
- | `subscription.failed` | Initial mandate/payment failed | Notify customer, offer retry or new subscription |
362
+ | `subscription.failed` | Initial creation failed (mandate creation) | Notify customer, offer a new subscription |
327
363
  | `subscription.expired` | Subscription term ended | Revoke access |
328
364
 
329
365
  Webhook signature verification, raw-body handling, durable processing, and idempotency are covered in the `webhook-integration` skill.
@@ -357,6 +393,22 @@ export async function POST(req: NextRequest) {
357
393
  case 'subscription.on_hold':
358
394
  await notifyPaymentFailed(event.data.customer.customer_id);
359
395
  break;
396
+ case 'subscription.paused':
397
+ case 'subscription.unpaused': {
398
+ // Pause and resume can happen in quick succession, and handlers for
399
+ // different events can finish in any order. Don't trust the event type:
400
+ // re-read the subscription and apply its CURRENT status, scoped to this
401
+ // subscription (the customer may hold other active ones). Run this under
402
+ // a per-subscription lock if your handlers execute concurrently.
403
+ const current = await client.subscriptions.retrieve(event.data.subscription_id);
404
+ if (current.status === 'paused') {
405
+ await suspendSubscriptionAccess(current.subscription_id);
406
+ } else if (current.status === 'active' || current.status === 'past_due') {
407
+ // past_due keeps access during the payment grace period
408
+ await restoreSubscriptionAccess(current.subscription_id);
409
+ }
410
+ break;
411
+ }
360
412
  case 'subscription.cancelled':
361
413
  if (event.data.cancel_at_next_billing_date) {
362
414
  await scheduleAccessRevocation(event.data.subscription_id, new Date(event.data.next_billing_date));
@@ -459,9 +511,9 @@ await client.subscriptions.changePlan('sub_xxxxx', {
459
511
 
460
512
  This will fail. Always specify a proration mode.
461
513
 
462
- ### 5. Confusing `on_hold` with Pause
514
+ ### 5. Confusing `on_hold` with `paused`
463
515
 
464
- `on_hold` means payment failed, not that the customer paused. There is no general pause/resume operation. `on_hold` is recoverable only by updating the payment method or waiting for retry.
516
+ `on_hold` is involuntary: a payment failed, and it is recovered by updating the payment method or a successful retry. `paused` is deliberate: you or the customer froze the subscription with `subscriptions.update(id, { status: 'paused' })`, and it is resumed with `{ status: 'active' }`. Handle `subscription.paused`/`subscription.unpaused` separately from `subscription.on_hold`.
465
517
 
466
518
  ### 6. Not Handling `cancel_at_next_billing_date`
467
519
 
@@ -481,6 +533,7 @@ if (data.cancel_at_next_billing_date) {
481
533
 
482
534
  ## Resources
483
535
 
536
+ - [Subscriptions (states, pause, grace period)](https://docs.dodopayments.com/features/subscription)
484
537
  - [Subscription Integration Guide](https://docs.dodopayments.com/developer-resources/subscription-integration-guide)
485
538
  - [Upgrade & Downgrade Guide](https://docs.dodopayments.com/developer-resources/subscription-upgrade-downgrade)
486
539
  - [On-Demand Subscriptions](https://docs.dodopayments.com/developer-resources/ondemand-subscriptions)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: testing-and-go-live
3
- description: Guide for test-mode payment scenarios, renewal simulation, local webhook delivery tests, test and live catalog migration, and production launch checks.
3
+ description: Dodo Payments test mode and production launch, covering test cards and payment methods, success and decline scenarios, renewal simulation, local webhook testing with the CLI, copying products from test to live, and a go-live checklist. Use when verifying payment flows, switching from test_mode to live_mode, or preparing to launch.
4
4
  ---
5
5
 
6
6
  # Testing and Go-Live
@@ -44,6 +44,8 @@ const client = new DodoPayments({
44
44
  ```
45
45
 
46
46
  ```python
47
+ import os
48
+
47
49
  from dodopayments import DodoPayments
48
50
 
49
51
  client = DodoPayments(
@@ -67,31 +69,34 @@ Get your test API key from the Dodo dashboard. Test keys are prefixed `dodo_test
67
69
 
68
70
  ### Card success and decline scenarios
69
71
 
70
- Use these card numbers in test mode. The expiry can be any future date; the CVC can be any three digits.
72
+ Use these card numbers in test mode with expiry **06/32** (or **12/34**) and CVV **123**.
71
73
 
72
74
  | Scenario | Card Number | Result |
73
75
  |---|---|---|
74
76
  | Success | `4242 4242 4242 4242` | Payment succeeds |
75
77
  | Decline | `4000 0000 0000 0002` | Payment declined |
76
78
  | Decline (insufficient funds) | `4000 0000 0000 9995` | Insufficient funds error |
77
- | Decline (lost card) | `4000 0000 0000 9987` | Lost card error |
79
+
80
+ Only use cards listed on the [Testing Process](https://docs.dodopayments.com/miscellaneous/testing-process) page; other Stripe-style test numbers (for example "lost card" variants) are not documented for Dodo.
78
81
 
79
82
  ### Subscription renewal failure
80
83
 
81
84
  To test subscription renewal failures, use this card:
82
85
 
83
- | Card Number | Behavior |
84
- |---|---|
85
- | `4000 0000 0000 0069` | Renewal fails on next billing date |
86
+ | Card Number | Expiry | CVV | Behavior |
87
+ |---|---|---|---|
88
+ | `4000 0000 0000 0341` | 12/34 | 123 | Declined at the subscription's next charge (renewal, upgrade, or downgrade) |
89
+
90
+ Create the subscription with a success card first, then switch its payment method to this card in the Customer Portal.
86
91
 
87
92
  ### UPI (India)
88
93
 
89
- Test UPI success and failure with these VPAs:
94
+ Test UPI success and failure with these VPAs. The billing country must be `IN` and the currency `INR` (non-Indian merchants also need Adaptive Currency enabled):
90
95
 
91
96
  | VPA | Result |
92
97
  |---|---|
93
- | `success@okhdfcbank` | Payment succeeds |
94
- | `failure@okhdfcbank` | Payment fails |
98
+ | `success@upi` | Payment succeeds |
99
+ | `failure@upi` | Payment fails |
95
100
 
96
101
  ### BNPL, wallets, and regional methods
97
102
 
@@ -162,7 +167,9 @@ dodo wh trigger payment.success http://localhost:3000/webhook
162
167
 
163
168
  Sends a realistic mock payload for a chosen event. It runs offline and works even while logged out — which is the tell for the part that matters: **these payloads are unsigned.** `unwrap()` rejects them because there is no valid signature to verify.
164
169
 
165
- Use `unsafeUnwrap()` for triggered events only, and never on an endpoint that also receives real traffic. Event names are `<category>.<event>`, for example `payment.success`, `subscription.active`, or `dispute.opened`.
170
+ Use `unsafeUnwrap()` for triggered events only, and never on an endpoint that also receives real traffic. Trigger names are `<category>.<event>`, for example `payment.success`, `subscription.active`, or `dispute.opened`.
171
+
172
+ **Trigger names are not always the payload `type`.** `payment.success` delivers `type: "payment.succeeded"`, `refund.success` delivers `refund.succeeded`, and `licence.created` delivers `license_key.created`. Switch on the payload types in your handler; a `case 'payment.success'` never matches.
166
173
 
167
174
  As with `listen`, both arguments are required in direct mode; `/wh trigger` inside the TUI opens a wizard instead.
168
175
 
@@ -184,19 +191,19 @@ Products are environment-specific. You cannot reuse a test product ID in product
184
191
 
185
192
  **Programmatic approach:**
186
193
 
187
- Fetch the product from test mode, extract its configuration, and create it in live mode:
194
+ Fetch the product from test mode, extract its configuration, and create it in live mode. API keys are per mode — a test key does not authenticate against live mode and vice versa — so this needs two distinct keys:
188
195
 
189
196
  ```typescript
190
197
  // In test mode
191
198
  const testClient = new DodoPayments({
192
- bearerToken: process.env.DODO_PAYMENTS_API_KEY,
199
+ bearerToken: process.env.DODO_PAYMENTS_TEST_API_KEY,
193
200
  environment: 'test_mode',
194
201
  });
195
202
  const testProduct = await testClient.products.retrieve('pdt_test_123');
196
203
 
197
204
  // In live mode
198
205
  const liveClient = new DodoPayments({
199
- bearerToken: process.env.DODO_PAYMENTS_API_KEY,
206
+ bearerToken: process.env.DODO_PAYMENTS_LIVE_API_KEY,
200
207
  environment: 'live_mode',
201
208
  });
202
209
  const liveProduct = await liveClient.products.create({
@@ -331,5 +338,5 @@ Test keys (prefixed `dodo_test_`) only work with `https://test.dodopayments.com`
331
338
 
332
339
  - [Testing Process](https://docs.dodopayments.com/miscellaneous/testing-process)
333
340
  - [Test vs. Live Mode](https://docs.dodopayments.com/miscellaneous/test-mode-vs-live-mode)
334
- - [Webhook Integration](https://docs.dodopayments.com/developer-resources/webhooks/intents/introduction)
335
- - [Dodo CLI](https://docs.dodopayments.com/developer-resources/cli)
341
+ - [Webhook Integration](https://docs.dodopayments.com/developer-resources/webhooks)
342
+ - [Dodo CLI](https://docs.dodopayments.com/developer-resources/sdks/cli)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: usage-based-billing
3
- description: Guide for charging directly per measured API call, token, storage unit, or other consumption using meters, stable usage events, aggregation, free thresholds, and metered subscriptions.
3
+ description: Dodo Payments usage-based billing that charges per measured unit, covering meters, usage event ingestion by event_name, aggregation, free thresholds, price_per_unit, and metered subscriptions. Use when billing per API call, AI token, storage, or bandwidth, sending usage events, or adding metered pricing to a plan; use credit-based-billing for prepaid credit pools.
4
4
  ---
5
5
 
6
6
  # Dodo Payments Usage-Based Billing
@@ -181,7 +181,7 @@ async function trackBatchUsage(
181
181
  customerId: string;
182
182
  eventName: string;
183
183
  occurredAt: string;
184
- metadata: Record<string, string>;
184
+ metadata: Record<string, string | number | boolean>;
185
185
  }>
186
186
  ) {
187
187
  const formattedEvents = events.map((event) => ({
@@ -301,10 +301,11 @@ async function callAI(
301
301
  customer_id: customerId,
302
302
  event_name: 'ai.tokens',
303
303
  timestamp: completedAt,
304
+ // Aggregated properties (Sum/Max/Last) must be numbers, not strings.
304
305
  metadata: {
305
- tokens: response.usage.total_tokens.toString(),
306
- prompt_tokens: response.usage.prompt_tokens.toString(),
307
- completion_tokens: response.usage.completion_tokens.toString(),
306
+ tokens: response.usage.total_tokens,
307
+ prompt_tokens: response.usage.prompt_tokens,
308
+ completion_tokens: response.usage.completion_tokens,
308
309
  model: 'gpt-4',
309
310
  }
310
311
  }]
@@ -331,9 +332,10 @@ async function updateStorageUsage(
331
332
  customer_id: customerId,
332
333
  event_name: 'storage.snapshot',
333
334
  timestamp: capturedAt,
335
+ // Numeric values: a string property does not aggregate.
334
336
  metadata: {
335
- bytes: bytesUsed.toString(),
336
- gb: (bytesUsed / 1024 / 1024 / 1024).toFixed(2),
337
+ bytes: bytesUsed,
338
+ gb: Number((bytesUsed / 1024 / 1024 / 1024).toFixed(2)),
337
339
  }
338
340
  }]
339
341
  });
@@ -355,15 +357,16 @@ await updateStorageUsage(
355
357
  ### Retrieve Usage History
356
358
 
357
359
  ```typescript
358
- const usage = await client.subscriptions.retrieveUsageHistory(
359
- 'sub_abc123',
360
- { page_size: 100 }
361
- );
362
-
363
- console.log(usage.items); // Array of billing-period usage records
360
+ // Auto-paginates across ALL billing periods; narrow with start_date/end_date/meter_id.
361
+ for await (const period of client.subscriptions.retrieveUsageHistory('sub_abc123', {
362
+ start_date: '2025-01-01T00:00:00Z',
363
+ page_size: 20,
364
+ })) {
365
+ console.log(period); // one record per billing period, with per-meter usage
366
+ }
364
367
  ```
365
368
 
366
- This returns aggregated usage per meter for the subscription's current billing period.
369
+ This is paginated usage history organized by billing period, across the subscription's lifetime - not just the current period. Filter with `start_date`, `end_date`, and `meter_id`, and iterate pages (or `for await`) rather than reading only the first page.
367
370
 
368
371
  ---
369
372
 
@@ -378,7 +381,7 @@ To link a meter to credits:
378
381
  3. On the meter, enable **Bill usage in Credits**.
379
382
  4. Set `credit_entitlement_id` and `meter_units_per_credit` (e.g., 1,000 tokens = 1 credit).
380
383
 
381
- Usage under the free threshold is excluded. Approximately every minute, a background worker aggregates new usage, converts it using the meter-to-credit ratio, and consumes the oldest non-expired credit grants (FIFO). When credits run out, configured overage behavior applies.
384
+ The free threshold does **not** apply to credit-billed meters: every unit counts toward credit deduction (it only applies when the meter bills in money). Approximately every minute, a background worker aggregates new usage, converts it using the meter-to-credit ratio, and deducts from non-expired grants, earliest-expiring grants consumed first. When credits run out, configured overage behavior applies.
382
385
 
383
386
  ---
384
387