@dodopayments/opencode-plugin 0.6.1 → 0.6.3

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 (37) hide show
  1. package/package.json +1 -1
  2. package/skills/billing-sdk/SKILL.md +8 -193
  3. package/skills/billing-sdk/references/pricing-page.md +197 -0
  4. package/skills/checkout-integration/SKILL.md +14 -449
  5. package/skills/checkout-integration/references/overlay-checkout.md +148 -0
  6. package/skills/checkout-integration/references/server-examples.md +317 -0
  7. package/skills/credit-based-billing/SKILL.md +5 -151
  8. package/skills/credit-based-billing/references/ai-token-example.md +108 -0
  9. package/skills/credit-based-billing/references/credit-webhooks.md +51 -0
  10. package/skills/framework-adapters/SKILL.md +38 -527
  11. package/skills/framework-adapters/references/astro.md +52 -0
  12. package/skills/framework-adapters/references/bun.md +45 -0
  13. package/skills/framework-adapters/references/convex.md +91 -0
  14. package/skills/framework-adapters/references/express.md +72 -0
  15. package/skills/framework-adapters/references/fastify.md +54 -0
  16. package/skills/framework-adapters/references/hono.md +63 -0
  17. package/skills/framework-adapters/references/nuxt.md +53 -0
  18. package/skills/framework-adapters/references/remix.md +65 -0
  19. package/skills/framework-adapters/references/sveltekit.md +49 -0
  20. package/skills/framework-adapters/references/tanstack-start.md +31 -0
  21. package/skills/license-keys/SKILL.md +8 -303
  22. package/skills/license-keys/references/cli-tool.md +143 -0
  23. package/skills/license-keys/references/desktop-app.md +170 -0
  24. package/skills/mobile-checkout/SKILL.md +25 -313
  25. package/skills/mobile-checkout/references/android.md +69 -0
  26. package/skills/mobile-checkout/references/flutter.md +97 -0
  27. package/skills/mobile-checkout/references/ios.md +66 -0
  28. package/skills/mobile-checkout/references/react-native.md +100 -0
  29. package/skills/refunds-and-disputes/SKILL.md +1 -1
  30. package/skills/subscription-integration/SKILL.md +3 -77
  31. package/skills/subscription-integration/references/webhook-events.md +82 -0
  32. package/skills/usage-based-billing/SKILL.md +5 -114
  33. package/skills/usage-based-billing/references/instrumentation.md +119 -0
  34. package/skills/webhook-integration/SKILL.md +28 -277
  35. package/skills/webhook-integration/references/common-mistakes.md +146 -0
  36. package/skills/webhook-integration/references/event-catalog.md +103 -0
  37. 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
+ ---
@@ -366,162 +366,16 @@ Use the `usage-based-billing` skill to create the meter, choose `count`, `sum`,
366
366
 
367
367
  ## End-to-end AI token example
368
368
 
369
- Assume the dashboard/API configuration already has:
370
-
371
- - a custom-unit entitlement named `AI Token Credits`, precision `0`;
372
- - a subscription product that issues credits each cycle;
373
- - a `sum` meter whose case-sensitive event name is `ai.tokens` and key is `tokens`;
374
- - that meter linked through `credit_entitlement_id` and `meter_units_per_credit`;
375
- - a one-time product `pdt_ai_token_topup` with credits attached.
376
-
377
- 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:
378
-
379
- ```typescript
380
- import DodoPayments from 'dodopayments';
381
-
382
- const client = new DodoPayments({
383
- bearerToken: process.env['DODO_PAYMENTS_API_KEY'],
384
- environment: 'test_mode',
385
- });
386
-
387
- async function recordTokenUsage(
388
- customerId: string,
389
- generationId: string,
390
- model: string,
391
- promptTokens: number,
392
- completionTokens: number,
393
- ): Promise<number> {
394
- const tokens = promptTokens + completionTokens;
395
- const response = await client.usageEvents.ingest({
396
- events: [
397
- {
398
- event_id: `generation:${generationId}`,
399
- customer_id: customerId,
400
- event_name: 'ai.tokens',
401
- timestamp: new Date().toISOString(),
402
- metadata: {
403
- tokens,
404
- prompt_tokens: promptTokens,
405
- completion_tokens: completionTokens,
406
- model,
407
- },
408
- },
409
- ],
410
- });
411
-
412
- return response.ingested_count;
413
- }
414
-
415
- async function readTokenBalance(
416
- customerId: string,
417
- creditEntitlementId: string,
418
- ): Promise<string> {
419
- const response = await client.creditEntitlements.balances.retrieve(
420
- customerId,
421
- { credit_entitlement_id: creditEntitlementId },
422
- );
423
- return response.balance;
424
- }
425
-
426
- async function createTopUpCheckout(customerEmail: string) {
427
- return client.checkoutSessions.create({
428
- product_cart: [
429
- { product_id: 'pdt_ai_token_topup', quantity: 1 },
430
- ],
431
- customer: { email: customerEmail },
432
- return_url: 'https://app.example.com/credits',
433
- });
434
- }
435
-
436
- async function main(): Promise<void> {
437
- const customerId = 'cus_8VbC6JDZzPEqfBPUdpj0K';
438
- const creditEntitlementId = 'cde_ztxm5XJsKxWucRWA3rjdM';
439
-
440
- const ingested = await recordTokenUsage(
441
- customerId,
442
- 'gen_01JZAIEXAMPLE',
443
- 'example-model',
444
- 1800,
445
- 700,
446
- );
447
- console.log({ ingested });
448
-
449
- // Meter deduction is asynchronous; this read may still show the prior balance.
450
- const balance = await readTokenBalance(customerId, creditEntitlementId);
451
- console.log({ balance });
452
-
453
- // Call this after a verified credit.balance_low event and customer action.
454
- const topUp = await createTopUpCheckout('customer@example.com');
455
- console.log(topUp);
456
- }
457
-
458
- main().catch((error: unknown) => {
459
- console.error(error instanceof Error ? error.message : error);
460
- process.exitCode = 1;
461
- });
462
- ```
463
-
464
- The lifecycle is:
465
-
466
- 1. The model returns actual prompt and completion token counts.
467
- 2. The app ingests one uniquely identified `ai.tokens` event.
468
- 3. The meter aggregates the `tokens` metadata value.
469
- 4. The worker converts units into credits and deducts from non-expired grants, earliest-expiring first.
470
- 5. Dodo emits `credit.deducted`; when configured threshold conditions are met, it emits `credit.balance_low`.
471
- 6. The app notifies the customer and offers checkout for the attached top-up product.
472
-
473
- 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).
474
370
 
