@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.
- package/package.json +1 -1
- package/skills/billing-sdk/SKILL.md +8 -193
- package/skills/billing-sdk/references/pricing-page.md +197 -0
- package/skills/checkout-integration/SKILL.md +14 -449
- 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 +5 -151
- 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/framework-adapters/SKILL.md +38 -527
- 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 +8 -303
- package/skills/license-keys/references/cli-tool.md +143 -0
- package/skills/license-keys/references/desktop-app.md +170 -0
- package/skills/mobile-checkout/SKILL.md +25 -313
- 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/refunds-and-disputes/SKILL.md +1 -1
- package/skills/subscription-integration/SKILL.md +3 -77
- package/skills/subscription-integration/references/webhook-events.md +82 -0
- package/skills/usage-based-billing/SKILL.md +5 -114
- package/skills/usage-based-billing/references/instrumentation.md +119 -0
- package/skills/webhook-integration/SKILL.md +28 -277
- 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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
+
Covers:
|
|
517
376
|
|
|
518
|
-
|
|
519
|
-
|
|
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.
|