@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,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:
|
|
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` |
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
+
Covers:
|
|
518
376
|
|
|
519
|
-
|
|
520
|
-
|
|
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. **
|
|
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.
|