@dodopayments/opencode-plugin 0.3.3 → 0.5.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,6 +1,6 @@
1
1
  ---
2
2
  name: usage-based-billing
3
- description: Guide for implementing usage-based billing with Dodo Payments - meters, events, pricing per unit, and metered subscriptions.
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.
4
4
  ---
5
5
 
6
6
  # Dodo Payments Usage-Based Billing
@@ -11,20 +11,21 @@ Charge customers for what they actually use—API calls, storage, AI tokens, or
11
11
 
12
12
  ---
13
13
 
14
- ## Overview
14
+ ## When to use this skill
15
15
 
16
- Usage-based billing is perfect for:
17
- - **APIs**: Charge per request or operation
18
- - **AI Services**: Bill per token, generation, or inference
19
- - **Infrastructure**: Charge for compute, storage, bandwidth
20
- - **SaaS**: Metered features alongside subscriptions
16
+ - You need to bill customers based on consumption (API calls, tokens, storage, bandwidth).
17
+ - You want to combine usage charges with subscriptions or one-time purchases.
18
+ - You need to track and aggregate events into billable quantities.
19
+ - You're building an AI service, SaaS platform, or infrastructure product with metered features.
21
20
 
22
21
  ---
23
22
 
24
23
  ## Core Concepts
25
24
 
26
25
  ### Events
27
- Usage actions sent from your application:
26
+
27
+ Usage records sent from your application to Dodo. Each event is attributed to a customer and matched to a meter by its `event_name`.
28
+
28
29
  ```json
29
30
  {
30
31
  "event_id": "evt_unique_123",
@@ -36,129 +37,159 @@ Usage actions sent from your application:
36
37
  ```
37
38
 
38
39
  ### Meters
39
- Aggregate events into billable quantities:
40
- | Aggregation | Use Case | Example |
41
- |-------------|----------|---------|
42
- | **Count** | Total events | API calls, image generations |
43
- | **Sum** | Add values | Tokens used, bytes transferred |
44
- | **Max** | Highest value | Peak concurrent users |
45
- | **Last** | Most recent | Current storage used |
46
40
 
47
- ### Products with Usage Pricing
48
- - Price per unit (e.g., $0.001 per API call)
49
- - Free threshold (e.g., 1,000 free calls)
50
- - Automatic billing each cycle
41
+ Filters and aggregates events into billable quantities. A meter specifies:
42
+ - **Event name**: which events to match (case-sensitive)
43
+ - **Aggregation type**: how to combine events (count, sum, max, last)
44
+ - **Measurement unit**: the billing unit (calls, tokens, GB, etc.)
45
+ - **Optional filters**: conditions events must meet to be counted
51
46
 
52
- **Billing Example**: 2,500 calls - 1,000 free = 1,500 × $0.02 = $30.00
53
-
54
- ---
47
+ ### Aggregation Types
55
48
 
56
- ## Quick Start
49
+ | Type | Use Case | Example |
50
+ |------|----------|---------|
51
+ | **Count** | Total events | API calls, image generations |
52
+ | **Sum** | Add values from a property | Tokens used, bytes transferred |
53
+ | **Max** | Highest value in a period | Peak concurrent users |
54
+ | **Last** | Most recent value | Current storage used |
57
55
 
58
- ### 1. Create a Meter
56
+ For `sum`, `max`, and `last`, you specify which metadata property to aggregate.
59
57
 
60
- In Dashboard → Meters → Create Meter:
58
+ ### Pricing
61
59
 
62
- 1. **Name**: "API Requests"
63
- 2. **Event Name**: `api.call` (exact match, case-sensitive)
64
- 3. **Aggregation**: Count
65
- 4. **Unit**: "calls"
60
+ Attach a meter to a product price to charge per unit:
61
+ - **Price per unit**: e.g., $0.001 per API call
62
+ - **Free threshold**: e.g., 1,000 free calls per month
63
+ - **Charge formula**: `(usage − threshold) × price_per_unit`
66
64
 
67
- ### 2. Create Usage-Based Product
65
+ **Example**: 2,500 calls − 1,000 free = 1,500 × $0.02 = $30.00
68
66
 
69
- In Dashboard → Products → Create Product:
67
+ ---
70
68
 
71
- 1. Select **Usage-Based** type
72
- 2. Connect your meter
73
- 3. Set pricing:
74
- - **Price Per Unit**: $0.001
75
- - **Free Threshold**: 1000
69
+ ## Meter Lifecycle
76
70
 
77
- ### 3. Send Events
71
+ ### Create a Meter
78
72
 
