@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.
- package/.mcp.json +1 -1
- package/README.md +152 -42
- package/opencode-plugin/README.md +20 -6
- package/opencode-plugin/index.js +27 -1
- package/package.json +5 -5
- package/skills/better-auth-integration/SKILL.md +214 -0
- package/skills/billing-sdk/SKILL.md +357 -370
- package/skills/checkout-integration/SKILL.md +580 -329
- package/skills/credit-based-billing/SKILL.md +448 -775
- package/skills/customer-management/SKILL.md +281 -0
- package/skills/discounts-and-promotions/SKILL.md +464 -0
- package/skills/dodo-best-practices/SKILL.md +471 -0
- package/skills/framework-adapters/SKILL.md +644 -0
- package/skills/license-keys/SKILL.md +324 -199
- package/skills/localized-pricing/SKILL.md +230 -0
- package/skills/mobile-checkout/SKILL.md +521 -0
- package/skills/product-catalog-management/SKILL.md +311 -0
- package/skills/refunds-and-disputes/SKILL.md +333 -0
- package/skills/subscription-integration/SKILL.md +323 -404
- package/skills/testing-and-go-live/SKILL.md +335 -0
- package/skills/usage-based-billing/SKILL.md +339 -479
- package/skills/webhook-integration/SKILL.md +600 -402
- package/skills/best-practices/SKILL.md +0 -315
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: usage-based-billing
|
|
3
|
-
description: Guide for
|
|
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
|
-
##
|
|
14
|
+
## When to use this skill
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
---
|
|
47
|
+
### Aggregation Types
|
|
55
48
|
|
|
56
|
-
|
|
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
|
-
|
|
56
|
+
For `sum`, `max`, and `last`, you specify which metadata property to aggregate.
|
|
59
57
|
|
|
60
|
-
|
|
58
|
+
### Pricing
|
|
61
59
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
65
|
+
**Example**: 2,500 calls − 1,000 free = 1,500 × $0.02 = $30.00
|
|
68
66
|
|
|
69
|
-
|
|
67
|
+
---
|
|
70
68
|
|
|
71
|
-
|
|
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
|
-
###
|
|
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.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
103
|
+
### List and Retrieve Meters
|
|
105
104
|
|
|
106
105
|
```typescript
|
|
107
|
-
|
|
106
|
+
// List all meters
|
|
107
|
+
const meters = await client.meters.list();
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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:
|
|
139
|
+
event_id: `api-call:${requestId}`,
|
|
122
140
|
customer_id: customerId,
|
|
123
|
-
event_name:
|
|
124
|
-
timestamp:
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
157
|
-
event_id: `
|
|
158
|
-
customer_id:
|
|
159
|
-
event_name:
|
|
160
|
-
timestamp:
|
|
161
|
-
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
|
-
###
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
+
## Pricing Models
|
|
302
221
|
|
|
303
|
-
|
|
222
|
+
### Per-Unit Pricing
|
|
304
223
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
236
|
+
## Instrumenting Your Application
|
|
317
237
|
|
|
318
|
-
###
|
|
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
|
-
|
|
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
|
|
257
|
+
async function completeApiOperation(
|
|
258
|
+
outbox: UsageOutbox,
|
|
259
|
+
operationId: string,
|
|
324
260
|
customerId: string,
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
// Meter: Images Generated (Count aggregation)
|
|
283
|
+
### Track AI Token Usage
|
|
355
284
|
|
|
356
|
-
|
|
285
|
+
```typescript
|
|
286
|
+
async function callAI(
|
|
357
287
|
customerId: string,
|
|
358
|
-
|
|
359
|
-
|
|
288
|
+
generationId: string,
|
|
289
|
+
prompt: string,
|
|
290
|
+
completedAt: string,
|
|
360
291
|
) {
|
|
361
|
-
await
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
382
|
-
req: Request,
|
|
383
|
-
customerId: string
|
|
384
|
-
) {
|
|
297
|
+
// Track tokens after completion
|
|
385
298
|
await client.usageEvents.ingest({
|
|
386
299
|
events: [{
|
|
387
|
-
event_id: `
|
|
300
|
+
event_id: `generation:${generationId}`,
|
|
388
301
|
customer_id: customerId,
|
|
389
|
-
event_name: '
|
|
390
|
-
timestamp:
|
|
302
|
+
event_name: 'ai.tokens',
|
|
303
|
+
timestamp: completedAt,
|
|
391
304
|
metadata: {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
317
|
+
### Track Storage Usage
|
|
402
318
|
|
|
403
|
-
|
|
404
|
-
// Meter: Storage (Last aggregation - snapshot of current usage)
|
|
319
|
+
For snapshot-based metrics (current state), use the `last` aggregation:
|
|
405
320
|
|
|
406
|
-
|
|
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: `
|
|
330
|
+
event_id: `storage-snapshot:${snapshotId}`,
|
|
410
331
|
customer_id: customerId,
|
|
411
332
|
event_name: 'storage.snapshot',
|
|
412
|
-
timestamp:
|
|
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(
|
|
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
|
-
##
|
|
353
|
+
## Querying Usage for Display
|
|
428
354
|
|
|
429
|
-
###
|
|
355
|
+
### Retrieve Usage History
|
|
430
356
|
|
|
431
357
|
```typescript
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
370
|
+
## Credit-Based Billing Integration
|
|
504
371
|
|
|
505
|
-
|
|
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
|
-
|
|
374
|
+
To link a meter to credits:
|
|
508
375
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
-
|
|
517
|
-
// - Applies free threshold (10,000 calls)
|
|
518
|
-
// - Charges overage at $0.01/call
|
|
519
|
-
// - Combines with subscription fee
|
|
520
|
-
```
|
|
383
|
+
---
|
|
521
384
|
|
|
522
|
-
|
|
385
|
+
## Webhook Integration
|
|
523
386
|
|
|
524
|
-
|
|
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
|
-
|
|
391
|
+
## Common Mistakes
|
|
535
392
|
|
|
536
|
-
|
|
393
|
+
### 1. Using Unstable or Reused Event IDs
|
|
537
394
|
|
|
538
|
-
|
|
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
|
-
//
|
|
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: `
|
|
552
|
-
customer_id: '
|
|
553
|
-
event_name: '
|
|
554
|
-
|
|
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
|
-
//
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
648
|
-
(15,000 - 1,000) × $0.001 = $14.00
|
|
649
|
-
```
|
|
499
|
+
### 5. Mismatched Event Names
|
|
650
500
|
|
|
651
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
668
|
-
|
|
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/
|
|
678
|
-
- [
|
|
679
|
-
- [
|
|
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)
|