@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.
Files changed (47) hide show
  1. package/README.md +1 -0
  2. package/opencode-plugin/README.md +1 -1
  3. package/package.json +1 -1
  4. package/skills/better-auth-integration/SKILL.md +12 -3
  5. package/skills/billing-sdk/SKILL.md +15 -167
  6. package/skills/billing-sdk/references/pricing-page.md +197 -0
  7. package/skills/checkout-integration/SKILL.md +31 -460
  8. package/skills/checkout-integration/references/overlay-checkout.md +148 -0
  9. package/skills/checkout-integration/references/server-examples.md +317 -0
  10. package/skills/credit-based-billing/SKILL.md +11 -158
  11. package/skills/credit-based-billing/references/ai-token-example.md +108 -0
  12. package/skills/credit-based-billing/references/credit-webhooks.md +51 -0
  13. package/skills/customer-management/SKILL.md +25 -17
  14. package/skills/discounts-and-promotions/SKILL.md +20 -17
  15. package/skills/dodo-best-practices/SKILL.md +18 -5
  16. package/skills/dodo-mcp-usage/SKILL.md +152 -0
  17. package/skills/framework-adapters/SKILL.md +64 -421
  18. package/skills/framework-adapters/references/astro.md +52 -0
  19. package/skills/framework-adapters/references/bun.md +45 -0
  20. package/skills/framework-adapters/references/convex.md +91 -0
  21. package/skills/framework-adapters/references/express.md +72 -0
  22. package/skills/framework-adapters/references/fastify.md +54 -0
  23. package/skills/framework-adapters/references/hono.md +63 -0
  24. package/skills/framework-adapters/references/nuxt.md +53 -0
  25. package/skills/framework-adapters/references/remix.md +65 -0
  26. package/skills/framework-adapters/references/sveltekit.md +49 -0
  27. package/skills/framework-adapters/references/tanstack-start.md +31 -0
  28. package/skills/license-keys/SKILL.md +117 -367
  29. package/skills/license-keys/references/cli-tool.md +143 -0
  30. package/skills/license-keys/references/desktop-app.md +170 -0
  31. package/skills/localized-pricing/SKILL.md +51 -29
  32. package/skills/mobile-checkout/SKILL.md +72 -310
  33. package/skills/mobile-checkout/references/android.md +69 -0
  34. package/skills/mobile-checkout/references/flutter.md +97 -0
  35. package/skills/mobile-checkout/references/ios.md +66 -0
  36. package/skills/mobile-checkout/references/react-native.md +100 -0
  37. package/skills/product-catalog-management/SKILL.md +39 -16
  38. package/skills/refunds-and-disputes/SKILL.md +91 -71
  39. package/skills/subscription-integration/SKILL.md +43 -64
  40. package/skills/subscription-integration/references/webhook-events.md +82 -0
  41. package/skills/testing-and-go-live/SKILL.md +22 -15
  42. package/skills/usage-based-billing/SKILL.md +16 -122
  43. package/skills/usage-based-billing/references/instrumentation.md +119 -0
  44. package/skills/webhook-integration/SKILL.md +31 -263
  45. package/skills/webhook-integration/references/common-mistakes.md +146 -0
  46. package/skills/webhook-integration/references/event-catalog.md +103 -0
  47. package/skills/webhook-integration/references/local-testing.md +42 -0