475
371
  ## Handle credit webhooks
476
372
 
477
- 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.
478
-
479
- All credit events except `credit.balance_low` use the full ledger payload.
480
-
481
- | Exact event | Application action |
482
- |---|---|
483
- | `credit.added` | Refresh cached balance; correlate the grant or purchase and ledger entry. |
484
- | `credit.deducted` | Refresh cached balance and usage display; reconcile the source usage/job. |
485
- | `credit.expired` | Refresh balance; notify only if your product policy promises expiry notices. |
486
- | `credit.rolled_over` | Refresh balance and expose the new rollover grant in account history. |
487
- | `credit.rollover_forfeited` | Refresh balance and explain forfeiture according to the configured rollover limit. |
488
- | `credit.overage_charged` | Reconcile the overage charge with billing and customer-visible history. |
489
- | `credit.overage_reset` | Clear cached overage state after confirming the ledger payload. |
490
- | `credit.manual_adjustment` | Reconcile the adjustment with its internal support/admin operation. |
491
- | `credit.balance_low` | Deduplicate, refresh the authoritative balance, notify the customer, and offer upgrade or top-up. |
492
-
493
- ### Low-balance payload
494
-
495
- `credit.balance_low` has a dedicated payload:
496
-
497
- ```json
498
- {
499
- "business_id": "bus_H4ekzPSlcg",
500
- "type": "credit.balance_low",
501
- "timestamp": "2025-08-04T06:15:00.000000Z",
502
- "data": {
503
- "payload_type": "CreditBalanceLow",
504
- "customer_id": "cus_8VbC6JDZzPEqfBPUdpj0K",
505
- "subscription_id": "sub_7EeHq2ewQuadropD2ra",
506
- "credit_entitlement_id": "cent_9xY2bKwQn5MjRpL8d",
507
- "credit_entitlement_name": "API Credits",
508
- "available_balance": "15",
509
- "subscription_credits_amount": "100",
510
- "threshold_percent": 20,
511
- "threshold_amount": "20"
512
- }
513
- }
514
- ```
373
+ Full guide: [references/credit-webhooks.md](references/credit-webhooks.md).
515
374
 
516
- ### Low-balance notification flow
375
+ Covers:
517
376
 
518
- 1. Verify the raw webhook and deduplicate by `webhook-id` as described in `webhook-integration`.
519
- 2. Confirm `type === 'credit.balance_low'` and validate the dedicated payload.
520
- 3. Map `customer_id` to the authenticated application account; do not accept a customer ID supplied by a browser.
521
- 4. Read the current balance with `balances.retrieve(...)` because another grant or deduction may have occurred since emission.
522
- 5. If the balance is still below your customer-notification policy, enqueue one notification keyed by webhook ID or threshold occurrence.
523
- 6. Link to a one-time top-up checkout or plan-upgrade flow.
524
- 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
525
379
 
526
380
  ## Enforce credits safely
527
381
 
@@ -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.