79
73
  ```typescript
80
74
  import DodoPayments from 'dodopayments';
81
75
 
82
76
  const client = new DodoPayments({
83
77
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
78
+ environment: 'test_mode',
84
79
  });
85
80
 
86
- await client.usageEvents.ingest({
87
- events: [{
88
- event_id: `api_${Date.now()}_${Math.random()}`,
89
- customer_id: 'cus_abc123',
90
- event_name: 'api.call',
91
- timestamp: new Date().toISOString(),
92
- metadata: {
93
- endpoint: '/v1/users',
94
- method: 'GET',
95
- }
96
- }]
81
+ const meter = await client.meters.create({
82
+ name: 'API Requests',
83
+ event_name: 'api.call',
84
+ aggregation: { type: 'count' },
85
+ measurement_unit: 'calls',
86
+ description: 'Track API calls per customer',
97
87
  });
88
+
89
+ console.log(meter.id); // mtr_...
98
90
  ```
99
91
 
100
- ---
92
+ For a sum aggregation, specify the property to aggregate:
101
93
 
102
- ## Implementation Examples
94
+ ```typescript
95
+ const meter = await client.meters.create({
96
+ name: 'Token Usage',
97
+ event_name: 'ai.tokens',
98
+ aggregation: { type: 'sum', key: 'tokens' },
99
+ measurement_unit: 'tokens',
100
+ });
101
+ ```
103
102
 
104
- ### TypeScript/Node.js
103
+ ### List and Retrieve Meters
105
104
 
106
105
  ```typescript
107
- import DodoPayments from 'dodopayments';
106
+ // List all meters
107
+ const meters = await client.meters.list();
108
108
 
109
- const client = new DodoPayments({
110
- bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
111
- });
109
+ // Retrieve a specific meter
110
+ const meter = await client.meters.retrieve('mtr_abc123');
111
+ ```
112
+
113
+ ### Archive and Unarchive
114
+
115
+ Meters are archived, not deleted. Archived meters stop accepting new events but retain historical data.
112
116
 
