@dodopayments/opencode-plugin 0.3.3 → 0.4.0

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,942 +1,615 @@
1
1
  ---
2
2
  name: credit-based-billing
3
- description: Guide for implementing credit-based billing with Dodo Payments - credit entitlements, balances, ledger management, rollover, overage, and meter-based deduction.
3
+ description: Complete guide for giving customers included, free, prepaid, promotional, or top-up credits using grants, balances, ledger deductions, rollover, expiry, alerts, and overage.
4
4
  ---
5
5
 
6
6
  # Dodo Payments Credit-Based Billing
7
7
 
8
- **Reference: [docs.dodopayments.com/features/credit-based-billing](https://docs.dodopayments.com/features/credit-based-billing)**
8
+ Use this skill to build prepaid or included-credit billing for tokens, API calls, compute, storage, or currency-denominated value, with auditable grants and deductions.
9
9
 
10
- Grant customers a balance of credits (API calls, tokens, compute units, or any custom metric) and deduct from that balance as they consume your service.
10
+ ## When to use this skill
11
11
 
12
- ---
12
+ - Include a recurring credit allowance in a subscription.
13
+ - Sell one-time top-up packs or prepaid usage.
14
+ - Deduct credits from metered AI, API, or compute consumption.
15
+ - Grant promotional or support credits and record a reason.
16
+ - Configure expiry, rollover, low-balance alerts, or overage.
17
+ - Reconcile a customer's balance against an immutable ledger trail.
13
18
 
14
- ## Overview
19
+ ## Choose the billing model
15
20
 
16
- Credit-based billing lets you:
17
- - **Issue credits** with subscriptions, one-time purchases, or via API
18
- - **Deduct automatically** via usage meters or manually via API
19
- - **Configure rollover** to carry unused credits forward
20
- - **Handle overage** when credits run out mid-cycle
21
- - **Set expiration** rules per credit entitlement
22
- - **Track everything** via a full audit ledger
21
+ | Requirement | Choose | Why |
22
+ |---|---|---|
23
+ | A fixed product with no tracked consumption | Plain subscription or one-time payment | No balance or usage pipeline is needed. |
24
+ | Bill each measured unit directly in money | Pure usage-based billing | A meter rates usage with `price_per_unit`; no prepaid pool is needed. |
25
+ | Include or pre-sell a consumable allowance | Credit-based billing | Grants create a balance that usage can consume. |
26
+ | Include credits, then charge beyond the allowance | Credits with overage | The credit pool is consumed first; configured overage handles the deficit. |
27
+ | Combine a base fee, included credits, and metered use | Usage-based product linked to a credit entitlement | The meter converts usage units into credit deductions. |
23
28
 
24
- Credits work across all product types: subscriptions, one-time purchases, and usage-based billing.
29
+ Credit-based and usage-based billing are complementary. Define and test meters with the `usage-based-billing` skill; this skill covers the credit side and the meter-to-credit link.
25
30
 
26
- ---
31
+ ## Core concepts
27
32
 
28
- ## Core Concepts
33
+ ### Credit entitlements and grants
29
34
 
30
- ### Credit Types
35
+ A **credit entitlement** is the reusable definition of a credit unit and its lifecycle: name, unit, precision, expiry, rollover, and overage. A **grant** is an issuance of that credit to one customer. Grants can originate from:
31
36
 
32
- | Type | Description | Best For |
33
- |------|-------------|----------|
34
- | **Custom Unit** | Your own metric (tokens, API calls, compute hours) with configurable precision (0–3 decimals) | API calls, AI tokens, compute hours, messages |
35
- | **Fiat Credits** | Real currency value (USD, EUR, etc.) that depletes as customers use your service | Prepaid balances, promotional credits, compensation |
37
+ - a subscription, reissued each billing cycle;
38
+ - a one-time product or add-on;
39
+ - a direct API ledger credit;
40
+ - rollover from an earlier grant.
36
41
 
37
- ### Credit Lifecycle
42
+ Credits gate **how much** a customer can consume. The separate `entitlements` resource gates fulfillment such as feature access, files, or license delivery. Do not confuse these SDK namespaces:
38
43
 
39
- 1. **Credits Issued** — Granted on purchase (subscription cycle or one-time) or via API
40
- 2. **Credits Consumed** — Deducted via meter events or manual API calls
41
- 3. **Credits Expire or Roll Over** — At cycle end, unused credits expire or carry forward
42
- 4. **Overage Handling** — If balance hits zero, overage is forgiven, billed, or carried as deficit
44
+ ```text
45
+ client.creditEntitlements credit definitions and balances
46
+ client.creditEntitlements.balances customer credit balances and ledger
47
+ client.entitlements feature/file/license entitlements
48
+ client.entitlements.files entitlement files
49
+ client.entitlements.grants fulfillment grants
50
+ ```
43
51
 
44
- ### Grant Sources
52
+ Use `client.customers.listCreditEntitlements(customerID)` for the customer-level credit-entitlement view. Do not substitute `client.customers.listEntitlements(...)`, which reads the other entitlement system.
45
53
 
46
- | Source | Description |
47
- |--------|-------------|
48
- | **Subscription** | Credits issued each billing cycle |
49
- | **One-Time** | Credits issued with a one-time payment |
50
- | **API** | Credits granted manually via API or dashboard |
51
- | **Rollover** | Credits carried over from a previous billing cycle |
54
+ ### Custom-unit and fiat credits
52
55
 
53
- ---
56
+ | Credit model | Representation | Example |
57
+ |---|---|---|
58
+ | Custom unit | Application-defined units | tokens, calls, compute hours, GB-hours |
59
+ | Fiat | Currency value | USD or EUR prepaid balance |
54
60
 
55
- ## Quick Start
61
+ Custom-unit precision is configurable from `0` through `10`; the dashboard default is `2`. Precision cannot be changed after creation, so choose it before issuing grants. Use `0` for indivisible units such as whole requests. Use decimal precision only when fractional credits are meaningful.
56
62
 
57
- ### 1. Create a Credit Entitlement
63
+ Fiat examples use currency minor units: `5,000` credits represents USD 50 in the documented OpenAI example. Do not infer a universal fiat precision rule beyond the documented minor-unit model. All monetary amounts are in the currency's smallest unit (for example, cents for USD).
58
64
 
59
- ```typescript
60
- import DodoPayments from 'dodopayments';
65
+ A product can carry up to **five** credit entitlements. Each can have independent issuance and lifecycle settings.
61
66
 
62
- const client = new DodoPayments({
63
- bearerToken: process.env.DODO_PAYMENTS_API_KEY,
64
- });
67
+ ### Balance and ledger
65
68
 
66
- const credit = await client.creditEntitlements.create({
67
- name: 'API Credits',
68
- credit_type: 'custom_unit',
69
- unit_name: 'API Calls',
70
- precision: 0,
71
- expiry_duration: 30, // days
72
- rollover_enabled: false,
73
- allow_overage: false,
74
- });
75
- ```
76
-
77
- ### 2. Attach Credits to a Product
69
+ The balance response includes `balance`, `overage`, `last_transaction_at`, identifiers, and timestamps. Treat credit amounts as decimal strings; do not pass them through binary floating-point arithmetic.
78
70
 
79
- In Dashboard → Products → Create/Edit Product → Entitlements → Attach Credits:
80
- - Select the credit entitlement
81
- - Set credits issued per billing cycle (subscriptions) or total (one-time)
82
- - Configure trial credits, proration, low balance threshold
83
-
84
- ### 3. Create Checkout with Credit Product
85
-
86
- ```typescript
87
- const session = await client.checkoutSessions.create({
88
- product_cart: [
89
- {
90
- product_id: 'prod_ai_pro_plan', // Product with credits attached
91
- quantity: 1,
92
- }
93
- ],
94
- customer: { email: 'customer@example.com' },
95
- return_url: 'https://yourapp.com/success',
96
- });
71
+ Every transaction has a ledger record with:
97
72
 
98
- // Redirect to session.checkout_url
99
- ```
73
+ - `balance_before` and `balance_after`;
74
+ - `overage_before` and `overage_after`;
75
+ - amount and transaction type;
76
+ - source references and grant ID;
77
+ - description, metadata, and timestamp.
100
78
 
101
- ### 4. Deduct Credits via Usage Events
79
+ This ledger is the audit trail and provides transaction-level balance snapshots. There is no separately documented historical balance-snapshots endpoint.
102
80
 
103
- ```typescript
104
- // Meter linked to credit entitlement deducts automatically
105
- await client.usageEvents.ingest({
106
- events: [{
107
- event_id: `gen_${Date.now()}_${crypto.randomUUID()}`,
108
- customer_id: 'cus_abc123',
109
- event_name: 'ai.generation',
110
- timestamp: new Date().toISOString(),
111
- metadata: { model: 'gpt-4', tokens: '1500' }
112
- }]
113
- });
114
- ```
81
+ ## Initialize the TypeScript SDK
115
82
 
116
- ### 5. Check Balance
83
+ Keep API keys server-side. Dodo Payments keys use `dodo_test_...` and `dodo_live_...` prefixes.
117
84
 
118
85
  ```typescript
119
- const balance = await client.creditEntitlements.balances.get(
120
- 'cent_credit_id',
121
- 'cus_abc123'
122
- );
86
+ import DodoPayments from 'dodopayments';
123
87
 
124
- console.log(`Available: ${balance.available_balance}`);
125
- console.log(`Overage: ${balance.overage_balance}`);
88
+ const client = new DodoPayments({
89
+ bearerToken: process.env['DODO_PAYMENTS_API_KEY'],
90
+ environment: 'test_mode',
91
+ });
126
92
  ```
127
93
 
128
- ---
129
-
130
- ## API Reference
131
-
132
- ### Credit Entitlement CRUD
133
-
134
- | Operation | Method | Endpoint |
135
- |-----------|--------|----------|
136
- | Create | `POST` | `/credit-entitlements` |
137
- | List | `GET` | `/credit-entitlements` |
138
- | Get | `GET` | `/credit-entitlements/{id}` |
139
- | Update | `PATCH` | `/credit-entitlements/{id}` |
140
- | Delete | `DELETE` | `/credit-entitlements/{id}` |
141
- | Undelete | `POST` | `/credit-entitlements/{id}/undelete` |
142
-
143
- ### Balance & Ledger Operations
144
-
145
- | Operation | Method | Endpoint |
146
- |-----------|--------|----------|
147
- | List All Balances | `GET` | `/credit-entitlements/{id}/balances` |
148
- | Get Customer Balance | `GET` | `/credit-entitlements/{id}/balances/{customer_id}` |
149
- | Create Ledger Entry | `POST` | `/credit-entitlements/{id}/balances/{customer_id}/ledger-entries` |
150
- | List Customer Ledger | `GET` | `/credit-entitlements/{id}/balances/{customer_id}/ledger` |
151
- | List Customer Grants | `GET` | `/credit-entitlements/{id}/balances/{customer_id}/grants` |
152
-
153
- ---
94
+ The SDK defaults to `live_mode` when `environment` is omitted. Use `test_mode` explicitly during development.
154
95
 
155
- ## Implementation Examples
96
+ ## Create and configure a credit entitlement
156
97
 
157
- ### TypeScript/Node.js
158
-
159
- #### Create Credit Entitlement
98
+ The confirmed TypeScript method is `client.creditEntitlements.create(...)`.
160
99
 
161
100
  ```typescript
162
101
  import DodoPayments from 'dodopayments';
163
102
 
164
103
  const client = new DodoPayments({
165
- bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
104
+ bearerToken: process.env['DODO_PAYMENTS_API_KEY'],
105
+ environment: 'test_mode',
166
106
  });
167
107
 
168
- // Custom unit credit (AI tokens)
169
- const tokenCredit = await client.creditEntitlements.create({
170
- name: 'AI Tokens',
171
- credit_type: 'custom_unit',
172
- unit_name: 'tokens',
108
+ const tokenCredits = await client.creditEntitlements.create({
109
+ name: 'AI Token Credits',
110
+ description: 'Credits consumed by model token usage',
111
+ unit: 'token credit',
173
112
  precision: 0,
174
- expiry_duration: 30,
113
+ expires_after_days: 30,
175
114
  rollover_enabled: true,
176
- max_rollover_percentage: 25,
177
- rollover_timeframe: 'month',
178
- max_rollover_count: 3,
179
- allow_overage: true,
180
- overage_limit: 50000,
181
- price_per_unit: 0.001,
182
- overage_behavior: 'bill_overage_at_billing',
115
+ rollover_percentage: 25,
116
+ rollover_timeframe_count: 1,
117
+ rollover_timeframe_interval: 'Month',
118
+ max_rollover_count: 2,
119
+ overage_enabled: true,
120
+ overage_behavior: 'invoice_at_billing',
121
+ overage_limit: 100000,
122
+ price_per_unit: '1',
123
+ currency: 'USD',
183
124
  });
184
125
 
185
- // Fiat credit (USD balance)
186
- const usdCredit = await client.creditEntitlements.create({
187
- name: 'Platform Credits',
188
- credit_type: 'fiat',
189
- unit_currency: 'USD',
190
- expiry_duration: 90,
191
- rollover_enabled: false,
192
- allow_overage: false,
193
- });
126
+ console.log(tokenCredits.id);
194
127
  ```
195
128
 
196
- #### Manual Credit/Debit via Ledger Entry
129
+ `price_per_unit` is a decimal string in the configured currency; monetary values use the currency's smallest unit. In this example, `'1'` is one cent per overage credit for USD.
197
130
 
198
- ```typescript
199
- // Grant credits manually (e.g., promotional bonus)
200
- await client.creditEntitlements.balances.createLedgerEntry(
201
- 'cent_credit_id',
202
- 'cus_abc123',
203
- {
204
- type: 'credit',
205
- amount: '500',
206
- description: 'Welcome bonus - 500 free API credits',
207
- idempotency_key: `welcome_bonus_${customerId}`,
208
- }
209
- );
131
+ ### Confirmed create options
210
132
 
211
- // Debit credits manually (e.g., service compensation deduction)
212
- await client.creditEntitlements.balances.createLedgerEntry(
213
- 'cent_credit_id',
214
- 'cus_abc123',
215
- {
216
- type: 'debit',
217
- amount: '100',
218
- description: 'Manual deduction for premium support',
219
- idempotency_key: `support_deduction_${Date.now()}`,
220
- }
221
- );
222
- ```
133
+ | Field | Required | Meaning and constraints |
134
+ |---|---:|---|
135
+ | `name` | Yes | Display name for the credit entitlement. |
136
+ | `unit` | Yes | Unit label such as `API Calls`, `Tokens`, or `Credits`. |
137
+ | `precision` | Yes | Decimal places, `0–10`; immutable after creation. |
138
+ | `description` | No | Optional description. |
139
+ | `expires_after_days` | No | Nullable number of days after issuance before expiry. |
140
+ | `rollover_enabled` | Yes | Enables or disables carry-forward. |
141
+ | `rollover_percentage` | No | Percentage carried forward, `0–100`. |
142
+ | `rollover_timeframe_count` | No | Number of rollover-validity intervals. |
143
+ | `rollover_timeframe_interval` | No | `Day`, `Week`, `Month`, or `Year`. |
144
+ | `max_rollover_count` | No | Maximum consecutive rollovers before forfeiture. |
145
+ | `overage_enabled` | Yes | Allows deficit handling after credits are exhausted. |
146
+ | `overage_behavior` | No | End-of-cycle behavior from the exact enum below. |
147
+ | `overage_limit` | No | Maximum allowed overage units. |
148
+ | `price_per_unit` | Conditional | Decimal string; required when overage is enabled. |
149
+ | `currency` | Conditional | Required when a price is present. Monetary values use the smallest currency unit. |
223
150
 
224
- #### Query Customer Balance and Ledger
151
+ ### Configure rollover
225
152
 
226
- ```typescript
227
- // Get current balance
228
- const balance = await client.creditEntitlements.balances.get(
229
- 'cent_credit_id',
230
- 'cus_abc123'
231
- );
232
- console.log(`Balance: ${balance.available_balance}`);
153
+ Rollover carries eligible unused credits into a new grant. Configure:
233
154
 
234
- // List all balances for a credit entitlement
235
- const allBalances = await client.creditEntitlements.balances.list(
236
- 'cent_credit_id'
237
- );
155
+ 1. `rollover_enabled: true`.
156
+ 2. `rollover_percentage` as the carry-forward cap from `0` through `100`.
157
+ 3. Both `rollover_timeframe_count` and `rollover_timeframe_interval`; neither should be supplied alone.
158
+ 4. `max_rollover_count` if credits may roll only a limited number of consecutive times.
238
159
 
239
- // Get full transaction history
240
- const ledger = await client.creditEntitlements.balances.listLedger(
241
- 'cent_credit_id',
242
- 'cus_abc123'
243
- );
160
+ Rollover is applied before expiry; only the remainder expires.
244
161
 
245
- for (const entry of ledger.items) {
246
- console.log(`${entry.type}: ${entry.amount} | Balance: ${entry.balance_after}`);
247
- }
162
+ ### Configure expiry
248
163
 
249
- // List credit grants
250
- const grants = await client.creditEntitlements.balances.listGrants(
251
- 'cent_credit_id',
252
- 'cus_abc123'
253
- );
254
- ```
164
+ The API represents expiry as nullable `expires_after_days`. The dashboard offers 7, 30, 60, 90, custom days, or never; its default is 30 days, and custom expiry has a one-day minimum. Use `null` for no API-configured expiry only where the current API form permits it.
255
165
 
256
- #### Update Credit Entitlement Settings
166
+ ### Configure overage
257
167
 
258
- ```typescript
259
- await client.creditEntitlements.update('cent_credit_id', {
260
- rollover_enabled: true,
261
- max_rollover_percentage: 50,
262
- allow_overage: true,
263
- overage_limit: 10000,
264
- price_per_unit: 0.002,
265
- overage_behavior: 'bill_overage_at_billing',
266
- });
267
- ```
168
+ | Exact `overage_behavior` value | Result at billing-cycle end |
169
+ |---|---|
170
+ | `forgive_at_reset` | Do not charge and do not preserve the deficit. This is the default. |
171
+ | `invoice_at_billing` | Charge the overage at billing, then reset it. |
172
+ | `carry_deficit` | Preserve the negative balance. |
173
+ | `carry_deficit_auto_repay` | Preserve the deficit and repay it from newly issued credits. |
268
174
 
269
- ### Python
270
-
271
- ```python
272
- from dodopayments import DodoPayments
273
- import os
274
- import uuid
275
- from datetime import datetime
276
-
277
- client = DodoPayments(bearer_token=os.environ["DODO_PAYMENTS_API_KEY"])
278
-
279
- # Create credit entitlement
280
- credit = client.credit_entitlements.create(
281
- name="AI Tokens",
282
- credit_type="custom_unit",
283
- unit_name="tokens",
284
- precision=0,
285
- expiry_duration=30,
286
- rollover_enabled=True,
287
- max_rollover_percentage=25,
288
- allow_overage=True,
289
- overage_limit=50000,
290
- price_per_unit=0.001,
291
- overage_behavior="bill_overage_at_billing",
292
- )
293
-
294
- # Grant credits manually
295
- client.credit_entitlements.balances.create_ledger_entry(
296
- credit_entitlement_id="cent_credit_id",
297
- customer_id="cus_abc123",
298
- type="credit",
299
- amount="500",
300
- description="Promotional bonus",
301
- idempotency_key=f"promo_{uuid.uuid4()}",
302
- )
303
-
304
- # Check balance
305
- balance = client.credit_entitlements.balances.get(
306
- credit_entitlement_id="cent_credit_id",
307
- customer_id="cus_abc123",
308
- )
309
- print(f"Available: {balance.available_balance}")
310
-
311
- # Send usage events that deduct credits
312
- client.usage_events.ingest(events=[{
313
- "event_id": f"api_{datetime.now().timestamp()}_{uuid.uuid4()}",
314
- "customer_id": "cus_abc123",
315
- "event_name": "ai.tokens",
316
- "timestamp": datetime.now().isoformat(),
317
- "metadata": {"tokens": "1500", "model": "gpt-4"}
318
- }])
319
- ```
175
+ When `overage_enabled` is true, `price_per_unit` is required; when a price is present, `currency` is required. Use `overage_limit` to cap allowed overage units.
320
176
 
321
- ### Go
322
-
323
- ```go
324
- package main
325
-
326
- import (
327
- "context"
328
- "fmt"
329
- "os"
330
- "time"
331
-
332
- "github.com/dodopayments/dodopayments-go"
333
- "github.com/google/uuid"
334
- )
335
-
336
- func main() {
337
- client := dodopayments.NewClient(
338
- option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
339
- )
340
-
341
- ctx := context.Background()
342
-
343
- // Create credit entitlement
344
- credit, err := client.CreditEntitlements.Create(ctx, &dodopayments.CreditEntitlementCreateParams{
345
- Name: "AI Tokens",
346
- CreditType: "custom_unit",
347
- UnitName: "tokens",
348
- Precision: 0,
349
- })
350
- if err != nil {
351
- panic(err)
352
- }
353
-
354
- // Get customer balance
355
- balance, err := client.CreditEntitlements.Balances.Get(ctx, credit.ID, "cus_abc123")
356
- if err != nil {
357
- panic(err)
358
- }
359
- fmt.Printf("Balance: %s\n", balance.AvailableBalance)
360
-
361
- // Send usage events
362
- _, err = client.UsageEvents.Ingest(ctx, &dodopayments.UsageEventIngestParams{
363
- Events: []dodopayments.UsageEvent{{
364
- EventID: fmt.Sprintf("api_%d_%s", time.Now().Unix(), uuid.New().String()),
365
- CustomerID: "cus_abc123",
366
- EventName: "ai.tokens",
367
- Timestamp: time.Now().Format(time.RFC3339),
368
- Metadata: map[string]string{
369
- "tokens": "1500",
370
- "model": "gpt-4",
371
- },
372
- }},
373
- })
374
- if err != nil {
375
- panic(err)
376
- }
377
- }
378
- ```
177
+ When overage is disabled, Dodo stops deducting after the balance reaches zero. Meter processing is asynchronous, so this is not synchronous authorization for your application requests. Implement application-side enforcement when requests must be rejected immediately.
379
178
 
380
- ---
179
+ ## Attach credits to products and subscriptions
381
180
 
382
- ## Credit Settings
181
+ Credits are attached to products as credit entitlements in the product creation or edit flow. Do not use `client.entitlements.grants` to attach or issue credit balances.
383
182
 
384
- ### Rollover
183
+ ### Subscription product
385
184
 
386
- Carry unused credits forward to the next billing cycle:
185
+ In **Products → Create Product** or an existing product:
387
186
 
388
- | Setting | Description |
389
- |---------|-------------|
390
- | **Rollover Enabled** | Toggle to allow unused credits to carry forward |
391
- | **Max Rollover Percentage** | Limit how much carries over (0–100%) |
392
- | **Rollover Timeframe** | How long rolled-over credits remain valid (day, week, month, year) |
393
- | **Max Rollover Count** | Maximum consecutive rollovers before credits are forfeited |
187
+ 1. Choose subscription pricing.
188
+ 2. Open **Entitlements & Credits** and attach an existing credit entitlement.
189
+ 3. Set the credits issued each billing period.
190
+ 4. Optionally set a low-balance notification amount, trial-credit amount, whether unused trial credits expire after trial, and credit proration for plan changes.
191
+ 5. Keep the entitlement's default rollover, overage, and expiry rules or customize them for this product.
394
192
 
395
- **Example**: 200 unused credits at cycle end, 75% rollover → 150 credits carry forward, 50 forfeited.
193
+ The customer receives a fresh grant each billing cycle.
396
194
 
397
- ### Overage
195
+ ### One-time product or add-on
398
196
 
399
- Controls what happens when a customer's balance reaches zero mid-cycle:
197
+ Attach the credit entitlement to a single-payment product or add-on and configure the one-time amount. The purchase issues one grant. This is the supported shape for prepaid packs, top-ups, and promotional bundles.
400
198
 
401
- | Setting | Description |
402
- |---------|-------------|
403
- | **Allow Overage** | Let customers continue past zero balance |
404
- | **Overage Limit** | Max credits consumable beyond balance |
405
- | **Price Per Unit** | Cost per additional credit (with currency) |
406
- | **Overage Behavior** | How overage is handled at cycle end |
199
+ ### Usage-based product
407
200
 
408
- **Overage Behaviors:**
201
+ Attach the credit entitlement to the same usage-based product as the meter. The confirmed meter attachment fields are:
409
202
 
410
- | Behavior | Description |
411
- |----------|-------------|
412
- | **Forgive overage at reset** | Overage tracked but not billed (default) |
413
- | **Bill overage at billing** | Overage charged on next invoice |
414
- | **Carry over deficit** | Negative balance carries into next cycle |
415
- | **Carry over deficit (auto-repay)** | Deficit auto-repaid from new credits next cycle |
203
+ | Field | Purpose |
204
+ |---|---|
205
+ | `meter_id` | Meter to aggregate usage. |
206
+ | `free_threshold` | Optional usage excluded before rating. |
207
+ | `credit_entitlement_id` | Credit pool consumed by this meter. |
208
+ | `meter_units_per_credit` | Usage units required to deduct one credit; required when `credit_entitlement_id` is set. |
209
+ | `price_per_unit` | Direct per-unit monetary price when using pure usage pricing; decimal string. |
416
210
 
417
- ### Expiration
211
+ Product attachment request shapes can evolve. Configure them in the dashboard or copy the current schema from the [Create Product API](https://docs.dodopayments.com/api-reference/products/post-products); do not invent an `entitlements`, `credits`, or `grants` payload.
418
212
 
419
- | Setting | Description |
420
- |---------|-------------|
421
- | **Credit Expiry** | Duration after issuance: 7, 30, 60, 90, custom days, or never |
422
- | **Trial Credits Expire After Trial** | Whether trial-specific credits expire when trial ends |
213
+ ### Start checkout for an attached product
423
214
 
424
- ---
215
+ Product purchase creates the customer grant; checkout does not accept an ad hoc credit amount.
425
216
 
426
- ## Webhook Events
217
+ ```typescript
218
+ const session = await client.checkoutSessions.create({
219
+ product_cart: [
220
+ {
221
+ product_id: 'pdt_ai_pro_plan',
222
+ quantity: 1,
223
+ },
224
+ ],
225
+ customer: { email: 'customer@example.com' },
226
+ return_url: 'https://app.example.com/billing/success',
227
+ });
427
228
 
428
- Credit-based billing fires these webhook events:
229
+ console.log(session);
230
+ ```
429
231
 
430
- | Event | Description |
431
- |-------|-------------|
432
- | `credit.added` | Credits granted to a customer |
433
- | `credit.deducted` | Credits consumed through usage or manual debit |
434
- | `credit.expired` | Unused credits expired |
435
- | `credit.rolled_over` | Credits carried forward to a new grant |
436
- | `credit.rollover_forfeited` | Credits forfeited at max rollover count |
437
- | `credit.overage_charged` | Overage charges applied |
438
- | `credit.manual_adjustment` | Manual credit/debit adjustment made |
439
- | `credit.balance_low` | Balance dropped below configured threshold |
232
+ Use `checkoutSessions.create`, not the deprecated `payments.create` or `subscriptions.create` methods.
440
233
 
441
- ### Webhook Handler Example
234
+ ## Discover a customer's credit entitlements
235
+
236
+ Use the customer resource when the application needs to discover all credit entitlements associated with a customer:
442
237
 
443
238
  ```typescript
444
- // app/api/webhooks/dodo/route.ts
445
- import { NextRequest, NextResponse } from 'next/server';
446
-
447
- export async function POST(req: NextRequest) {
448
- const event = await req.json();
449
-
450
- switch (event.type) {
451
- case 'credit.added':
452
- await handleCreditAdded(event.data);
453
- break;
454
- case 'credit.deducted':
455
- await handleCreditDeducted(event.data);
456
- break;
457
- case 'credit.balance_low':
458
- await handleBalanceLow(event.data);
459
- break;
460
- case 'credit.expired':
461
- await handleCreditExpired(event.data);
462
- break;
463
- case 'credit.overage_charged':
464
- await handleOverageCharged(event.data);
465
- break;
466
- }
239
+ const customerCredits = await client.customers.listCreditEntitlements(
240
+ 'cus_TV52uJWWXt2yIoBBxpjaa',
241
+ );
467
242
 
468
- return NextResponse.json({ received: true });
469
- }
243
+ console.log(customerCredits);
244
+ ```
470
245
 
471
- async function handleCreditAdded(data: any) {
472
- const { customer_id, credit_entitlement_id, amount, balance_after } = data;
473
-
474
- // Update internal records
475
- await prisma.creditBalance.upsert({
476
- where: { customerId_creditId: { customerId: customer_id, creditId: credit_entitlement_id } },
477
- create: { customerId: customer_id, creditId: credit_entitlement_id, balance: balance_after },
478
- update: { balance: balance_after },
479
- });
480
- }
246
+ Use the balance resource below when the credit entitlement ID is already known and an exact balance is required.
481
247
 
482
- async function handleCreditDeducted(data: any) {
483
- const { customer_id, credit_entitlement_id, amount, balance_after } = data;
248
+ ## Read a customer's balance
484
249
 
485
- await prisma.creditBalance.update({
486
- where: { customerId_creditId: { customerId: customer_id, creditId: credit_entitlement_id } },
487
- data: { balance: balance_after },
488
- });
489
- }
250
+ The confirmed method takes the customer ID first and the credit entitlement ID inside the options object:
490
251
 
491
- async function handleBalanceLow(data: any) {
492
- const {
493
- customer_id,
494
- credit_entitlement_name,
495
- available_balance,
496
- threshold_percent,
497
- } = data;
498
-
499
- // Notify the customer
500
- await sendEmail(customer_id, {
501
- subject: `Your ${credit_entitlement_name} balance is running low`,
502
- body: `You have ${available_balance} credits remaining (${threshold_percent}% threshold reached). Consider upgrading your plan or purchasing additional credits.`,
503
- });
504
- }
252
+ ```typescript
253
+ const customerCreditBalance = await client.creditEntitlements.balances.retrieve(
254
+ 'cus_TV52uJWWXt2yIoBBxpjaa',
255
+ { credit_entitlement_id: 'cde_ztxm5XJsKxWucRWA3rjdM' },
256
+ );
505
257
 
506
- async function handleCreditExpired(data: any) {
507
- const { customer_id, credit_entitlement_id, amount, balance_after } = data;
258
+ console.log({
259
+ balance: customerCreditBalance.balance,
260
+ overage: customerCreditBalance.overage,
261
+ lastTransactionAt: customerCreditBalance.last_transaction_at,
262
+ });
263
+ ```
508
264
 
509
- await prisma.creditBalance.update({
510
- where: { customerId_creditId: { customerId: customer_id, creditId: credit_entitlement_id } },
511
- data: { balance: balance_after },
512
- });
265
+ `balance` and `overage` are decimal strings in the entitlement's credit unit. They are not currency amounts unless the entitlement itself represents fiat value.
513
266
 
514
- // Optionally notify customer
515
- await sendCreditExpiryNotification(customer_id, amount);
516
- }
267
+ ## Grant credits manually
517
268
 
518
- async function handleOverageCharged(data: any) {
519
- const { customer_id, credit_entitlement_id, amount, overage_after } = data;
269
+ Manual credits are appropriate for promotions, migrations, refunds implemented as credit, and support gestures. Use a stable idempotency key derived from the business operation so a retry cannot grant twice.
520
270
 
521
- // Track overage for billing
522
- await prisma.overageRecord.create({
523
- data: {
524
- customerId: customer_id,
525
- creditId: credit_entitlement_id,
526
- amount,
527
- overageBalance: overage_after,
271
+ ```typescript
272
+ async function grantSupportCredits(
273
+ customerId: string,
274
+ creditEntitlementId: string,
275
+ supportCaseId: string,
276
+ ): Promise<string> {
277
+ const entry = await client.creditEntitlements.balances.createLedgerEntry(
278
+ customerId,
279
+ {
280
+ credit_entitlement_id: creditEntitlementId,
281
+ amount: '500',
282
+ entry_type: 'credit',
283
+ expires_at: '2027-01-31T23:59:59Z',
284
+ idempotency_key: `support-credit:${supportCaseId}`,
285
+ reason: `Support gesture for case ${supportCaseId}`,
286
+ metadata: { support_case_id: supportCaseId },
528
287
  },
529
- });
530
- }
531
- ```
532
-
533
- ### Balance Low Payload
534
-
535
- The `credit.balance_low` event has a distinct payload:
288
+ );
536
289
 
537
- ```json
538
- {
539
- "business_id": "bus_xxxxx",
540
- "type": "credit.balance_low",
541
- "timestamp": "2025-08-04T06:15:00.000000Z",
542
- "data": {
543
- "payload_type": "CreditBalanceLow",
544
- "customer_id": "cus_xxxxx",
545
- "subscription_id": "sub_xxxxx",
546
- "credit_entitlement_id": "cent_xxxxx",
547
- "credit_entitlement_name": "API Credits",
548
- "available_balance": "15",
549
- "subscription_credits_amount": "100",
550
- "threshold_percent": 20,
551
- "threshold_amount": "20"
552
- }
290
+ return entry.id;
553
291
  }
554
292
  ```
555
293
 
556
- ---
557
-
558
- ## Usage Billing with Credits
559
-
560
- When credits are linked to usage meters, meter events automatically deduct credits. A background worker processes events every minute, converts meter units to credits using your configured rate, and deducts using FIFO ordering (oldest grants first).
561
-
562
- ### How It Works
294
+ `amount` is a decimal string backed by `NUMERIC(38,28)`; its integer part must be below `10^10`. `expires_at` applies to credits. Keep the amount within the entitlement's configured precision.
563
295
 
564
- 1. **Your app sends usage events** — Each event includes customer ID, event name, and metadata
565
- 2. **Meters aggregate events** — Using Count, Sum, Max, Last, or Unique Count
566
- 3. **Credits are deducted automatically** — Converted at your configured `meter_units_per_credit` rate
567
- 4. **Overage is tracked** — If balance reaches zero and overage is enabled, usage continues
296
+ If the same `idempotency_key` already exists, the API can return `409`. Treat that as a signal to reconcile the original operation, not as permission to generate a new key and retry the grant.
568
297
 
569
- ### Meter-Credit Configuration
298
+ ## Deduct credits manually
570
299
 
571
- In Dashboard → Products → Usage-Based Product → Select Meter:
572
- 1. Toggle **Bill usage in Credits**
573
- 2. Select the credit entitlement
574
- 3. Set **Meter units per credit** (e.g., 1000 API calls = 1 credit)
575
- 4. Set **Free Threshold** (units free before credit deduction begins)
576
-
577
- ### AI Token Billing Example
300
+ Use the same method with `entry_type: 'debit'`. Debits consume the oldest grants first (FIFO) and can return `400` when the balance is insufficient.
578
301
 
579
302
  ```typescript
580
- import DodoPayments from 'dodopayments';
581
-
582
- const client = new DodoPayments({
583
- bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
584
- });
585
-
586
- // Track AI token usage — meter auto-deducts credits
587
- async function trackAIUsage(
303
+ async function deductJobCredits(
588
304
  customerId: string,
589
- promptTokens: number,
590
- completionTokens: number,
591
- model: string
592
- ) {
593
- const totalTokens = promptTokens + completionTokens;
594
-
595
- await client.usageEvents.ingest({
596
- events: [{
597
- event_id: `ai_${Date.now()}_${crypto.randomUUID()}`,
598
- customer_id: customerId,
599
- event_name: 'ai.tokens',
600
- timestamp: new Date().toISOString(),
601
- metadata: {
602
- tokens: totalTokens.toString(),
603
- prompt_tokens: promptTokens.toString(),
604
- completion_tokens: completionTokens.toString(),
605
- model,
606
- }
607
- }]
608
- });
609
- }
305
+ creditEntitlementId: string,
306
+ jobId: string,
307
+ amount: string,
308
+ ): Promise<{ balanceAfter: string; overageAfter: string }> {
309
+ const entry = await client.creditEntitlements.balances.createLedgerEntry(
310
+ customerId,
311
+ {
312
+ credit_entitlement_id: creditEntitlementId,
313
+ amount,
314
+ entry_type: 'debit',
315
+ idempotency_key: `job-debit:${jobId}`,
316
+ reason: `Credit deduction for job ${jobId}`,
317
+ metadata: { job_id: jobId },
318
+ },
319
+ );
610
320
 
611
- // After AI completion
612
- await trackAIUsage('cus_abc123', 500, 1200, 'gpt-4');
321
+ return {
322
+ balanceAfter: entry.balance_after,
323
+ overageAfter: entry.overage_after,
324
+ };
325
+ }
613
326
  ```
614
327
 
615
- ### API Rate Tracking Example
328
+ Do not manually debit usage that is also connected to automatic meter-to-credit deduction; that charges the same consumption twice.
329
+
330
+ ## Read the ledger for an audit trail
331
+
332
+ The SDK returns an auto-paginating iterable:
616
333
 
617
334
  ```typescript
618
- // Middleware to track and deduct API credits
619
- async function trackAPICredit(customerId: string, req: Request) {
620
- await client.usageEvents.ingest({
621
- events: [{
622
- event_id: `api_${Date.now()}_${crypto.randomUUID()}`,
623
- customer_id: customerId,
624
- event_name: 'api.call',
625
- timestamp: new Date().toISOString(),
626
- metadata: {
627
- endpoint: new URL(req.url).pathname,
628
- method: req.method,
629
- }
630
- }]
335
+ for await (const entry of client.creditEntitlements.balances.listLedger(
336
+ 'cus_TV52uJWWXt2yIoBBxpjaa',
337
+ { credit_entitlement_id: 'cde_ztxm5XJsKxWucRWA3rjdM' },
338
+ )) {
339
+ console.log({
340
+ id: entry.id,
341
+ amount: entry.amount,
342
+ balanceBefore: entry.balance_before,
343
+ balanceAfter: entry.balance_after,
344
+ overageBefore: entry.overage_before,
345
+ overageAfter: entry.overage_after,
346
+ createdAt: entry.created_at,
631
347
  });
632
348
  }
633
349
  ```
634
350
 
635
- ---
351
+ Persist the Dodo ledger entry ID with internal order, support-case, or job IDs. Reconciliation should compare ledger entries and source references rather than overwrite history from a cached balance.
636
352
 
637
- ## Next.js Integration
353
+ ## Configure automatic meter-to-credit deduction
638
354
 
639
- ### Credit Balance API Route
355
+ 1. Attach a credit entitlement to the usage-based product.
356
+ 2. Add the meter to the same product.
357
+ 3. Enable **Bill usage in Credits** for that meter.
358
+ 4. Set `credit_entitlement_id` and `meter_units_per_credit`.
359
+ 5. Optionally set `free_threshold`; usage under it is excluded.
360
+ 6. Ingest events whose case-sensitive `event_name` matches the meter.
640
361
 
641
- ```typescript
642
- // app/api/credits/balance/route.ts
643
- import { NextRequest, NextResponse } from 'next/server';
644
- import DodoPayments from 'dodopayments';
362
+ A background worker processes new usage approximately every minute, aggregates it according to the meter, converts meter units using `meter_units_per_credit`, and consumes the oldest non-expired grants first (FIFO). Multiple meters can consume one shared credit pool at different conversion rates.
645
363
 
646
- const client = new DodoPayments({
647
- bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
648
- });
364
+ This delay is material: an accepted usage event does not imply that a balance read immediately afterward includes its deduction.
649
365
 
650
- export async function GET(req: NextRequest) {
651
- const customerId = req.nextUrl.searchParams.get('customer_id');
652
- const creditId = req.nextUrl.searchParams.get('credit_id');
366
+ Use the `usage-based-billing` skill to create the meter, choose `count`, `sum`, `max`, or `last`, define its filter, and validate event batching and metadata.
653
367
 
654
- if (!customerId || !creditId) {
655
- return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
656
- }
368
+ ## End-to-end AI token example
657
369
 
658
- try {
659
- const balance = await client.creditEntitlements.balances.get(creditId, customerId);
370
+ Assume the dashboard/API configuration already has:
660
371
 
661
- return NextResponse.json({
662
- available: balance.available_balance,
663
- overage: balance.overage_balance,
664
- });
665
- } catch (error: any) {
666
- return NextResponse.json({ error: error.message }, { status: 500 });
667
- }
668
- }
669
- ```
372
+ - a custom-unit entitlement named `AI Token Credits`, precision `0`;
373
+ - a subscription product that issues credits each cycle;
374
+ - a `sum` meter whose case-sensitive event name is `ai.tokens` and key is `tokens`;
375
+ - that meter linked through `credit_entitlement_id` and `meter_units_per_credit`;
376
+ - a one-time product `pdt_ai_token_topup` with credits attached.
670
377
 
671
- ### Manual Credit Grant API Route
378
+ The following server-side script records actual model usage, reads the credit balance, and creates a top-up checkout when your verified low-balance webhook flow prompts the customer:
672
379
 
673
380
  ```typescript
674
- // app/api/credits/grant/route.ts
675
- import { NextRequest, NextResponse } from 'next/server';
676
381
  import DodoPayments from 'dodopayments';
677
382
 
678
383
  const client = new DodoPayments({
679
- bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
384
+ bearerToken: process.env['DODO_PAYMENTS_API_KEY'],
385
+ environment: 'test_mode',
680
386
  });
681
387
 
682
- export async function POST(req: NextRequest) {
683
- const { customerId, creditId, amount, description } = await req.json();
684
-
685
- try {
686
- const entry = await client.creditEntitlements.balances.createLedgerEntry(
687
- creditId,
688
- customerId,
388
+ async function recordTokenUsage(
389
+ customerId: string,
390
+ generationId: string,
391
+ model: string,
392
+ promptTokens: number,
393
+ completionTokens: number,
394
+ ): Promise<number> {
395
+ const tokens = promptTokens + completionTokens;
396
+ const response = await client.usageEvents.ingest({
397
+ events: [
689
398
  {
690
- type: 'credit',
691
- amount: amount.toString(),
692
- description,
693
- idempotency_key: `grant_${customerId}_${Date.now()}`,
694
- }
695
- );
696
-
697
- return NextResponse.json({ success: true, balance_after: entry.balance_after });
698
- } catch (error: any) {
699
- return NextResponse.json({ error: error.message }, { status: 500 });
700
- }
701
- }
702
- ```
703
-
704
- ### Credit Balance Hook
705
-
706
- ```typescript
707
- // hooks/useCreditBalance.ts
708
- import useSWR from 'swr';
399
+ event_id: `generation:${generationId}`,
400
+ customer_id: customerId,
401
+ event_name: 'ai.tokens',
402
+ timestamp: new Date().toISOString(),
403
+ metadata: {
404
+ tokens,
405
+ prompt_tokens: promptTokens,
406
+ completion_tokens: completionTokens,
407
+ model,
408
+ },
409
+ },
410
+ ],
411
+ });
709
412
 
710
- const fetcher = (url: string) => fetch(url).then(r => r.json());
413
+ return response.ingested_count;
414
+ }
711
415
 
712
- export function useCreditBalance(customerId: string, creditId: string) {
713
- const { data, error, mutate } = useSWR(
714
- `/api/credits/balance?customer_id=${customerId}&credit_id=${creditId}`,
715
- fetcher,
716
- { refreshInterval: 30000 } // Refresh every 30s
416
+ async function readTokenBalance(
417
+ customerId: string,
418
+ creditEntitlementId: string,
419
+ ): Promise<string> {
420
+ const response = await client.creditEntitlements.balances.retrieve(
421
+ customerId,
422
+ { credit_entitlement_id: creditEntitlementId },
717
423
  );
718
-
719
- return {
720
- available: data?.available,
721
- overage: data?.overage,
722
- isLoading: !error && !data,
723
- isError: error,
724
- refresh: mutate,
725
- };
424
+ return response.balance;
726
425
  }
727
426
 
728
- // Usage in component
729
- function CreditDisplay() {
730
- const { available, isLoading } = useCreditBalance('cus_abc123', 'cent_xxxxx');
731
-
732
- if (isLoading) return <div>Loading...</div>;
733
-
734
- return (
735
- <div>
736
- <h3>Credits Remaining</h3>
737
- <p className="text-3xl font-bold">{available}</p>
738
- </div>
739
- );
427
+ async function createTopUpCheckout(customerEmail: string) {
428
+ return client.checkoutSessions.create({
429
+ product_cart: [
430
+ { product_id: 'pdt_ai_token_topup', quantity: 1 },
431
+ ],
432
+ customer: { email: customerEmail },
433
+ return_url: 'https://app.example.com/credits',
434
+ });
740
435
  }
741
- ```
742
436
 
743
- ---
437
+ async function main(): Promise<void> {
438
+ const customerId = 'cus_8VbC6JDZzPEqfBPUdpj0K';
439
+ const creditEntitlementId = 'cde_ztxm5XJsKxWucRWA3rjdM';
744
440
 
745
- ## Common Patterns
441
+ const ingested = await recordTokenUsage(
442
+ customerId,
443
+ 'gen_01JZAIEXAMPLE',
444
+ 'example-model',
445
+ 1800,
446
+ 700,
447
+ );
448
+ console.log({ ingested });
746
449
 
747
- ### Credit Top-Up (One-Time Purchase)
450
+ // Meter deduction is asynchronous; this read may still show the prior balance.
451
+ const balance = await readTokenBalance(customerId, creditEntitlementId);
452
+ console.log({ balance });
748
453
 
749
- Sell credit packs as one-time products:
454
+ // Call this after a verified credit.balance_low event and customer action.
455
+ const topUp = await createTopUpCheckout('customer@example.com');
456
+ console.log(topUp);
457
+ }
750
458
 
751
- ```typescript
752
- // Product: "500 API Credit Pack" with 500 credits attached
753
- const session = await client.checkoutSessions.create({
754
- product_cart: [
755
- { product_id: 'prod_credit_pack_500', quantity: 1 }
756
- ],
757
- customer: { email: 'customer@example.com' },
758
- return_url: 'https://yourapp.com/credits/success',
459
+ main().catch((error: unknown) => {
460
+ console.error(error instanceof Error ? error.message : error);
461
+ process.exitCode = 1;
759
462
  });
760
463
  ```
761
464
 
762
- ### Subscription with Credits + Overage
465
+ The lifecycle is:
763
466
 
764
- ```typescript
765
- // Product: Pro Plan - $49/month, 10,000 tokens included, $0.002/token overage
766
- // Credits attached in dashboard with:
767
- // - Credits per cycle: 10000
768
- // - Allow overage: true
769
- // - Price per unit: $0.002
770
- // - Overage behavior: Bill overage at billing
467
+ 1. The model returns actual prompt and completion token counts.
468
+ 2. The app ingests one uniquely identified `ai.tokens` event.
469
+ 3. The meter aggregates the `tokens` metadata value.
470
+ 4. The worker converts units into credits and deducts FIFO from non-expired grants.
471
+ 5. Dodo emits `credit.deducted`; when configured threshold conditions are met, it emits `credit.balance_low`.
472
+ 6. The app notifies the customer and offers checkout for the attached top-up product.
771
473
 
772
- const session = await client.checkoutSessions.create({
773
- product_cart: [
774
- { product_id: 'prod_pro_with_credits', quantity: 1 }
775
- ],
776
- subscription_data: { trial_period_days: 14 },
777
- customer: { email: 'customer@example.com' },
778
- return_url: 'https://yourapp.com/success',
779
- });
780
- ```
474
+ Do not poll every minute to synthesize your own threshold event. Use the verified webhook and keep polling only for user-interface freshness.
781
475
 
782
- ### Access Control Based on Credits
476
+ ## Handle credit webhooks
783
477
 
784
- ```typescript
785
- // Middleware to check credit balance before allowing API access
786
- async function requireCredits(customerId: string, creditId: string): Promise<boolean> {
787
- try {
788
- const balance = await client.creditEntitlements.balances.get(creditId, customerId);
789
- const available = parseFloat(balance.available_balance);
790
- return available > 0;
791
- } catch {
792
- return false;
793
- }
794
- }
478
+ Webhook signature verification, raw-body handling, retries, and event deduplication are covered in the `webhook-integration` skill. Verify with `client.webhooks.unwrap()` before dispatching any event; never trust a parsed, unverified body.
795
479
 
796
- // Express middleware
797
- async function creditGate(req: Request, res: Response, next: Function) {
798
- const customerId = req.headers['x-customer-id'] as string;
799
- const hasCredits = await requireCredits(customerId, 'cent_api_credits');
480
+ All credit events except `credit.balance_low` use the full ledger payload.
800
481
 
801
- if (!hasCredits) {
802
- return res.status(402).json({
803
- error: 'Insufficient credits',
804
- message: 'Your credit balance is exhausted. Please upgrade your plan or purchase more credits.',
805
- });
806
- }
482
+ | Exact event | Application action |
483
+ |---|---|
484
+ | `credit.added` | Refresh cached balance; correlate the grant or purchase and ledger entry. |
485
+ | `credit.deducted` | Refresh cached balance and usage display; reconcile the source usage/job. |
486
+ | `credit.expired` | Refresh balance; notify only if your product policy promises expiry notices. |
487
+ | `credit.rolled_over` | Refresh balance and expose the new rollover grant in account history. |
488
+ | `credit.rollover_forfeited` | Refresh balance and explain forfeiture according to the configured rollover limit. |
489
+ | `credit.overage_charged` | Reconcile the overage charge with billing and customer-visible history. |
490
+ | `credit.overage_reset` | Clear cached overage state after confirming the ledger payload. |
491
+ | `credit.manual_adjustment` | Reconcile the adjustment with its internal support/admin operation. |
492
+ | `credit.balance_low` | Deduplicate, refresh the authoritative balance, notify the customer, and offer upgrade or top-up. |
807
493
 
808
- next();
809
- }
810
- ```
494
+ ### Low-balance payload
811
495
 
812
- ### Promotional Credit Grants
496
+ `credit.balance_low` has a dedicated payload:
813
497
 
814
- ```typescript
815
- // Grant promotional credits with idempotency
816
- async function grantPromoCredits(
817
- customerId: string,
818
- creditId: string,
819
- amount: number,
820
- promoCode: string
821
- ) {
822
- await client.creditEntitlements.balances.createLedgerEntry(
823
- creditId,
824
- customerId,
825
- {
826
- type: 'credit',
827
- amount: amount.toString(),
828
- description: `Promotional credit: ${promoCode}`,
829
- idempotency_key: `promo_${promoCode}_${customerId}`, // Prevents double-granting
830
- }
831
- );
498
+ ```json
499
+ {
500
+ "business_id": "bus_H4ekzPSlcg",
501
+ "type": "credit.balance_low",
502
+ "timestamp": "2025-08-04T06:15:00.000000Z",
503
+ "data": {
504
+ "payload_type": "CreditBalanceLow",
505
+ "customer_id": "cus_8VbC6JDZzPEqfBPUdpj0K",
506
+ "subscription_id": "sub_7EeHq2ewQuadropD2ra",
507
+ "credit_entitlement_id": "cent_9xY2bKwQn5MjRpL8d",
508
+ "credit_entitlement_name": "API Credits",
509
+ "available_balance": "15",
510
+ "subscription_credits_amount": "100",
511
+ "threshold_percent": 20,
512
+ "threshold_amount": "20"
513
+ }
832
514
  }
833
515
  ```
834
516
 
835
- ---
517
+ ### Low-balance notification flow
836
518
 
837
- ## Real-World Pricing Examples
519
+ 1. Verify the raw webhook and deduplicate by `webhook-id` as described in `webhook-integration`.
520
+ 2. Confirm `type === 'credit.balance_low'` and validate the dedicated payload.
521
+ 3. Map `customer_id` to the authenticated application account; do not accept a customer ID supplied by a browser.
522
+ 4. Read the current balance with `balances.retrieve(...)` because another grant or deduction may have occurred since emission.
523
+ 5. If the balance is still below your customer-notification policy, enqueue one notification keyed by webhook ID or threshold occurrence.
524
+ 6. Link to a one-time top-up checkout or plan-upgrade flow.
525
+ 7. Record notification delivery separately from the Dodo ledger; never manufacture a ledger entry for an email.
838
526
 
839
- ### AI SaaS Platform
527
+ ## Enforce credits safely
840
528
 
841
- ```
842
- Plan | Price | Credits/Month | Overage
843
- ----------|----------|----------------|----------
844
- Starter | $29/mo | 10,000 tokens | $0.003/token
845
- Pro | $99/mo | 100,000 tokens | $0.002/token
846
- Enterprise| $499/mo | 1,000,000 tokens| $0.001/token
847
-
848
- Credit Config:
849
- Type: Custom Unit ("AI Tokens"), Precision: 0
850
- Rollover: 25% max, 1-month timeframe
851
- Overage: Bill overage at billing
852
- Meter: ai.generation (Sum on tokens)
853
- ```
854
-
855
- ### API Gateway
856
-
857
- ```
858
- Plan | Price | Credits/Month | Overage
859
- ----------|----------|----------------|----------
860
- Free | $0/mo | 1,000 calls | Blocked
861
- Developer | $19/mo | 50,000 calls | $0.001/call
862
- Business | $99/mo | 500,000 calls | $0.0005/call
863
-
864
- Credit Config:
865
- Type: Custom Unit ("API Calls"), Precision: 0
866
- Rollover: Disabled
867
- Overage: Free=disabled, Dev+=forgive at reset
868
- Meter: api.request (Count)
869
- ```
870
-
871
- ### Cloud Storage
529
+ ### Best-effort balance gate
872
530
 
873
- ```
874
- Plan | Price | Credits/Month | Overage
875
- ----------|----------|----------------|----------
876
- Personal | $9/mo | 100 GB-hours | $0.05/GB-hour
877
- Team | $49/mo | 1,000 GB-hours | $0.03/GB-hour
878
-
879
- Credit Config:
880
- Type: Custom Unit ("GB-hours"), Precision: 2
881
- Rollover: 50% max, carries over once
882
- Overage: Enabled with 200% limit
883
- Meter: storage.usage (Sum)
884
- ```
531
+ For a precision-`0` entitlement, a server can perform a coarse pre-check with integer-safe parsing:
885
532
 
886
- ---
887
-
888
- ## Credit Ledger
889
-
890
- Every credit operation is recorded with full audit trail:
891
-
892
- | Transaction Type | Description |
893
- |-----------------|-------------|
894
- | **Credit Added** | Credits granted (subscription, one-time, or API) |
895
- | **Credit Deducted** | Credits consumed through usage or manual debit |
896
- | **Credit Expired** | Credits expired without rollover |
897
- | **Credit Rolled Over** | Credits carried forward to the next period |
898
- | **Rollover Forfeited** | Rolled credits forfeited after max count reached |
899
- | **Overage Charged** | Usage beyond credit balance with overage enabled |
900
- | **Auto Top-Up** | Automatic credit replenishment at low balance |
901
- | **Manual Adjustment** | Credit or debit applied manually by merchant |
902
- | **Refund** | Credits refunded |
903
-
904
- Each ledger entry records balance before/after, overage before/after, description, and reference to the source.
905
-
906
- ---
907
-
908
- ## Best Practices
909
-
910
- ### 1. Start Simple
911
- Begin with a single credit type and no rollover. Add complexity based on customer usage patterns.
912
-
913
- ### 2. Use Meaningful Units
914
- Name credits after what they represent ("API Calls", "AI Tokens") not generic terms.
915
-
916
- ### 3. Set Low Balance Thresholds
917
- Configure thresholds and subscribe to `credit.balance_low` to alert customers before they run out.
918
-
919
- ### 4. Use Idempotency Keys
920
- Always include idempotency keys for manual ledger entries to prevent double-granting:
921
533
  ```typescript
922
- idempotency_key: `promo_${promoCode}_${customerId}`
923
- ```
924
-
925
- ### 5. Configure Expiry Thoughtfully
926
- Short expiry (7 days) drives urgency but may frustrate. 30–90 days is customer-friendly for most SaaS.
927
-
928
- ### 6. Test the Full Cycle
929
- In test mode: create credits → attach to product → purchase → send usage events → verify deduction → test expiration and rollover.
534
+ async function hasAtLeastWholeCredits(
535
+ customerId: string,
536
+ creditEntitlementId: string,
537
+ required: bigint,
538
+ ): Promise<boolean> {
539
+ const response = await client.creditEntitlements.balances.retrieve(
540
+ customerId,
541
+ { credit_entitlement_id: creditEntitlementId },
542
+ );
930
543
 
931
- ### 7. Monitor Overage
932
- If using "Bill overage at billing", monitor overage amounts to avoid unexpected charges for customers.
544
+ return BigInt(response.balance) >= required;
545
+ }
546
+ ```
933
547
 
934
- ---
548
+ Use this only for precision `0`. For fractional credits, use an arbitrary-precision decimal implementation that preserves the entitlement's configured precision.
549
+
550
+ This read is not a reservation. Two concurrent requests can both observe enough balance, and automatic meter deductions may not yet be reflected.
551
+
552
+ ### Strict request enforcement
553
+
554
+ Choose one of these patterns:
555
+
556
+ - **Predictable fixed cost:** create an idempotent manual debit before serving. Treat an insufficient-balance `400` as denial. If work fails after debit, issue a separately idempotent compensating credit with a linked reason. Do not also meter the same usage.
557
+ - **Variable cost such as AI tokens:** reserve estimated capacity atomically in your own datastore, enforce per-customer concurrency, ingest actual usage afterward, and reconcile local reservations with Dodo webhooks and ledger entries.
558
+ - **Overage product:** authorize against the product's overage policy and your own risk limit, not merely `balance > 0`.
559
+
560
+ Return an application-level payment/credit-required response with a top-up or upgrade path when authorization fails. Do not expose Dodo API errors or secrets to the client.
561
+
562
+ ### Race conditions to avoid
563
+
564
+ - **Check then act:** a remote balance read followed by work is not atomic.
565
+ - **Immediate post-event read:** the meter worker may not have processed the event.
566
+ - **Parallel workers:** multiple jobs can consume the same apparent balance without a local reservation or atomic debit.
567
+ - **Retry with new IDs:** changing `event_id` or `idempotency_key` after a timeout can duplicate usage or adjustments.
568
+ - **Cache as authority:** webhook-maintained balances are useful for display, not strict authorization.
569
+ - **Mixed deduction paths:** automatic meter deduction plus manual debit double-counts consumption.
570
+
571
+ ## Common mistakes
572
+
573
+ 1. **Confusing two resources.** `creditEntitlements` and `entitlements` are different. Credit balances do not live under `client.entitlements.grants`.
574
+ 2. **Calling invented balance methods.** Use `balances.retrieve(customerId, { credit_entitlement_id })`, not `balances.get(creditId, customerId)`.
575
+ 3. **Using the wrong ledger argument order.** `createLedgerEntry` receives `customerId` first; `credit_entitlement_id` belongs in the request object.
576
+ 4. **Using `type` or `description` for adjustments.** Confirmed fields are `entry_type` and `reason`.
577
+ 5. **Getting the grants argument order wrong.** `creditEntitlements.balances.listGrants(customerID, params)` does exist and takes the customer id first, with the entitlement supplied in the params object. It returns a paginated page, so read `.items` rather than treating the result as an array. Use `customers.listCreditEntitlements(customerID)` when you want the customer-level summary instead.
578
+ 6. **Treating `available_balance` as the balance API field.** The confirmed balance response uses `balance` and `overage`; `available_balance` belongs to the dedicated low-balance webhook payload.
579
+ 7. **Choosing precision casually.** It supports `0–10` for custom units and cannot be changed after creation.
580
+ 8. **Using JavaScript floating point for credits.** API amounts are decimal strings. Preserve them as strings or use decimal arithmetic.
581
+ 9. **Exceeding adjustment bounds.** Ledger amounts map to `NUMERIC(38,28)`, and the integer part must be below `10^10`.
582
+ 10. **Misstating monetary units.** Currency amounts use the smallest currency unit. Do not label cents as dollars.
583
+ 11. **Using unstable idempotency keys.** A timestamp or random key on every retry defeats deduplication. Derive the key from the immutable business operation.
584
+ 12. **Retrying an insufficient debit blindly.** Manual debits can return `400` for insufficient balance; route the customer to top-up or your overage policy.
585
+ 13. **Assuming event ingestion is synchronous deduction.** Meter-to-credit processing occurs approximately every minute.
586
+ 14. **Using Dodo's balance as a per-request lock.** The docs explicitly warn against strict authorization from asynchronously reported meter balances.
587
+ 15. **Mismatching meter names.** Usage `event_name` matching is case-sensitive.
588
+ 16. **Sending duplicate IDs within one batch.** Duplicate `event_id` values in the same ingestion request reject the entire request. Previously ingested IDs are ignored on retry.
589
+ 17. **Inventing a deduplication window.** Current docs require unique event IDs but do not publish a retention duration.
590
+ 18. **Getting conversion backward.** `meter_units_per_credit` is usage units required for one credit, and is required when `credit_entitlement_id` is set.
591
+ 19. **Ignoring the free threshold.** Usage under `free_threshold` is excluded before credit deduction.
592
+ 20. **Supplying one rollover timeframe field.** `rollover_timeframe_count` and `rollover_timeframe_interval` must be supplied together.
593
+ 21. **Using the wrong rollover enum case.** Confirmed values are `Day`, `Week`, `Month`, and `Year`.
594
+ 22. **Assuming expiry happens before rollover.** Rollover is applied first; only the remainder expires.
595
+ 23. **Inventing overage enum values.** Use only `forgive_at_reset`, `invoice_at_billing`, `carry_deficit`, or `carry_deficit_auto_repay`.
596
+ 24. **Omitting overage pricing dependencies.** Enabled overage requires `price_per_unit`; a price requires `currency`.
597
+ 25. **Attaching too many credits.** The current product limit is five credit entitlements.
598
+ 26. **Assuming checkout grants arbitrary credits.** Credits must already be attached to the purchased product or add-on.
599
+ 27. **Handling only the common webhooks.** Include `credit.overage_reset` and `credit.rollover_forfeited`, not only added, deducted, and low-balance events.
600
+ 28. **Parsing an unverified webhook.** Verify the raw body with `client.webhooks.unwrap()` as covered in `webhook-integration`; never hand-roll HMAC or trust re-serialized JSON.
601
+ 29. **Inventing historical snapshot APIs.** Use ledger `balance_before` and `balance_after` for transaction-level history.
602
+ 30. **Using deprecated purchase methods.** New checkout flows use `client.checkoutSessions.create(...)`, not `payments.create(...)` or `subscriptions.create(...)`.
603
+ 31. **Using the wrong host or key format.** Valid hosts are `https://test.dodopayments.com` and `https://live.dodopayments.com`; keys start with `dodo_test_` or `dodo_live_`.
935
604
 
936
605
  ## Resources
937
606
 
938
607
  - [Credit-Based Billing Guide](https://docs.dodopayments.com/features/credit-based-billing)
939
- - [Credit Entitlements API](https://docs.dodopayments.com/api-reference/credit-entitlements/create-credit-entitlement)
940
- - [Credit Webhook Events](https://docs.dodopayments.com/developer-resources/webhooks/intents/credit)
941
- - [Usage-Based Billing](https://docs.dodopayments.com/features/usage-based-billing/introduction)
942
- - [Subscription Integration](https://docs.dodopayments.com/developer-resources/subscription-integration-guide)
608
+ - [Create Credit Entitlement API](https://docs.dodopayments.com/api-reference/credit-entitlements/create-credit-entitlement)
609
+ - [Get Customer Balance API](https://docs.dodopayments.com/api-reference/credit-entitlements/get-customer-balance)
610
+ - [Create Ledger Entry API](https://docs.dodopayments.com/api-reference/credit-entitlements/create-ledger-entry)
611
+ - [List Customer Ledger API](https://docs.dodopayments.com/api-reference/credit-entitlements/list-customer-ledger)
612
+ - [Credit Webhook Reference](https://docs.dodopayments.com/developer-resources/webhooks/intents/credit)
613
+ - [Meter Guide](https://docs.dodopayments.com/features/usage-based-billing/meters)
614
+ - [Usage Event Ingestion API](https://docs.dodopayments.com/api-reference/usage-events/ingest-events)
615
+ - [Create Product API](https://docs.dodopayments.com/api-reference/products/post-products)