@dodopayments/opencode-plugin 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/opencode-plugin/README.md +1 -1
- package/package.json +1 -1
- package/skills/better-auth-integration/SKILL.md +12 -3
- package/skills/billing-sdk/SKILL.md +15 -167
- package/skills/billing-sdk/references/pricing-page.md +197 -0
- package/skills/checkout-integration/SKILL.md +31 -460
- package/skills/checkout-integration/references/overlay-checkout.md +148 -0
- package/skills/checkout-integration/references/server-examples.md +317 -0
- package/skills/credit-based-billing/SKILL.md +11 -158
- package/skills/credit-based-billing/references/ai-token-example.md +108 -0
- package/skills/credit-based-billing/references/credit-webhooks.md +51 -0
- package/skills/customer-management/SKILL.md +25 -17
- package/skills/discounts-and-promotions/SKILL.md +20 -17
- package/skills/dodo-best-practices/SKILL.md +18 -5
- package/skills/dodo-mcp-usage/SKILL.md +152 -0
- package/skills/framework-adapters/SKILL.md +64 -421
- package/skills/framework-adapters/references/astro.md +52 -0
- package/skills/framework-adapters/references/bun.md +45 -0
- package/skills/framework-adapters/references/convex.md +91 -0
- package/skills/framework-adapters/references/express.md +72 -0
- package/skills/framework-adapters/references/fastify.md +54 -0
- package/skills/framework-adapters/references/hono.md +63 -0
- package/skills/framework-adapters/references/nuxt.md +53 -0
- package/skills/framework-adapters/references/remix.md +65 -0
- package/skills/framework-adapters/references/sveltekit.md +49 -0
- package/skills/framework-adapters/references/tanstack-start.md +31 -0
- package/skills/license-keys/SKILL.md +117 -367
- package/skills/license-keys/references/cli-tool.md +143 -0
- package/skills/license-keys/references/desktop-app.md +170 -0
- package/skills/localized-pricing/SKILL.md +51 -29
- package/skills/mobile-checkout/SKILL.md +72 -310
- package/skills/mobile-checkout/references/android.md +69 -0
- package/skills/mobile-checkout/references/flutter.md +97 -0
- package/skills/mobile-checkout/references/ios.md +66 -0
- package/skills/mobile-checkout/references/react-native.md +100 -0
- package/skills/product-catalog-management/SKILL.md +39 -16
- package/skills/refunds-and-disputes/SKILL.md +91 -71
- package/skills/subscription-integration/SKILL.md +43 -64
- package/skills/subscription-integration/references/webhook-events.md +82 -0
- package/skills/testing-and-go-live/SKILL.md +22 -15
- package/skills/usage-based-billing/SKILL.md +16 -122
- package/skills/usage-based-billing/references/instrumentation.md +119 -0
- package/skills/webhook-integration/SKILL.md +31 -263
- package/skills/webhook-integration/references/common-mistakes.md +146 -0
- package/skills/webhook-integration/references/event-catalog.md +103 -0
- package/skills/webhook-integration/references/local-testing.md +42 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Dodo Payments Subscription Integration: Webhook Events
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
| Event | When | Action |
|
|
5
|
+
|-------|------|--------|
|
|
6
|
+
| `subscription.active` | Subscription becomes active, including a trial start or recovery | Grant access |
|
|
7
|
+
| `subscription.updated` | Any subscription field changes | Sync your stored copy (status, dates, metadata) |
|
|
8
|
+
| `subscription.renewed` | Successful renewal | Log renewal, send receipt |
|
|
9
|
+
| `subscription.past_due` | Renewal failed and the grace period opened (payload has `past_due_ends_at`) | Keep access, prompt for payment before the deadline |
|
|
10
|
+
| `subscription.on_hold` | Renewal/plan-change payment failed | Notify customer, offer recovery |
|
|
11
|
+
| `subscription.paused` | Subscription paused | Revoke access while paused |
|
|
12
|
+
| `subscription.unpaused` | Paused subscription resumed | Restore access |
|
|
13
|
+
| `subscription.plan_changed` | Plan upgraded/downgraded or add-ons changed | Update entitlements |
|
|
14
|
+
| `subscription.update_payment_method` | Payment method updated | Update stored payment details |
|
|
15
|
+
| `subscription.cancelled` | Customer cancels | Schedule access revocation per `cancel_at_next_billing_date` |
|
|
16
|
+
| `subscription.failed` | Initial creation failed (mandate creation) | Notify customer, offer a new subscription |
|
|
17
|
+
| `subscription.expired` | Subscription term ended | Revoke access |
|
|
18
|
+
|
|
19
|
+
Webhook signature verification, raw-body handling, durable processing, and idempotency are covered in the `webhook-integration` skill.
|
|
20
|
+
|
|
21
|
+
### Example Handler
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
25
|
+
|
|
26
|
+
export async function POST(req: NextRequest) {
|
|
27
|
+
const raw = await req.text();
|
|
28
|
+
const headers = Object.fromEntries(req.headers.entries());
|
|
29
|
+
const event = await client.webhooks.unwrap(raw, { headers });
|
|
30
|
+
const webhookId = req.headers.get('webhook-id');
|
|
31
|
+
|
|
32
|
+
if (!webhookId) {
|
|
33
|
+
return NextResponse.json({ error: 'Missing webhook-id' }, { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Implement this as an atomic insert backed by a UNIQUE constraint.
|
|
37
|
+
// Keep the claim and entitlement changes in the same database transaction.
|
|
38
|
+
const claimed = await claimWebhookId(webhookId);
|
|
39
|
+
if (!claimed) {
|
|
40
|
+
return NextResponse.json({ received: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
switch (event.type) {
|
|
44
|
+
case 'subscription.active':
|
|
45
|
+
await grantAccess(event.data.customer.customer_id, event.data.product_id);
|
|
46
|
+
break;
|
|
47
|
+
case 'subscription.on_hold':
|
|
48
|
+
await notifyPaymentFailed(event.data.customer.customer_id);
|
|
49
|
+
break;
|
|
50
|
+
case 'subscription.paused':
|
|
51
|
+
case 'subscription.unpaused': {
|
|
52
|
+
// Pause and resume can happen in quick succession, and handlers for
|
|
53
|
+
// different events can finish in any order. Don't trust the event type:
|
|
54
|
+
// re-read the subscription and apply its CURRENT status, scoped to this
|
|
55
|
+
// subscription (the customer may hold other active ones). Run this under
|
|
56
|
+
// a per-subscription lock if your handlers execute concurrently.
|
|
57
|
+
const current = await client.subscriptions.retrieve(event.data.subscription_id);
|
|
58
|
+
if (current.status === 'paused') {
|
|
59
|
+
await suspendSubscriptionAccess(current.subscription_id);
|
|
60
|
+
} else if (current.status === 'active' || current.status === 'past_due') {
|
|
61
|
+
// past_due keeps access during the payment grace period
|
|
62
|
+
await restoreSubscriptionAccess(current.subscription_id);
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case 'subscription.cancelled':
|
|
67
|
+
if (event.data.cancel_at_next_billing_date) {
|
|
68
|
+
await scheduleAccessRevocation(event.data.subscription_id, new Date(event.data.next_billing_date));
|
|
69
|
+
} else {
|
|
70
|
+
await revokeAccessImmediately(event.data.subscription_id);
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
case 'subscription.expired':
|
|
74
|
+
await revokeAccess(event.data.customer.customer_id);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return NextResponse.json({ received: true });
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: testing-and-go-live
|
|
3
|
-
description:
|
|
3
|
+
description: Dodo Payments test mode and production launch, covering test cards and payment methods, success and decline scenarios, renewal simulation, local webhook testing with the CLI, copying products from test to live, and a go-live checklist. Use when verifying payment flows, switching from test_mode to live_mode, or preparing to launch.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Testing and Go-Live
|
|
@@ -44,6 +44,8 @@ const client = new DodoPayments({
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
```python
|
|
47
|
+
import os
|
|
48
|
+
|
|
47
49
|
from dodopayments import DodoPayments
|
|
48
50
|
|
|
49
51
|
client = DodoPayments(
|
|
@@ -67,31 +69,34 @@ Get your test API key from the Dodo dashboard. Test keys are prefixed `dodo_test
|
|
|
67
69
|
|
|
68
70
|
### Card success and decline scenarios
|
|
69
71
|
|
|
70
|
-
Use these card numbers in test mode
|
|
72
|
+
Use these card numbers in test mode with expiry **06/32** (or **12/34**) and CVV **123**.
|
|
71
73
|
|
|
72
74
|
| Scenario | Card Number | Result |
|
|
73
75
|
|---|---|---|
|
|
74
76
|
| Success | `4242 4242 4242 4242` | Payment succeeds |
|
|
75
77
|
| Decline | `4000 0000 0000 0002` | Payment declined |
|
|
76
78
|
| Decline (insufficient funds) | `4000 0000 0000 9995` | Insufficient funds error |
|
|
77
|
-
|
|
79
|
+
|
|
80
|
+
Only use cards listed on the [Testing Process](https://docs.dodopayments.com/miscellaneous/testing-process) page; other Stripe-style test numbers (for example "lost card" variants) are not documented for Dodo.
|
|
78
81
|
|
|
79
82
|
### Subscription renewal failure
|
|
80
83
|
|
|
81
84
|
To test subscription renewal failures, use this card:
|
|
82
85
|
|
|
83
|
-
| Card Number | Behavior |
|
|
84
|
-
|
|
85
|
-
| `4000 0000 0000
|
|
86
|
+
| Card Number | Expiry | CVV | Behavior |
|
|
87
|
+
|---|---|---|---|
|
|
88
|
+
| `4000 0000 0000 0341` | 12/34 | 123 | Declined at the subscription's next charge (renewal, upgrade, or downgrade) |
|
|
89
|
+
|
|
90
|
+
Create the subscription with a success card first, then switch its payment method to this card in the Customer Portal.
|
|
86
91
|
|
|
87
92
|
### UPI (India)
|
|
88
93
|
|
|
89
|
-
Test UPI success and failure with these VPAs:
|
|
94
|
+
Test UPI success and failure with these VPAs. The billing country must be `IN` and the currency `INR` (non-Indian merchants also need Adaptive Currency enabled):
|
|
90
95
|
|
|
91
96
|
| VPA | Result |
|
|
92
97
|
|---|---|
|
|
93
|
-
| `success@
|
|
94
|
-
| `failure@
|
|
98
|
+
| `success@upi` | Payment succeeds |
|
|
99
|
+
| `failure@upi` | Payment fails |
|
|
95
100
|
|
|
96
101
|
### BNPL, wallets, and regional methods
|
|
97
102
|
|
|
@@ -162,7 +167,9 @@ dodo wh trigger payment.success http://localhost:3000/webhook
|
|
|
162
167
|
|
|
163
168
|
Sends a realistic mock payload for a chosen event. It runs offline and works even while logged out — which is the tell for the part that matters: **these payloads are unsigned.** `unwrap()` rejects them because there is no valid signature to verify.
|
|
164
169
|
|
|
165
|
-
Use `unsafeUnwrap()` for triggered events only, and never on an endpoint that also receives real traffic.
|
|
170
|
+
Use `unsafeUnwrap()` for triggered events only, and never on an endpoint that also receives real traffic. Trigger names are `<category>.<event>`, for example `payment.success`, `subscription.active`, or `dispute.opened`.
|
|
171
|
+
|
|
172
|
+
**Trigger names are not always the payload `type`.** `payment.success` delivers `type: "payment.succeeded"`, `refund.success` delivers `refund.succeeded`, and `licence.created` delivers `license_key.created`. Switch on the payload types in your handler; a `case 'payment.success'` never matches.
|
|
166
173
|
|
|
167
174
|
As with `listen`, both arguments are required in direct mode; `/wh trigger` inside the TUI opens a wizard instead.
|
|
168
175
|
|
|
@@ -184,19 +191,19 @@ Products are environment-specific. You cannot reuse a test product ID in product
|
|
|
184
191
|
|
|
185
192
|
**Programmatic approach:**
|
|
186
193
|
|
|
187
|
-
Fetch the product from test mode, extract its configuration, and create it in live mode:
|
|
194
|
+
Fetch the product from test mode, extract its configuration, and create it in live mode. API keys are per mode — a test key does not authenticate against live mode and vice versa — so this needs two distinct keys:
|
|
188
195
|
|
|
189
196
|
```typescript
|
|
190
197
|
// In test mode
|
|
191
198
|
const testClient = new DodoPayments({
|
|
192
|
-
bearerToken: process.env.
|
|
199
|
+
bearerToken: process.env.DODO_PAYMENTS_TEST_API_KEY,
|
|
193
200
|
environment: 'test_mode',
|
|
194
201
|
});
|
|
195
202
|
const testProduct = await testClient.products.retrieve('pdt_test_123');
|
|
196
203
|
|
|
197
204
|
// In live mode
|
|
198
205
|
const liveClient = new DodoPayments({
|
|
199
|
-
bearerToken: process.env.
|
|
206
|
+
bearerToken: process.env.DODO_PAYMENTS_LIVE_API_KEY,
|
|
200
207
|
environment: 'live_mode',
|
|
201
208
|
});
|
|
202
209
|
const liveProduct = await liveClient.products.create({
|
|
@@ -331,5 +338,5 @@ Test keys (prefixed `dodo_test_`) only work with `https://test.dodopayments.com`
|
|
|
331
338
|
|
|
332
339
|
- [Testing Process](https://docs.dodopayments.com/miscellaneous/testing-process)
|
|
333
340
|
- [Test vs. Live Mode](https://docs.dodopayments.com/miscellaneous/test-mode-vs-live-mode)
|
|
334
|
-
- [Webhook Integration](https://docs.dodopayments.com/developer-resources/webhooks
|
|
335
|
-
- [Dodo CLI](https://docs.dodopayments.com/developer-resources/cli)
|
|
341
|
+
- [Webhook Integration](https://docs.dodopayments.com/developer-resources/webhooks)
|
|
342
|
+
- [Dodo CLI](https://docs.dodopayments.com/developer-resources/sdks/cli)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: usage-based-billing
|
|
3
|
-
description:
|
|
3
|
+
description: Dodo Payments usage-based billing that charges per measured unit, covering meters, usage event ingestion by event_name, aggregation, free thresholds, price_per_unit, and metered subscriptions. Use when billing per API call, AI token, storage, or bandwidth, sending usage events, or adding metered pricing to a plan; use credit-based-billing for prepaid credit pools.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Dodo Payments Usage-Based Billing
|
|
@@ -181,7 +181,7 @@ async function trackBatchUsage(
|
|
|
181
181
|
customerId: string;
|
|
182
182
|
eventName: string;
|
|
183
183
|
occurredAt: string;
|
|
184
|
-
metadata: Record<string, string>;
|
|
184
|
+
metadata: Record<string, string | number | boolean>;
|
|
185
185
|
}>
|
|
186
186
|
) {
|
|
187
187
|
const formattedEvents = events.map((event) => ({
|
|
@@ -235,135 +235,29 @@ The `product-catalog-management` skill is the canonical source for the complete
|
|
|
235
235
|
|
|
236
236
|
## Instrumenting Your Application
|
|
237
237
|
|
|
238
|
-
|
|
238
|
+
Full guide: [references/instrumentation.md](references/instrumentation.md).
|
|
239
239
|
|
|
240
|
-
|
|
240
|
+
Covers:
|
|
241
241
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
}
|
|
256
|
-
|
|
257
|
-
async function completeApiOperation(
|
|
258
|
-
outbox: UsageOutbox,
|
|
259
|
-
operationId: string,
|
|
260
|
-
customerId: string,
|
|
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 },
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
|
-
|
|
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
|
-
}
|
|
279
|
-
```
|
|
280
|
-
|
|
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.
|
|
282
|
-
|
|
283
|
-
### Track AI Token Usage
|
|
284
|
-
|
|
285
|
-
```typescript
|
|
286
|
-
async function callAI(
|
|
287
|
-
customerId: string,
|
|
288
|
-
generationId: string,
|
|
289
|
-
prompt: string,
|
|
290
|
-
completedAt: string,
|
|
291
|
-
) {
|
|
292
|
-
const response = await openai.chat.completions.create({
|
|
293
|
-
model: 'gpt-4',
|
|
294
|
-
messages: [{ role: 'user', content: prompt }],
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
// Track tokens after completion
|
|
298
|
-
await client.usageEvents.ingest({
|
|
299
|
-
events: [{
|
|
300
|
-
event_id: `generation:${generationId}`,
|
|
301
|
-
customer_id: customerId,
|
|
302
|
-
event_name: 'ai.tokens',
|
|
303
|
-
timestamp: completedAt,
|
|
304
|
-
metadata: {
|
|
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',
|
|
309
|
-
}
|
|
310
|
-
}]
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
return response;
|
|
314
|
-
}
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
### Track Storage Usage
|
|
318
|
-
|
|
319
|
-
For snapshot-based metrics (current state), use the `last` aggregation:
|
|
320
|
-
|
|
321
|
-
```typescript
|
|
322
|
-
async function updateStorageUsage(
|
|
323
|
-
customerId: string,
|
|
324
|
-
snapshotId: string,
|
|
325
|
-
bytesUsed: number,
|
|
326
|
-
capturedAt: string,
|
|
327
|
-
) {
|
|
328
|
-
await client.usageEvents.ingest({
|
|
329
|
-
events: [{
|
|
330
|
-
event_id: `storage-snapshot:${snapshotId}`,
|
|
331
|
-
customer_id: customerId,
|
|
332
|
-
event_name: 'storage.snapshot',
|
|
333
|
-
timestamp: capturedAt,
|
|
334
|
-
metadata: {
|
|
335
|
-
bytes: bytesUsed.toString(),
|
|
336
|
-
gb: (bytesUsed / 1024 / 1024 / 1024).toFixed(2),
|
|
337
|
-
}
|
|
338
|
-
}]
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Call periodically or after storage changes
|
|
343
|
-
await updateStorageUsage(
|
|
344
|
-
'cus_abc',
|
|
345
|
-
'snapshot_01K1M4D2K9',
|
|
346
|
-
5368709120,
|
|
347
|
-
'2026-08-01T10:30:00Z',
|
|
348
|
-
); // 5GB
|
|
349
|
-
```
|
|
350
|
-
|
|
351
|
-
---
|
|
242
|
+
- Track API Calls
|
|
243
|
+
- Track AI Token Usage
|
|
244
|
+
- Track Storage Usage
|
|
352
245
|
|
|
353
246
|
## Querying Usage for Display
|
|
354
247
|
|
|
355
248
|
### Retrieve Usage History
|
|
356
249
|
|
|
357
250
|
```typescript
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
console.log(
|
|
251
|
+
// Auto-paginates across ALL billing periods; narrow with start_date/end_date/meter_id.
|
|
252
|
+
for await (const period of client.subscriptions.retrieveUsageHistory('sub_abc123', {
|
|
253
|
+
start_date: '2025-01-01T00:00:00Z',
|
|
254
|
+
page_size: 20,
|
|
255
|
+
})) {
|
|
256
|
+
console.log(period); // one record per billing period, with per-meter usage
|
|
257
|
+
}
|
|
364
258
|
```
|
|
365
259
|
|
|
366
|
-
This
|
|
260
|
+
This is paginated usage history organized by billing period, across the subscription's lifetime - not just the current period. Filter with `start_date`, `end_date`, and `meter_id`, and iterate pages (or `for await`) rather than reading only the first page.
|
|
367
261
|
|
|
368
262
|
---
|
|
369
263
|
|
|
@@ -378,7 +272,7 @@ To link a meter to credits:
|
|
|
378
272
|
3. On the meter, enable **Bill usage in Credits**.
|
|
379
273
|
4. Set `credit_entitlement_id` and `meter_units_per_credit` (e.g., 1,000 tokens = 1 credit).
|
|
380
274
|
|
|
381
|
-
|
|
275
|
+
The free threshold does **not** apply to credit-billed meters: every unit counts toward credit deduction (it only applies when the meter bills in money). Approximately every minute, a background worker aggregates new usage, converts it using the meter-to-credit ratio, and deducts from non-expired grants, earliest-expiring grants consumed first. When credits run out, configured overage behavior applies.
|
|
382
276
|
|
|
383
277
|
---
|
|
384
278
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Dodo Payments Usage-Based Billing: Instrumenting Your Application
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Track API Calls
|
|
5
|
+
|
|
6
|
+
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.
|
|
7
|
+
|
|
8
|
+
```typescript
|
|
9
|
+
type PersistedUsageEvent = {
|
|
10
|
+
event_id: string;
|
|
11
|
+
customer_id: string;
|
|
12
|
+
event_name: string;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
metadata: Record<string, string | number | boolean>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface UsageOutbox {
|
|
18
|
+
persist(event: PersistedUsageEvent): Promise<void>;
|
|
19
|
+
nextBatch(limit: number): Promise<PersistedUsageEvent[]>;
|
|
20
|
+
markIngested(eventIds: string[]): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function completeApiOperation(
|
|
24
|
+
outbox: UsageOutbox,
|
|
25
|
+
operationId: string,
|
|
26
|
+
customerId: string,
|
|
27
|
+
occurredAt: string,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
await outbox.persist({
|
|
30
|
+
event_id: `api-call:${operationId}`,
|
|
31
|
+
customer_id: customerId,
|
|
32
|
+
event_name: 'api.call',
|
|
33
|
+
timestamp: occurredAt,
|
|
34
|
+
metadata: { endpoint: '/v1/users', method: 'GET', status: 200 },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function ingestUsageOutbox(outbox: UsageOutbox): Promise<void> {
|
|
39
|
+
const events = await outbox.nextBatch(1000);
|
|
40
|
+
if (events.length === 0) return;
|
|
41
|
+
|
|
42
|
+
await client.usageEvents.ingest({ events });
|
|
43
|
+
await outbox.markIngested(events.map((event) => event.event_id));
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
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.
|
|
48
|
+
|
|
49
|
+
### Track AI Token Usage
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
async function callAI(
|
|
53
|
+
customerId: string,
|
|
54
|
+
generationId: string,
|
|
55
|
+
prompt: string,
|
|
56
|
+
completedAt: string,
|
|
57
|
+
) {
|
|
58
|
+
const response = await openai.chat.completions.create({
|
|
59
|
+
model: 'gpt-4',
|
|
60
|
+
messages: [{ role: 'user', content: prompt }],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Track tokens after completion
|
|
64
|
+
await client.usageEvents.ingest({
|
|
65
|
+
events: [{
|
|
66
|
+
event_id: `generation:${generationId}`,
|
|
67
|
+
customer_id: customerId,
|
|
68
|
+
event_name: 'ai.tokens',
|
|
69
|
+
timestamp: completedAt,
|
|
70
|
+
// Aggregated properties (Sum/Max/Last) must be numbers, not strings.
|
|
71
|
+
metadata: {
|
|
72
|
+
tokens: response.usage.total_tokens,
|
|
73
|
+
prompt_tokens: response.usage.prompt_tokens,
|
|
74
|
+
completion_tokens: response.usage.completion_tokens,
|
|
75
|
+
model: 'gpt-4',
|
|
76
|
+
}
|
|
77
|
+
}]
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Track Storage Usage
|
|
85
|
+
|
|
86
|
+
For snapshot-based metrics (current state), use the `last` aggregation:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
async function updateStorageUsage(
|
|
90
|
+
customerId: string,
|
|
91
|
+
snapshotId: string,
|
|
92
|
+
bytesUsed: number,
|
|
93
|
+
capturedAt: string,
|
|
94
|
+
) {
|
|
95
|
+
await client.usageEvents.ingest({
|
|
96
|
+
events: [{
|
|
97
|
+
event_id: `storage-snapshot:${snapshotId}`,
|
|
98
|
+
customer_id: customerId,
|
|
99
|
+
event_name: 'storage.snapshot',
|
|
100
|
+
timestamp: capturedAt,
|
|
101
|
+
// Numeric values: a string property does not aggregate.
|
|
102
|
+
metadata: {
|
|
103
|
+
bytes: bytesUsed,
|
|
104
|
+
gb: Number((bytesUsed / 1024 / 1024 / 1024).toFixed(2)),
|
|
105
|
+
}
|
|
106
|
+
}]
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Call periodically or after storage changes
|
|
111
|
+
await updateStorageUsage(
|
|
112
|
+
'cus_abc',
|
|
113
|
+
'snapshot_01K1M4D2K9',
|
|
114
|
+
5368709120,
|
|
115
|
+
'2026-08-01T10:30:00Z',
|
|
116
|
+
); // 5GB
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|