113
- // Track single event
114
- async function trackUsage(
117
+ ```typescript
118
+ // Archive a meter
119
+ await client.meters.archive('mtr_abc123');
120
+
121
+ // Unarchive to resume
122
+ await client.meters.unarchive('mtr_abc123');
123
+ ```
124
+
125
+ ---
126
+
127
+ ## Event Ingestion
128
+
129
+ ### Send Events
130
+
131
+ ```typescript
132
+ async function recordApiCall(
115
133
  customerId: string,
116
- eventName: string,
117
- metadata: Record<string, string>
118
- ) {
119
- await client.usageEvents.ingest({
134
+ requestId: string,
135
+ occurredAt: string,
136
+ ): Promise<number> {
137
+ const response = await client.usageEvents.ingest({
120
138
  events: [{
121
- event_id: `${eventName}_${Date.now()}_${crypto.randomUUID()}`,
139
+ event_id: `api-call:${requestId}`,
122
140
  customer_id: customerId,
123
- event_name: eventName,
124
- timestamp: new Date().toISOString(),
125
- metadata,
126
- }]
141
+ event_name: 'api.call',
142
+ timestamp: occurredAt,
143
+ metadata: {
144
+ endpoint: '/v1/users',
145
+ method: 'GET',
146
+ },
147
+ }],
127
148
  });
149
+
150
+ return response.ingested_count;
128
151
  }
152
+ ```
129
153
 
130
- // Track API call
131
- await trackUsage('cus_abc123', 'api.call', {
132
- endpoint: '/v1/generate',
133
- method: 'POST',
134
- });
154
+ `requestId` must identify the underlying API operation and remain unchanged across retries. Do not generate it inside the ingestion attempt.
135
155
 
136
- // Track token usage (for Sum aggregation)
137
- await trackUsage('cus_abc123', 'token.usage', {
138
- tokens: '1500',
139
- model: 'gpt-4',
140
- });
141
- ```
156
+ ### Event Schema
157
+
158
+ | Field | Required | Notes |
159
+ |-------|----------|-------|
160
+ | `event_id` | Yes | Unique identifier for idempotency. Duplicate IDs in the same request reject the entire request. |
161
+ | `customer_id` | Yes | Dodo Payments customer ID. |
162
+ | `event_name` | Yes | Must match a meter's event name exactly (case-sensitive). |
163
+ | `timestamp` | No | ISO-8601 datetime. Defaults to current UTC time. Must be within one hour in the past or five minutes in the future. |
164
+ | `metadata` | No | Object with string, integer, number, or boolean values. Max 50 pairs; key length 100, value length 500. No nested objects or arrays. |
165
+
166
+ ### Idempotency and Deduplication
142
167
 
143
- ### Batch Event Ingestion
168
+ - Each distinct operation gets one globally unique `event_id`.
169
+ - Derive the ID from an immutable request, job, generation, or snapshot ID and reuse it on every retry.
170
+ - Duplicate IDs in a single request reject the entire batch.
171
+ - An ID already ingested in an earlier request is silently ignored, making retries safe.
144
172
 
145
- Send multiple events efficiently (max 1000 per request):
173
+ ### Batch Ingestion
174
+
175
+ Send up to 1,000 events per request:
146
176
 
147
177
  ```typescript
148
178
  async function trackBatchUsage(
149
179
  events: Array<{
180
+ operationId: string;
150
181
  customerId: string;
151
182
  eventName: string;
183
+ occurredAt: string;
152
184
  metadata: Record<string, string>;
153
- timestamp?: string;
154
185
  }>
155
186
  ) {
156
- const formattedEvents = events.map((e, i) => ({
157
- event_id: `batch_${Date.now()}_${i}_${crypto.randomUUID()}`,
158
- customer_id: e.customerId,
159
- event_name: e.eventName,
160
- timestamp: e.timestamp || new Date().toISOString(),
161
- metadata: e.metadata,
187
+ const formattedEvents = events.map((event) => ({
188
+ event_id: `usage:${event.operationId}`,
189
+ customer_id: event.customerId,
190
+ event_name: event.eventName,
191
+ timestamp: event.occurredAt,
192
+ metadata: event.metadata,
162
193
  }));
163
194
 
164
195
  await client.usageEvents.ingest({ events: formattedEvents });
@@ -166,250 +197,140 @@ async function trackBatchUsage(
166
197
 
167
198
  // Batch track multiple API calls
168
199
  await trackBatchUsage([
169
- { customerId: 'cus_abc', eventName: 'api.call', metadata: { endpoint: '/v1/users' } },
170
- { customerId: 'cus_abc', eventName: 'api.call', metadata: { endpoint: '/v1/orders' } },
171
- { customerId: 'cus_xyz', eventName: 'api.call', metadata: { endpoint: '/v1/products' } },
200
+ { operationId: 'req_101', customerId: 'cus_abc', eventName: 'api.call', occurredAt: '2026-08-01T10:00:00Z', metadata: { endpoint: '/v1/users' } },
201
+ { operationId: 'req_102', customerId: 'cus_abc', eventName: 'api.call', occurredAt: '2026-08-01T10:00:01Z', metadata: { endpoint: '/v1/orders' } },
202
+ { operationId: 'req_103', customerId: 'cus_xyz', eventName: 'api.call', occurredAt: '2026-08-01T10:00:02Z', metadata: { endpoint: '/v1/products' } },
172
203
  ]);
173
204
  ```
174
205
 
175
- ### Python
176
-
177
- ```python
178
- from dodopayments import DodoPayments
179
- import uuid
180
- from datetime import datetime
181
-
182
- client = DodoPayments(bearer_token=os.environ["DODO_PAYMENTS_API_KEY"])
183
-
184
- def track_usage(customer_id: str, event_name: str, metadata: dict):
185
- client.usage_events.ingest(events=[{
186
- "event_id": f"{event_name}_{datetime.now().timestamp()}_{uuid.uuid4()}",
187
- "customer_id": customer_id,
188
- "event_name": event_name,
189
- "timestamp": datetime.now().isoformat(),
190
- "metadata": metadata
191
- }])
192
-
193
- # Track AI token usage
194
- track_usage("cus_abc123", "ai.tokens", {
195
- "tokens": "2500",
196
- "model": "claude-3",
197
- "operation": "completion"
198
- })
199
-
200
- # Track image generation
201
- track_usage("cus_abc123", "image.generated", {
202
- "size": "1024x1024",
203
- "model": "dall-e-3"
204
- })
205
- ```
206
-
207
- ### Go
208
-
209
- ```go
210
- package main
211
-
212
- import (
213
- "context"
214
- "fmt"
215
- "os"
216
- "time"
217
-
218
- "github.com/dodopayments/dodopayments-go"
219
- "github.com/google/uuid"
220
- )
221
-
222
- func main() {
223
- client := dodopayments.NewClient(
224
- option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
225
- )
226
-
227
- ctx := context.Background()
228
-
229
- _, err := client.UsageEvents.Ingest(ctx, &dodopayments.UsageEventIngestParams{
230
- Events: []dodopayments.UsageEvent{{
231
- EventID: fmt.Sprintf("api_%d_%s", time.Now().Unix(), uuid.New().String()),
232
- CustomerID: "cus_abc123",
233
- EventName: "api.call",
234
- Timestamp: time.Now().Format(time.RFC3339),
235
- Metadata: map[string]string{
236
- "endpoint": "/v1/users",
237
- "method": "GET",
238
- },
239
- }},
240
- })
241
-
242
- if err != nil {
243
- panic(err)
244
- }
245
- }
246
- ```
247
-
248
- ---
249
-
250
- ## Meter Configuration
251
-
252
- ### Aggregation Types
253
-
254
- #### Count (API Calls, Requests)
255
- ```
256
- Meter: API Requests
257
- Event Name: api.call
258
- Aggregation: Count
259
- Unit: calls
260
- ```
261
-
262
- #### Sum (Tokens, Bytes)
263
- ```
264
- Meter: Token Usage
265
- Event Name: token.usage
266
- Aggregation: Sum
267
- Over Property: tokens
268
- Unit: tokens
269
- ```
206
+ ### Query Events
270
207
 
271
- Events must include the property in metadata:
272
208
  ```typescript
273
- await client.usageEvents.ingest({
274
- events: [{
275
- event_id: 'token_123',
276
- customer_id: 'cus_abc',
277
- event_name: 'token.usage',
278
- metadata: { tokens: '1500' } // This value gets summed
279
- }]
209
+ // List events for a customer
210
+ const events = await client.usageEvents.list({
211
+ customer_id: 'cus_abc123',
280
212
  });
281
- ```
282
213
 
283
- #### Max (Peak Concurrent Users)
284
- ```
285
- Meter: Peak Users
286
- Event Name: concurrent.users
287
- Aggregation: Max
288
- Over Property: count
289
- Unit: users
214
+ // Retrieve a specific event
215
+ const event = await client.usageEvents.retrieve('evt_abc123');
290
216
  ```
291
217
 
292
- #### Last (Current Storage)
293
- ```
294
- Meter: Storage Used
295
- Event Name: storage.snapshot
296
- Aggregation: Last
297
- Over Property: bytes
298
- Unit: GB
299
- ```
218
+ ---
300
219
 
301
- ### Event Filtering
220
+ ## Pricing Models
302
221
 
303
- Filter which events count toward the meter:
222
+ ### Per-Unit Pricing
304
223
 
305
- ```
306
- Filter Logic: AND
307
- Conditions:
308
- - Property: tier, Equals: "premium"
309
- - Property: status, Equals: "success"
310
- ```
224
+ The only currently documented and operable pricing model. A meter attachment uses:
225
+ - `price_per_unit`: decimal string (max 5 integer digits, 12 decimal places)
226
+ - `free_threshold`: optional integer (usage below this is not charged)
227
+
228
+ Charge formula: `(usage − threshold) × price_per_unit`
229
+
230
+ The `product-catalog-management` skill is the canonical source for the complete product creation request. It defines the singular `price` object with `type: 'usage_based_price'` and its nested `meters` array; do not define a parallel product schema here.
311
231
 
312
- Only events matching ALL conditions are counted.
232
+ **Note:** Tiered, graduated, volume, and staircase pricing models are not currently documented in the Dodo Payments API. Use per-unit pricing with free thresholds for now.
313
233
 
314
234
  ---
315
235
 
316
- ## Common Use Cases
236
+ ## Instrumenting Your Application
317
237
 
318
- ### AI Token Billing
238
+ ### Track API Calls
239
+
240
+ Persist the event before reporting the operation as complete, then ingest it from a retrying worker. The outbox or queue implementation must durably store the payload before `persist` resolves.
319
241
 
320
242
  ```typescript
321
- // Meter: AI Tokens (Sum aggregation over "tokens" property)
243
+ type PersistedUsageEvent = {
244
+ event_id: string;
245
+ customer_id: string;
246
+ event_name: string;
247
+ timestamp: string;
248
+ metadata: Record<string, string | number | boolean>;
249
+ };
250
+
251
+ interface UsageOutbox {
252
+ persist(event: PersistedUsageEvent): Promise<void>;
253
+ nextBatch(limit: number): Promise<PersistedUsageEvent[]>;
254
+ markIngested(eventIds: string[]): Promise<void>;
255
+ }
322
256
 
323
- async function trackAIUsage(
257
+ async function completeApiOperation(
258
+ outbox: UsageOutbox,
259
+ operationId: string,
324
260
  customerId: string,
325
- promptTokens: number,
326
- completionTokens: number,
327
- model: string
328
- ) {
329
- const totalTokens = promptTokens + completionTokens;
330
-
331
- await client.usageEvents.ingest({
332
- events: [{
333
- event_id: `ai_${Date.now()}_${crypto.randomUUID()}`,
334
- customer_id: customerId,
335
- event_name: 'ai.tokens',
336
- timestamp: new Date().toISOString(),
337
- metadata: {
338
- tokens: totalTokens.toString(),
339
- prompt_tokens: promptTokens.toString(),
340
- completion_tokens: completionTokens.toString(),
341
- model,
342
- }
343
- }]
261
+ occurredAt: string,
262
+ ): Promise<void> {
263
+ await outbox.persist({
264
+ event_id: `api-call:${operationId}`,
265
+ customer_id: customerId,
266
+ event_name: 'api.call',
267
+ timestamp: occurredAt,
268
+ metadata: { endpoint: '/v1/users', method: 'GET', status: 200 },
344
269
  });
345
270
  }
346
271
 
347
- // After AI completion
348
- await trackAIUsage('cus_abc', 500, 1200, 'gpt-4');
272
+ async function ingestUsageOutbox(outbox: UsageOutbox): Promise<void> {
273
+ const events = await outbox.nextBatch(1000);
274
+ if (events.length === 0) return;
275
+
276
+ await client.usageEvents.ingest({ events });
277
+ await outbox.markIngested(events.map((event) => event.event_id));
278
+ }
349
279
  ```
350
280
 
351
- ### Image Generation
281
+ If the worker crashes after Dodo accepts the batch but before `markIngested`, retry the same persisted events with the same IDs. Dodo ignores the already-ingested IDs.
352
282
 
353
- ```typescript
354
- // Meter: Images Generated (Count aggregation)
283
+ ### Track AI Token Usage
355
284
 
356
- async function trackImageGeneration(
285
+ ```typescript
286
+ async function callAI(
357
287
  customerId: string,
358
- imageSize: string,
359
- model: string
288
+ generationId: string,
289
+ prompt: string,
290
+ completedAt: string,
360
291
  ) {
361
- await client.usageEvents.ingest({
362
- events: [{
363
- event_id: `img_${Date.now()}_${crypto.randomUUID()}`,
364
- customer_id: customerId,
365
- event_name: 'image.generated',
366
- timestamp: new Date().toISOString(),
367
- metadata: {
368
- size: imageSize,
369
- model,
370
- }
371
- }]
292
+ const response = await openai.chat.completions.create({
293
+ model: 'gpt-4',
294
+ messages: [{ role: 'user', content: prompt }],
372
295
  });
373
- }
374
- ```
375
-
376
- ### API Rate Tracking
377
-
378
- ```typescript
379
- // Middleware for Express/Next.js
380
296
 
381
- async function trackAPIUsage(
382
- req: Request,
383
- customerId: string
384
- ) {
297
+ // Track tokens after completion
385
298
  await client.usageEvents.ingest({
386
299
  events: [{
387
- event_id: `api_${Date.now()}_${crypto.randomUUID()}`,
300
+ event_id: `generation:${generationId}`,
388
301
  customer_id: customerId,
389
- event_name: 'api.call',
390
- timestamp: new Date().toISOString(),
302
+ event_name: 'ai.tokens',
303
+ timestamp: completedAt,
391
304
  metadata: {
392
- endpoint: req.url,
393
- method: req.method,
394
- user_agent: req.headers['user-agent'] || 'unknown',
305
+ tokens: response.usage.total_tokens.toString(),
306
+ prompt_tokens: response.usage.prompt_tokens.toString(),
307
+ completion_tokens: response.usage.completion_tokens.toString(),
308
+ model: 'gpt-4',
395
309
  }
396
310
  }]
397
311
  });
312
+
313
+ return response;
398
314
  }
399
315
  ```
400
316
 
401
- ### Storage Billing
317
+ ### Track Storage Usage
402
318
 
403
- ```typescript
404
- // Meter: Storage (Last aggregation - snapshot of current usage)
319
+ For snapshot-based metrics (current state), use the `last` aggregation:
405
320
 
406
- async function updateStorageUsage(customerId: string, bytesUsed: number) {
321
+ ```typescript
322
+ async function updateStorageUsage(
323
+ customerId: string,
324
+ snapshotId: string,
325
+ bytesUsed: number,
326
+ capturedAt: string,
327
+ ) {
407
328
  await client.usageEvents.ingest({
408
329
  events: [{
409
- event_id: `storage_${Date.now()}_${customerId}`,
330
+ event_id: `storage-snapshot:${snapshotId}`,
410
331
  customer_id: customerId,
411
332
  event_name: 'storage.snapshot',
412
- timestamp: new Date().toISOString(),
333
+ timestamp: capturedAt,
413
334
  metadata: {
414
335
  bytes: bytesUsed.toString(),
415
336
  gb: (bytesUsed / 1024 / 1024 / 1024).toFixed(2),
@@ -419,253 +340,191 @@ async function updateStorageUsage(customerId: string, bytesUsed: number) {
419
340
  }
420
341
 
421
342
  // Call periodically or after storage changes
422
- await updateStorageUsage('cus_abc', 5368709120); // 5GB
343
+ await updateStorageUsage(
344
+ 'cus_abc',
345
+ 'snapshot_01K1M4D2K9',
346
+ 5368709120,
347
+ '2026-08-01T10:30:00Z',
348
+ ); // 5GB
423
349
  ```
424
350
 
425
351
  ---
426
352
 
427
- ## Next.js Integration
353
+ ## Querying Usage for Display
428
354
 
429
- ### API Route for Usage Tracking
355
+ ### Retrieve Usage History
430
356
 
431
357
  ```typescript
432
- // app/api/track-usage/route.ts
433
- import { NextRequest, NextResponse } from 'next/server';
434
- import DodoPayments from 'dodopayments';
435
-
436
- const client = new DodoPayments({
437
- bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
438
- });
358
+ const usage = await client.subscriptions.retrieveUsageHistory(
359
+ 'sub_abc123',
360
+ { page_size: 100 }
361
+ );
439
362
 
440
- export async function POST(req: NextRequest) {
441
- const { customerId, eventName, metadata } = await req.json();
442
-
443
- try {
444
- await client.usageEvents.ingest({
445
- events: [{
446
- event_id: `${eventName}_${Date.now()}_${crypto.randomUUID()}`,
447
- customer_id: customerId,
448
- event_name: eventName,
449
- timestamp: new Date().toISOString(),
450
- metadata,
451
- }]
452
- });
453
-
454
- return NextResponse.json({ success: true });
455
- } catch (error: any) {
456
- return NextResponse.json(
457
- { error: error.message },
458
- { status: 500 }
459
- );
460
- }
461
- }
363
+ console.log(usage.items); // Array of billing-period usage records
462
364
  ```
463
365
 
464
- ### Usage Tracking Hook
465
-
466
- ```typescript
467
- // hooks/useUsageTracking.ts
468
- import { useCallback } from 'react';
469
-
470
- export function useUsageTracking(customerId: string) {
471
- const trackUsage = useCallback(async (
472
- eventName: string,
473
- metadata: Record<string, string>
474
- ) => {
475
- await fetch('/api/track-usage', {
476
- method: 'POST',
477
- headers: { 'Content-Type': 'application/json' },
478
- body: JSON.stringify({ customerId, eventName, metadata }),
479
- });
480
- }, [customerId]);
481
-
482
- return { trackUsage };
483
- }
484
-
485
- // Usage in component
486
- function AIChat() {
487
- const { trackUsage } = useUsageTracking('cus_abc123');
488
-
489
- const handleGenerate = async () => {
490
- const result = await generateAIResponse(prompt);
491
-
492
- // Track token usage
493
- await trackUsage('ai.tokens', {
494
- tokens: result.totalTokens.toString(),
495
- model: 'gpt-4',
496
- });
497
- };
498
- }
499
- ```
366
+ This returns aggregated usage per meter for the subscription's current billing period.
500
367
 
501
368
  ---
502
369
 
503
- ## Hybrid Billing
370
+ ## Credit-Based Billing Integration
504
371
 
505
- Combine usage-based with subscriptions:
372
+ Usage events can deduct from a customer's credit balance instead of charging per-unit. See the `credit-based-billing` skill for full details on credit entitlements, balances, and ledger management.
506
373
 
507
- ### Subscription + Usage Overage
374
+ To link a meter to credits:
508
375
 
509
- ```typescript
510
- // 1. Customer subscribes to plan with included usage
511
- // Product: Pro Plan - $49/month + $0.01/call after 10,000 free
376
+ 1. Create a credit entitlement (e.g., "AI Credits").
377
+ 2. Attach the credit entitlement to the same product.
378
+ 3. On the meter, enable **Bill usage in Credits**.
379
+ 4. Set `credit_entitlement_id` and `meter_units_per_credit` (e.g., 1,000 tokens = 1 credit).
512
380
 
513
- // 2. Track all usage events
514
- await trackUsage('cus_abc', 'api.call', { endpoint: '/v1/generate' });
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.
515
382
 
516
- // 3. Dodo automatically:
517
- // - Applies free threshold (10,000 calls)
518
- // - Charges overage at $0.01/call
519
- // - Combines with subscription fee
520
- ```
383
+ ---
521
384
 
522
- ### Multiple Meters per Product
385
+ ## Webhook Integration
523
386
 
524
- Attach up to 10 meters to a single product:
387
+ Usage events trigger webhooks for monitoring and reconciliation. See the `webhook-integration` skill for webhook setup and verification.
525
388
 
526
- ```
527
- Product: AI Platform
528
- ├── Meter: API Calls ($0.001/call, 1000 free)
529
- ├── Meter: Token Usage ($0.01/1000 tokens)
530
- ├── Meter: Image Generations ($0.05/image, 10 free)
531
- └── Meter: Storage ($0.10/GB)
532
- ```
389
+ ---
533
390
 
534
- ### Credit-Based Meter Billing
391
+ ## Common Mistakes
535
392
 
536
- Link meters to credit entitlements so usage events deduct from a customer's credit balance instead of charging per-unit:
393
+ ### 1. Using Unstable or Reused Event IDs
537
394
 
538
- 1. Create a credit entitlement (Dashboard → Products → Credits)
539
- 2. Create a usage-based product with a meter
540
- 3. On the meter, toggle **Bill usage in Credits**
541
- 4. Select the credit entitlement and set **Meter units per credit**
395
+ Generate one ID from the immutable business operation. Reusing an ID for a different operation drops usage, while generating a timestamp or random ID on every retry can bill the same operation twice.
542
396
 
543
397
  ```typescript
544
- // Meter: AI Tokens (Sum aggregation over "tokens")
545
- // Credit: "AI Credits" with 10,000 credits/cycle
546
- // Meter units per credit: 1000 (1,000 tokens = 1 credit)
547
-
548
- // Usage events deduct credits automatically
398
+ // WRONG — a retry creates a new billable event
549
399
  await client.usageEvents.ingest({
550
400
  events: [{
551
- event_id: `ai_${Date.now()}_${crypto.randomUUID()}`,
552
- customer_id: 'cus_abc123',
553
- event_name: 'ai.tokens',
554
- timestamp: new Date().toISOString(),
555
- metadata: { tokens: '1500', model: 'gpt-4' }
556
- }]
401
+ event_id: `api-call:${Date.now()}:${crypto.randomUUID()}`,
402
+ customer_id: 'cus_abc',
403
+ event_name: 'api.call',
404
+ }],
557
405
  });
558
406
 
559
- // Check remaining credit balance
560
- const balance = await client.creditEntitlements.balances.get(
561
- 'cent_ai_credits',
562
- 'cus_abc123'
563
- );
564
- console.log(`Credits remaining: ${balance.available_balance}`);
407
+ // CORRECT — retry request req_123 with this same ID
408
+ await client.usageEvents.ingest({
409
+ events: [{
410
+ event_id: 'api-call:req_123',
411
+ customer_id: 'cus_abc',
412
+ event_name: 'api.call',
413
+ }],
414
+ });
565
415
  ```
566
416
 
567
- Credit deduction runs via a background worker every minute using FIFO ordering (oldest grants consumed first). When credits run out:
568
- - **Overage disabled**: Usage is blocked
569
- - **Overage enabled**: Usage continues and overage is tracked per your configured behavior (forgive, bill, or carry deficit)
570
- ---
417
+ Do not reuse `api-call:req_123` for a distinct request. This matches credit ledger guidance: a timeout is not permission to generate a fresh idempotency key.
571
418
 
572
- ## Best Practices
419
+ ### 2. Using Fire-and-Forget Ingestion
420
+
421
+ Do not start ingestion after responding without first persisting the event. The process can crash after the user receives success but before usage reaches Dodo.
573
422
 
574
- ### 1. Unique Event IDs
575
- Always generate unique IDs for idempotency:
576
423
  ```typescript
577
- const eventId = `${eventName}_${Date.now()}_${crypto.randomUUID()}`;
424
+ // WRONG — an acknowledged operation can lose its usage event
425
+ app.post('/api/generate', async (req, res) => {
426
+ const result = await generateAI(req.body);
427
+ res.json(result);
428
+ void client.usageEvents.ingest({ events: [result.usageEvent] });
429
+ });
430
+
431
+ // CORRECT — durable persistence completes before success is returned
432
+ app.post('/api/generate', async (req, res) => {
433
+ const result = await generateAI(req.body);
434
+ await usageOutbox.persist(result.usageEvent);
435
+ res.json(result);
436
+ });
578
437
  ```
579
438
 
580
- ### 2. Batch Events
581
- For high-volume, batch events (max 1000/request):
582
- ```typescript
583
- // Queue events and send in batches
584
- const eventQueue: Event[] = [];
585
-
586
- function queueEvent(event: Event) {
587
- eventQueue.push(event);
588
- if (eventQueue.length >= 100) {
589
- flushEvents();
590
- }
591
- }
439
+ A retrying worker ingests the persisted payload with its original `event_id`, as shown in **Track API Calls**. If persistence fails, return an error so the operation can be retried rather than silently underbilling.
592
440
 
593
- async function flushEvents() {
594
- if (eventQueue.length === 0) return;
595
- const batch = eventQueue.splice(0, 1000);
596
- await client.usageEvents.ingest({ events: batch });
597
- }
441
+ ### 3. Clock Skew in Timestamps
598
442
 
599
- // Flush periodically
600
- setInterval(flushEvents, 5000);
601
- ```
443
+ Timestamps must be within one hour in the past or five minutes in the future. Ensure your server clock is synchronized.
602
444
 
603
- ### 3. Handle Failures Gracefully
604
- Implement retry logic for event ingestion:
605
445
  ```typescript
606
- async function trackWithRetry(event: Event, retries = 3) {
607
- for (let i = 0; i < retries; i++) {
608
- try {
609
- await client.usageEvents.ingest({ events: [event] });
610
- return;
611
- } catch (error) {
612
- if (i === retries - 1) throw error;
613
- await sleep(1000 * Math.pow(2, i)); // Exponential backoff
614
- }
615
- }
616
- }
617
- ```
446
+ // WRONG — timestamp is 2 hours old
447
+ const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
448
+ await client.usageEvents.ingest({
449
+ events: [{
450
+ event_id: 'evt_123',
451
+ customer_id: 'cus_abc',
452
+ event_name: 'api.call',
453
+ timestamp: oldTime, // Rejected
454
+ metadata: {}
455
+ }]
456
+ });
618
457
 
619
- ### 4. Include Relevant Metadata
620
- Add context for debugging and filtering:
621
- ```typescript
622
- metadata: {
623
- endpoint: '/v1/generate',
624
- method: 'POST',
625
- model: 'gpt-4',
626
- user_id: 'internal_user_123',
627
- request_id: requestId,
628
- }
458
+ // CORRECT — use current time
459
+ await client.usageEvents.ingest({
460
+ events: [{
461
+ event_id: 'evt_123',
462
+ customer_id: 'cus_abc',
463
+ event_name: 'api.call',
464
+ timestamp: new Date().toISOString(),
465
+ metadata: {}
466
+ }]
467
+ });
629
468
  ```
630
469
 
631
- ### 5. Monitor in Dashboard
632
- Check Meters dashboard for:
633
- - Event volume and trends
634
- - Usage per customer
635
- - Aggregated quantities
470
+ ### 4. Ingesting on the Client Side
636
471
 
637
- ---
472
+ Never send events from client-side code. Always ingest from your backend to avoid exposing your API key.
638
473
 
639
- ## Pricing Examples
474
+ ```typescript
475
+ // WRONG — client-side
476
+ const trackUsage = async (eventName: string) => {
477
+ await fetch('https://test.dodopayments.com/events/ingest', {
478
+ method: 'POST',
479
+ headers: {
480
+ 'Authorization': `Bearer ${process.env.DODO_PAYMENTS_API_KEY}`, // Exposed!
481
+ 'Content-Type': 'application/json'
482
+ },
483
+ body: JSON.stringify({ events: [...] })
484
+ });
485
+ };
640
486
 
641
- ### API Service
487
+ // CORRECT — send the event from a backend worker using its persisted payload
488
+ await client.usageEvents.ingest({
489
+ events: [{
490
+ event_id: 'api-call:req_123',
491
+ customer_id: 'cus_abc',
492
+ event_name: 'api.call',
493
+ timestamp: '2026-08-01T10:30:00Z',
494
+ metadata: { endpoint: '/v1/users' },
495
+ }],
496
+ });
642
497
  ```
643
- Meter: API Calls (Count)
644
- Price: $0.001 per call
645
- Free Threshold: 1,000 calls/month
646
498
 
647
- Customer uses 15,000 calls:
648
- (15,000 - 1,000) × $0.001 = $14.00
649
- ```
499
+ ### 5. Mismatched Event Names
650
500
 
651
- ### AI Token Service
652
- ```
653
- Meter: Tokens (Sum over "tokens")
654
- Price: $0.00001 per token ($0.01 per 1,000)
655
- Free Threshold: 10,000 tokens
501
+ Event names are case-sensitive and must match the meter's event name exactly.
656
502
 
657
- Customer uses 50,000 tokens:
658
- (50,000 - 10,000) × $0.00001 = $0.40
659
- ```
503
+ ```typescript
504
+ // WRONG — meter expects "api.call", event sends "API.CALL"
505
+ const meter = await client.meters.create({
506
+ name: 'API Requests',
507
+ event_name: 'api.call',
508
+ aggregation: { type: 'count' },
509
+ measurement_unit: 'calls',
510
+ });
660
511
 
661
- ### Image Generation
662
- ```
663
- Meter: Images (Count)
664
- Price: $0.05 per image
665
- Free Threshold: 10 images
512
+ await client.usageEvents.ingest({
513
+ events: [{
514
+ event_id: 'api-call:req_123',
515
+ customer_id: 'cus_abc',
516
+ event_name: 'API.CALL', // Won't match
517
+ }]
518
+ });
666
519
 
667
- Customer generates 100 images:
668
- (100 - 10) × $0.05 = $4.50
520
+ // CORRECT
521
+ await client.usageEvents.ingest({
522
+ events: [{
523
+ event_id: 'api-call:req_123',
524
+ customer_id: 'cus_abc',
525
+ event_name: 'api.call', // Matches exactly
526
+ }]
527
+ });
669
528
  ```
670
529
 
671
530
  ---
@@ -674,7 +533,8 @@ Customer generates 100 images:
674
533
 
675
534
  - [Usage-Based Billing Guide](https://docs.dodopayments.com/features/usage-based-billing/introduction)
676
535
  - [Meters Documentation](https://docs.dodopayments.com/features/usage-based-billing/meters)
677
- - [Event Ingestion](https://docs.dodopayments.com/features/usage-based-billing/event-ingestion)
678
- - [AI Chat App Tutorial](https://docs.dodopayments.com/developer-resources/build-an-ai-chat-app-with-usage-based-billing)
679
- - [Hybrid Billing Models](https://docs.dodopayments.com/features/hybrid-billing)
536
+ - [Event Ingestion API](https://docs.dodopayments.com/api-reference/usage-events/ingest-events)
537
+ - [Create Meter API](https://docs.dodopayments.com/api-reference/meters/create-meter)
538
+ - [Usage-Based Billing Integration Guide](https://docs.dodopayments.com/developer-resources/usage-based-billing-guide)
680
539
  - [Credit-Based Billing](https://docs.dodopayments.com/features/credit-based-billing)
540
+ - [Webhook Integration](https://docs.dodopayments.com/developer-resources/webhooks)