@@ -0,0 +1,317 @@
1
+ # Dodo Payments Checkout Integration: reference
2
+
3
+ ## Next.js App Router
4
+
5
+ ### API Route
6
+
7
+ ```typescript
8
+ // app/api/checkout/route.ts
9
+ import { NextResponse } from 'next/server';
10
+ import DodoPayments from 'dodopayments';
11
+ import { getCurrentUser } from '@/lib/auth';
12
+
13
+ const client = new DodoPayments({
14
+ bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
15
+ environment: process.env.NODE_ENV === 'production' ? 'live_mode' : 'test_mode'
16
+ });
17
+
18
+ type PlanId = 'starter' | 'pro';
19
+
20
+ const PRODUCT_IDS: Record<PlanId, string> = {
21
+ starter: 'pdt_starter_monthly',
22
+ pro: 'pdt_pro_monthly'
23
+ };
24
+
25
+ function productIdForPlan(value: unknown): string | null {
26
+ if (typeof value !== 'string') return null;
27
+ return PRODUCT_IDS[value as PlanId] ?? null;
28
+ }
29
+
30
+ export async function POST(request: Request) {
31
+ const user = await getCurrentUser();
32
+ if (!user) {
33
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
34
+ }
35
+
36
+ let body: unknown;
37
+ try {
38
+ body = await request.json();
39
+ } catch {
40
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
41
+ }
42
+
43
+ if (!body || typeof body !== 'object') {
44
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
45
+ }
46
+
47
+ const input = body as { planId?: unknown; quantity?: unknown };
48
+ const productId = productIdForPlan(input.planId);
49
+ const quantity = input.quantity ?? 1;
50
+
51
+ if (!productId) {
52
+ return NextResponse.json({ error: 'Unknown plan' }, { status: 400 });
53
+ }
54
+
55
+ if (!Number.isInteger(quantity) || (quantity as number) < 1) {
56
+ return NextResponse.json(
57
+ { error: 'Quantity must be a positive integer' },
58
+ { status: 400 }
59
+ );
60
+ }
61
+
62
+ try {
63
+ const session = await client.checkoutSessions.create({
64
+ product_cart: [{ product_id: productId, quantity: quantity as number }],
65
+ customer: { email: user.email, name: user.name },
66
+ metadata: { app_user_id: user.id },
67
+ return_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success`
68
+ });
69
+
70
+ return NextResponse.json({
71
+ checkoutUrl: session.checkout_url,
72
+ sessionId: session.session_id
73
+ });
74
+ } catch (error) {
75
+ console.error('Checkout error:', error);
76
+ return NextResponse.json(
77
+ { error: 'Failed to create checkout' },
78
+ { status: 500 }
79
+ );
80
+ }
81
+ }
82
+ ```
83
+
84
+ ### Client Component
85
+
86
+ ```typescript
87
+ // components/CheckoutButton.tsx
88
+ 'use client';
89
+
90
+ import { useState } from 'react';
91
+
92
+ interface CheckoutButtonProps {
93
+ planId: 'starter' | 'pro';
94
+ quantity?: number;
95
+ children: React.ReactNode;
96
+ }
97
+
98
+ export function CheckoutButton({ planId, quantity = 1, children }: CheckoutButtonProps) {
99
+ const [loading, setLoading] = useState(false);
100
+
101
+ const handleCheckout = async () => {
102
+ setLoading(true);
103
+ try {
104
+ const response = await fetch('/api/checkout', {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ planId, quantity })
108
+ });
109
+
110
+ if (!response.ok) {
111
+ throw new Error('Failed to create checkout');
112
+ }
113
+
114
+ const data = (await response.json()) as { checkoutUrl?: string };
115
+ if (!data.checkoutUrl) throw new Error('Checkout URL was not returned');
116
+ window.location.href = data.checkoutUrl;
117
+ } catch (error) {
118
+ console.error('Checkout error:', error);
119
+ alert('Failed to start checkout. Please try again.');
120
+ } finally {
121
+ setLoading(false);
122
+ }
123
+ };
124
+
125
+ return (
126
+ <button onClick={handleCheckout} disabled={loading}>
127
+ {loading ? 'Loading...' : children}
128
+ </button>
129
+ );
130
+ }
131
+ ```
132
+
133
+ ### Success Page
134
+
135
+ ```typescript
136
+ // app/checkout/success/page.tsx
137
+ import { Suspense } from 'react';
138
+
139
+ function SuccessContent() {
140
+ return (
141
+ <div className="text-center py-20">
142
+ <h1 className="text-3xl font-bold">Payment Successful</h1>
143
+ <p className="mt-4 text-gray-600">
144
+ Thank you for your purchase. Check your email for a confirmation.
145
+ </p>
146
+ <a href="/" className="mt-8 inline-block text-blue-600 hover:underline">
147
+ Return to Home
148
+ </a>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ export default function SuccessPage() {
154
+ return (
155
+ <Suspense fallback={<div>Loading...</div>}>
156
+ <SuccessContent />
157
+ </Suspense>
158
+ );
159
+ }
160
+ ```
161
+
162
+ ---
163
+
164
+ ## Express.js
165
+
166
+ ```typescript
167
+ import express from 'express';
168
+ import DodoPayments from 'dodopayments';
169
+ import { getCurrentUser } from './auth';
170
+ import { fulfillOneTimePurchase, grantSubscriptionAccess } from './entitlements';
171
+
172
+ const app = express();
173
+
174
+ const client = new DodoPayments({
175
+ bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
176
+ environment: process.env.NODE_ENV === 'production' ? 'live_mode' : 'test_mode'
177
+ });
178
+
179
+ // Mount the webhook before express.json() so verification receives signed bytes.
180
+ app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
181
+ try {
182
+ const event = client.webhooks.unwrap(req.body.toString('utf8'), {
183
+ headers: {
184
+ 'webhook-id': req.headers['webhook-id'] as string,
185
+ 'webhook-signature': req.headers['webhook-signature'] as string,
186
+ 'webhook-timestamp': req.headers['webhook-timestamp'] as string
187
+ }
188
+ });
189
+
190
+ if (event.type === 'payment.succeeded') {
191
+ // This branch fulfills one-time purchases only.
192
+ await fulfillOneTimePurchase(event.data.customer.customer_id);
193
+ } else if (event.type === 'subscription.active') {
194
+ // Subscription access starts only after this verified event.
195
+ await grantSubscriptionAccess(event.data);
196
+ }
197
+
198
+ res.json({ received: true });
199
+ } catch (error) {
200
+ console.error('Webhook verification failed:', error);
201
+ res.status(401).json({ error: 'Invalid signature' });
202
+ }
203
+ });
204
+
205
+ app.use(express.json());
206
+
207
+ type PlanId = 'starter' | 'pro';
208
+
209
+ const PRODUCT_IDS: Record<PlanId, string> = {
210
+ starter: 'pdt_starter_monthly',
211
+ pro: 'pdt_pro_monthly'
212
+ };
213
+
214
+ function productIdForPlan(value: unknown): string | null {
215
+ if (typeof value !== 'string') return null;
216
+ return PRODUCT_IDS[value as PlanId] ?? null;
217
+ }
218
+
219
+ app.post('/api/checkout', async (req, res) => {
220
+ const user = await getCurrentUser(req);
221
+ if (!user) {
222
+ res.status(401).json({ error: 'Not authenticated' });
223
+ return;
224
+ }
225
+
226
+ const { planId, quantity = 1 } = req.body as {
227
+ planId?: unknown;
228
+ quantity?: unknown;
229
+ };
230
+ const productId = productIdForPlan(planId);
231
+
232
+ if (!productId) {
233
+ res.status(400).json({ error: 'Unknown plan' });
234
+ return;
235
+ }
236
+
237
+ if (!Number.isInteger(quantity) || (quantity as number) < 1) {
238
+ res.status(400).json({ error: 'Quantity must be a positive integer' });
239
+ return;
240
+ }
241
+
242
+ try {
243
+ const session = await client.checkoutSessions.create({
244
+ product_cart: [{ product_id: productId, quantity: quantity as number }],
245
+ customer: { email: user.email, name: user.name },
246
+ metadata: { app_user_id: user.id },
247
+ return_url: `${process.env.APP_URL}/success`
248
+ });
249
+
250
+ res.json({ checkoutUrl: session.checkout_url });
251
+ } catch (error) {
252
+ console.error('Checkout error:', error);
253
+ res.status(500).json({ error: 'Failed to create checkout' });
254
+ }
255
+ });
256
+
257
+ app.get('/success', (req, res) => {
258
+ res.send('Payment successful!');
259
+ });
260
+ ```
261
+
262
+ ---
263
+
264
+ ## Python (FastAPI)
265
+
266
+ ```python
267
+ import logging
268
+ import os
269
+ from typing import Annotated, Literal
270
+
271
+ from dodopayments import DodoPayments
272
+ from fastapi import Depends, FastAPI, HTTPException
273
+ from pydantic import BaseModel, Field
274
+
275
+ from app.auth import User, get_current_user
276
+
277
+ app = FastAPI()
278
+ logger = logging.getLogger(__name__)
279
+ client = DodoPayments(
280
+ bearer_token=os.environ["DODO_PAYMENTS_API_KEY"],
281
+ environment="test_mode"
282
+ )
283
+
284
+ PRODUCT_IDS = {
285
+ "starter": "pdt_starter_monthly",
286
+ "pro": "pdt_pro_monthly",
287
+ }
288
+
289
+ class CheckoutRequest(BaseModel):
290
+ plan_id: Literal["starter", "pro"]
291
+ quantity: int = Field(default=1, gt=0)
292
+
293
+ @app.post("/api/checkout")
294
+ async def create_checkout(
295
+ request: CheckoutRequest,
296
+ user: Annotated[User, Depends(get_current_user)],
297
+ ):
298
+ try:
299
+ session = client.checkout_sessions.create(
300
+ product_cart=[{
301
+ "product_id": PRODUCT_IDS[request.plan_id],
302
+ "quantity": request.quantity
303
+ }],
304
+ customer={
305
+ "email": user.email,
306
+ "name": user.name
307
+ },
308
+ metadata={"app_user_id": user.id},
309
+ return_url=f"{os.environ['APP_URL']}/success"
310
+ )
311
+ return {"checkout_url": session.checkout_url}
312
+ except Exception as error:
313
+ logger.exception("Checkout session creation failed")
314
+ raise HTTPException(status_code=500, detail="Failed to create checkout") from error
315
+ ```
316
+
317
+ ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: credit-based-billing
3
- description: Complete guide for giving customers included, free, prepaid, promotional, or top-up credits using grants, balances, ledger deductions, rollover, expiry, alerts, and overage.
3
+ description: Dodo Payments credit-based billing with credit entitlements, grants, balances, ledger deductions, rollover, expiry, low-balance alerts, and overage. Use when selling prepaid or top-up credit packs, including credits in a plan, deducting AI token or API credits via meters, or granting promo credits; use usage-based-billing for direct per-unit charges.
4
4
  ---
5
5
 
6
6
  # Dodo Payments Credit-Based Billing
@@ -203,7 +203,7 @@ Attach the credit entitlement to the same usage-based product as the meter. The
203
203
  | Field | Purpose |
204
204
  |---|---|
205
205
  | `meter_id` | Meter to aggregate usage. |
206
- | `free_threshold` | Optional usage excluded before rating. |
206
+ | `free_threshold` | Free usage excluded before rating - applies **only** when the meter bills in money. It is ignored for credit-billed meters: every unit is deducted from the credit balance. |
207
207
  | `credit_entitlement_id` | Credit pool consumed by this meter. |
208
208
  | `meter_units_per_credit` | Usage units required to deduct one credit; required when `credit_entitlement_id` is set. |
209
209
  | `price_per_unit` | Direct per-unit monetary price when using pure usage pricing; decimal string. |
@@ -297,7 +297,7 @@ If the same `idempotency_key` already exists, the API can return `409`. Treat th
297
297
 
298
298
  ## Deduct credits manually
299
299
 
300
- Use the same method with `entry_type: 'debit'`. Debits consume the oldest grants first (FIFO) and can return `400` when the balance is insufficient.
300
+ Use the same method with `entry_type: 'debit'`. Debits consume the earliest-expiring grants first and can return `400` when the balance is insufficient.
301
301
 
302
302
  ```typescript
303
303
  async function deductJobCredits(
@@ -356,10 +356,9 @@ Persist the Dodo ledger entry ID with internal order, support-case, or job IDs.
356
356
  2. Add the meter to the same product.
357
357
  3. Enable **Bill usage in Credits** for that meter.
358
358
  4. Set `credit_entitlement_id` and `meter_units_per_credit`.
359
- 5. Optionally set `free_threshold`; usage under it is excluded.
360
- 6. Ingest events whose case-sensitive `event_name` matches the meter.
359
+ 5. Ingest events whose case-sensitive `event_name` matches the meter.
361
360
 
362
- A background worker processes new usage approximately every minute, aggregates it according to the meter, converts meter units using `meter_units_per_credit`, and consumes the oldest non-expired grants first (FIFO). Multiple meters can consume one shared credit pool at different conversion rates.
361
+ A background worker processes new usage approximately every minute, aggregates it according to the meter, converts meter units using `meter_units_per_credit`, and deducts from non-expired grants with the earliest-expiring grants consumed first. There is no free threshold on a credit-billed meter. Multiple meters can consume one shared credit pool at different conversion rates.
363
362
 
364
363
  This delay is material: an accepted usage event does not imply that a balance read immediately afterward includes its deduction.
365
364
 
@@ -367,162 +366,16 @@ Use the `usage-based-billing` skill to create the meter, choose `count`, `sum`,
367
366
 
368
367
  ## End-to-end AI token example
369
368
 
370
- Assume the dashboard/API configuration already has:
371
-
372
- - a custom-unit entitlement named `AI Token Credits`, precision `0`;
373
- - a subscription product that issues credits each cycle;
374
- - a `sum` meter whose case-sensitive event name is `ai.tokens` and key is `tokens`;
375
- - that meter linked through `credit_entitlement_id` and `meter_units_per_credit`;
376
- - a one-time product `pdt_ai_token_topup` with credits attached.
377
-
378
- The following server-side script records actual model usage, reads the credit balance, and creates a top-up checkout when your verified low-balance webhook flow prompts the customer:
379
-
380
- ```typescript
381
- import DodoPayments from 'dodopayments';
382
-
383
- const client = new DodoPayments({
384
- bearerToken: process.env['DODO_PAYMENTS_API_KEY'],
385
- environment: 'test_mode',
386
- });
387
-
388
- async function recordTokenUsage(
389
- customerId: string,
390
- generationId: string,
391
- model: string,
392
- promptTokens: number,
393
- completionTokens: number,
394
- ): Promise<number> {
395
- const tokens = promptTokens + completionTokens;
396
- const response = await client.usageEvents.ingest({
397
- events: [
398
- {
399
- event_id: `generation:${generationId}`,
400
- customer_id: customerId,
401
- event_name: 'ai.tokens',
402
- timestamp: new Date().toISOString(),
403
- metadata: {
404
- tokens,
405
- prompt_tokens: promptTokens,
406
- completion_tokens: completionTokens,
407
- model,
408
- },
409
- },
410
- ],
411
- });
412
-
413
- return response.ingested_count;
414
- }
415
-
416
- async function readTokenBalance(
417
- customerId: string,
418
- creditEntitlementId: string,
419
- ): Promise<string> {
420
- const response = await client.creditEntitlements.balances.retrieve(
421
- customerId,
422
- { credit_entitlement_id: creditEntitlementId },
423
- );
424
- return response.balance;
425
- }
426
-
427
- async function createTopUpCheckout(customerEmail: string) {
428
- return client.checkoutSessions.create({
429
- product_cart: [
430
- { product_id: 'pdt_ai_token_topup', quantity: 1 },
431
- ],
432
- customer: { email: customerEmail },
433
- return_url: 'https://app.example.com/credits',
434
- });
435
- }
436
-
437
- async function main(): Promise<void> {
438
- const customerId = 'cus_8VbC6JDZzPEqfBPUdpj0K';
439
- const creditEntitlementId = 'cde_ztxm5XJsKxWucRWA3rjdM';
440
-
441
- const ingested = await recordTokenUsage(
442
- customerId,
443
- 'gen_01JZAIEXAMPLE',
444
- 'example-model',
445
- 1800,
446
- 700,
447
- );
448
- console.log({ ingested });
449
-
450
- // Meter deduction is asynchronous; this read may still show the prior balance.
451
- const balance = await readTokenBalance(customerId, creditEntitlementId);
452
- console.log({ balance });
453
-
454
- // Call this after a verified credit.balance_low event and customer action.
455
- const topUp = await createTopUpCheckout('customer@example.com');
456
- console.log(topUp);
457
- }
458
-
459
- main().catch((error: unknown) => {
460
- console.error(error instanceof Error ? error.message : error);
461
- process.exitCode = 1;
462
- });
463
- ```
464
-
465
- The lifecycle is:
466
-
467
- 1. The model returns actual prompt and completion token counts.
468
- 2. The app ingests one uniquely identified `ai.tokens` event.
469
- 3. The meter aggregates the `tokens` metadata value.
470
- 4. The worker converts units into credits and deducts FIFO from non-expired grants.
471
- 5. Dodo emits `credit.deducted`; when configured threshold conditions are met, it emits `credit.balance_low`.
472
- 6. The app notifies the customer and offers checkout for the attached top-up product.
473
-
474
- Do not poll every minute to synthesize your own threshold event. Use the verified webhook and keep polling only for user-interface freshness.
369
+ Full guide: [references/ai-token-example.md](references/ai-token-example.md).
475
370
 
476
371
  ## Handle credit webhooks
477
372
 
478
- Webhook signature verification, raw-body handling, retries, and event deduplication are covered in the `webhook-integration` skill. Verify with `client.webhooks.unwrap()` before dispatching any event; never trust a parsed, unverified body.
479
-
480
- All credit events except `credit.balance_low` use the full ledger payload.
481
-
482
- | Exact event | Application action |
483
- |---|---|
484
- | `credit.added` | Refresh cached balance; correlate the grant or purchase and ledger entry. |
485
- | `credit.deducted` | Refresh cached balance and usage display; reconcile the source usage/job. |
486
- | `credit.expired` | Refresh balance; notify only if your product policy promises expiry notices. |
487
- | `credit.rolled_over` | Refresh balance and expose the new rollover grant in account history. |
488
- | `credit.rollover_forfeited` | Refresh balance and explain forfeiture according to the configured rollover limit. |
489
- | `credit.overage_charged` | Reconcile the overage charge with billing and customer-visible history. |
490
- | `credit.overage_reset` | Clear cached overage state after confirming the ledger payload. |
491
- | `credit.manual_adjustment` | Reconcile the adjustment with its internal support/admin operation. |
492
- | `credit.balance_low` | Deduplicate, refresh the authoritative balance, notify the customer, and offer upgrade or top-up. |
493
-
494
- ### Low-balance payload
495
-
496
- `credit.balance_low` has a dedicated payload:
497
-
498
- ```json
499
- {
500
- "business_id": "bus_H4ekzPSlcg",
501
- "type": "credit.balance_low",
502
- "timestamp": "2025-08-04T06:15:00.000000Z",
503
- "data": {
504
- "payload_type": "CreditBalanceLow",
505
- "customer_id": "cus_8VbC6JDZzPEqfBPUdpj0K",
506
- "subscription_id": "sub_7EeHq2ewQuadropD2ra",
507
- "credit_entitlement_id": "cent_9xY2bKwQn5MjRpL8d",
508
- "credit_entitlement_name": "API Credits",
509
- "available_balance": "15",
510
- "subscription_credits_amount": "100",
511
- "threshold_percent": 20,
512
- "threshold_amount": "20"
513
- }
514
- }
515
- ```
373
+ Full guide: [references/credit-webhooks.md](references/credit-webhooks.md).
516
374
 
517
- ### Low-balance notification flow
375
+ Covers:
518
376
 
519
- 1. Verify the raw webhook and deduplicate by `webhook-id` as described in `webhook-integration`.
520
- 2. Confirm `type === 'credit.balance_low'` and validate the dedicated payload.
521
- 3. Map `customer_id` to the authenticated application account; do not accept a customer ID supplied by a browser.
522
- 4. Read the current balance with `balances.retrieve(...)` because another grant or deduction may have occurred since emission.
523
- 5. If the balance is still below your customer-notification policy, enqueue one notification keyed by webhook ID or threshold occurrence.
524
- 6. Link to a one-time top-up checkout or plan-upgrade flow.
525
- 7. Record notification delivery separately from the Dodo ledger; never manufacture a ledger entry for an email.
377
+ - Low-balance payload
378
+ - Low-balance notification flow
526
379
 
527
380
  ## Enforce credits safely
528
381
 
@@ -588,7 +441,7 @@ Return an application-level payment/credit-required response with a top-up or up
588
441
  16. **Sending duplicate IDs within one batch.** Duplicate `event_id` values in the same ingestion request reject the entire request. Previously ingested IDs are ignored on retry.
589
442
  17. **Inventing a deduplication window.** Current docs require unique event IDs but do not publish a retention duration.
590
443
  18. **Getting conversion backward.** `meter_units_per_credit` is usage units required for one credit, and is required when `credit_entitlement_id` is set.
591
- 19. **Ignoring the free threshold.** Usage under `free_threshold` is excluded before credit deduction.
444
+ 19. **Expecting a free threshold on credit-billed meters.** `free_threshold` applies only to meters billed in money. When **Bill usage in Credits** is on, every unit deducts credits; model included usage as granted credits instead.
592
445
  20. **Supplying one rollover timeframe field.** `rollover_timeframe_count` and `rollover_timeframe_interval` must be supplied together.
593
446
  21. **Using the wrong rollover enum case.** Confirmed values are `Day`, `Week`, `Month`, and `Year`.
594
447
  22. **Assuming expiry happens before rollover.** Rollover is applied first; only the remainder expires.
@@ -0,0 +1,108 @@
1
+ # Dodo Payments Credit-Based Billing: End-to-end AI token example
2
+
3
+
4
+ Assume the dashboard/API configuration already has:
5
+
6
+ - a custom-unit entitlement named `AI Token Credits`, precision `0`;
7
+ - a subscription product that issues credits each cycle;
8
+ - a `sum` meter whose case-sensitive event name is `ai.tokens` and key is `tokens`;
9
+ - that meter linked through `credit_entitlement_id` and `meter_units_per_credit`;
10
+ - a one-time product `pdt_ai_token_topup` with credits attached.
11
+
12
+ The following server-side script records actual model usage, reads the credit balance, and creates a top-up checkout when your verified low-balance webhook flow prompts the customer:
13
+
14
+ ```typescript
15
+ import DodoPayments from 'dodopayments';
16
+
17
+ const client = new DodoPayments({
18
+ bearerToken: process.env['DODO_PAYMENTS_API_KEY'],
19
+ environment: 'test_mode',
20
+ });
21
+
22
+ async function recordTokenUsage(
23
+ customerId: string,
24
+ generationId: string,
25
+ model: string,
26
+ promptTokens: number,
27
+ completionTokens: number,
28
+ ): Promise<number> {
29
+ const tokens = promptTokens + completionTokens;
30
+ const response = await client.usageEvents.ingest({
31
+ events: [
32
+ {
33
+ event_id: `generation:${generationId}`,
34
+ customer_id: customerId,
35
+ event_name: 'ai.tokens',
36
+ timestamp: new Date().toISOString(),
37
+ metadata: {
38
+ tokens,
39
+ prompt_tokens: promptTokens,
40
+ completion_tokens: completionTokens,
41
+ model,
42
+ },
43
+ },
44
+ ],
45
+ });
46
+
47
+ return response.ingested_count;
48
+ }
49
+
50
+ async function readTokenBalance(
51
+ customerId: string,
52
+ creditEntitlementId: string,
53
+ ): Promise<string> {
54
+ const response = await client.creditEntitlements.balances.retrieve(
55
+ customerId,
56
+ { credit_entitlement_id: creditEntitlementId },
57
+ );
58
+ return response.balance;
59
+ }
60
+
61
+ async function createTopUpCheckout(customerEmail: string) {
62
+ return client.checkoutSessions.create({
63
+ product_cart: [
64
+ { product_id: 'pdt_ai_token_topup', quantity: 1 },
65
+ ],
66
+ customer: { email: customerEmail },
67
+ return_url: 'https://app.example.com/credits',
68
+ });
69
+ }
70
+
71
+ async function main(): Promise<void> {
72
+ const customerId = 'cus_8VbC6JDZzPEqfBPUdpj0K';
73
+ const creditEntitlementId = 'cde_ztxm5XJsKxWucRWA3rjdM';
74
+
75
+ const ingested = await recordTokenUsage(
76
+ customerId,
77
+ 'gen_01JZAIEXAMPLE',
78
+ 'example-model',
79
+ 1800,
80
+ 700,
81
+ );
82
+ console.log({ ingested });
83
+
84
+ // Meter deduction is asynchronous; this read may still show the prior balance.
85
+ const balance = await readTokenBalance(customerId, creditEntitlementId);
86
+ console.log({ balance });
87
+
88
+ // Call this after a verified credit.balance_low event and customer action.
89
+ const topUp = await createTopUpCheckout('customer@example.com');
90
+ console.log(topUp);
91
+ }
92
+
93
+ main().catch((error: unknown) => {
94
+ console.error(error instanceof Error ? error.message : error);
95
+ process.exitCode = 1;
96
+ });
97
+ ```
98
+
99
+ The lifecycle is:
100
+
101
+ 1. The model returns actual prompt and completion token counts.
102
+ 2. The app ingests one uniquely identified `ai.tokens` event.
103
+ 3. The meter aggregates the `tokens` metadata value.
104
+ 4. The worker converts units into credits and deducts from non-expired grants, earliest-expiring first.
105
+ 5. Dodo emits `credit.deducted`; when configured threshold conditions are met, it emits `credit.balance_low`.
106
+ 6. The app notifies the customer and offers checkout for the attached top-up product.
107
+
108
+ Do not poll every minute to synthesize your own threshold event. Use the verified webhook and keep polling only for user-interface freshness.
@@ -0,0 +1,51 @@
1
+ # Dodo Payments Credit-Based Billing: Handle credit webhooks
2
+
3
+
4
+ Webhook signature verification, raw-body handling, retries, and event deduplication are covered in the `webhook-integration` skill. Verify with `client.webhooks.unwrap()` before dispatching any event; never trust a parsed, unverified body.
5
+
6
+ All credit events except `credit.balance_low` use the full ledger payload.
7
+
8
+ | Exact event | Application action |
9
+ |---|---|
10
+ | `credit.added` | Refresh cached balance; correlate the grant or purchase and ledger entry. |
11
+ | `credit.deducted` | Refresh cached balance and usage display; reconcile the source usage/job. |
12
+ | `credit.expired` | Refresh balance; notify only if your product policy promises expiry notices. |
13
+ | `credit.rolled_over` | Refresh balance and expose the new rollover grant in account history. |
14
+ | `credit.rollover_forfeited` | Refresh balance and explain forfeiture according to the configured rollover limit. |
15
+ | `credit.overage_charged` | Reconcile the overage charge with billing and customer-visible history. |
16
+ | `credit.overage_reset` | Clear cached overage state after confirming the ledger payload. |
17
+ | `credit.manual_adjustment` | Reconcile the adjustment with its internal support/admin operation. |
18
+ | `credit.balance_low` | Deduplicate, refresh the authoritative balance, notify the customer, and offer upgrade or top-up. |
19
+
20
+ ### Low-balance payload
21
+
22
+ `credit.balance_low` has a dedicated payload:
23
+
24
+ ```json
25
+ {
26
+ "business_id": "bus_H4ekzPSlcg",
27
+ "type": "credit.balance_low",
28
+ "timestamp": "2025-08-04T06:15:00.000000Z",
29
+ "data": {
30
+ "payload_type": "CreditBalanceLow",
31
+ "customer_id": "cus_8VbC6JDZzPEqfBPUdpj0K",
32
+ "subscription_id": "sub_7EeHq2ewQuadropD2ra",
33
+ "credit_entitlement_id": "cent_9xY2bKwQn5MjRpL8d",
34
+ "credit_entitlement_name": "API Credits",
35
+ "available_balance": "15",
36
+ "subscription_credits_amount": "100",
37
+ "threshold_percent": 20,
38
+ "threshold_amount": "20"
39
+ }
40
+ }
41
+ ```
42
+
43
+ ### Low-balance notification flow
44
+
45
+ 1. Verify the raw webhook and deduplicate by `webhook-id` as described in `webhook-integration`.
46
+ 2. Confirm `type === 'credit.balance_low'` and validate the dedicated payload.
47
+ 3. Map `customer_id` to the authenticated application account; do not accept a customer ID supplied by a browser.
48
+ 4. Read the current balance with `balances.retrieve(...)` because another grant or deduction may have occurred since emission.
49
+ 5. If the balance is still below your customer-notification policy, enqueue one notification keyed by webhook ID or threshold occurrence.
50
+ 6. Link to a one-time top-up checkout or plan-upgrade flow.
51
+ 7. Record notification delivery separately from the Dodo ledger; never manufacture a ledger entry for an email.