关于 usage-based-billing
这是一份在 SaaS、AI 和基础设施产品中实现 Dodo Payments 用量计费的参考技能,介绍计量器与定价、事件接入及用量历史查询。
主要功能
- 01定义事件计量器与聚合方式
- 02幂等接入用量事件
- 03设置单价和免费额度
- 04查询客户及订阅用量
- 05将计量器关联到账户额度
适用场景
- 01按 API 调用或 AI Token 用量计费
- 02跟踪存储或带宽并计费
- 03将按量费用与订阅结合
- 04按计费周期展示用量历史
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dodopayments/ skills --skill usage-based-billing安装后在 Claude Code 中输入/usage-based-billing确认已加载。
附带文件references/instrumentation.md
详细介绍
Dodo Payments 按用量计费
参考:docs.dodopayments.com/features/usage-based-billing
根据客户的实际用量收费,例如 API 调用、存储空间、AI 令牌,或你定义的任何指标。
何时使用此技能
- 你需要根据用量向客户收费(API 调用、令牌、存储空间、带宽)。
- 你希望将用量费用与订阅或一次性购买结合起来。
- 你需要跟踪事件,并将其汇总为计费数量。
- 你正在构建具有计量功能的 AI 服务、SaaS 平台或基础设施产品。
核心概念
事件
从你的应用发送到 Dodo 的用量记录。每个事件都会关联到一位客户,并根据其 event_name 与计量器匹配。
{
"event_id": "evt_unique_123",
"customer_id": "cus_abc123",
"event_name": "api.call",
"timestamp": "2025-01-21T10:30:00Z",
"metadata": { "endpoint": "/v1/users", "tokens": 150 }
}
计量器
计量器会筛选并汇总事件,得出可计费的数量。计量器指定:
- 事件名称:要匹配的事件(区分大小写)
- 汇总类型:如何合并事件(计数、求和、最大值、最新值)
- 计量单位:计费单位(调用次数、令牌数、GB 等)
- 可选筛选条件:事件必须满足的计数条件
汇总类型
| 类型 | 使用场景 | 示例 |
|---|---|---|
| Count | 事件总数 | API 调用、图像生成 |
| Sum | 累加某个属性的值 | 已用令牌数、传输字节数 |
| Max | 某个周期内的最大值 | 并发用户峰值 |
| Last | 最新值 | 当前已用存储空间 |
对于 sum、max 和 last,你需要指定要汇总的元数据属性。
定价
将计量器关联到产品价格,即可按单位收费:
- 单价:例如,每次 API 调用 $0.001
- 免费额度:例如,每月免费 1,000 次调用
- 计费公式:
(usage − threshold) × price_per_unit
示例:2,500 次调用 − 1,000 次免费调用 = 1,500 × $0.02 = $30.00
计量器生命周期
创建计量器
import DodoPayments from 'dodopayments';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment: 'test_mode',
});
const meter = await client.meters.create({
name: 'API Requests',
event_name: 'api.call',
aggregation: { type: 'count' },
measurement_unit: 'calls',
description: 'Track API calls per customer',
});
console.log(meter.id); // mtr_...
对于求和汇总,请指定要汇总的属性:
const meter = await client.meters.create({
name: 'Token Usage',
event_name: 'ai.tokens',
aggregation: { type: 'sum', key: 'tokens' },
measurement_unit: 'tokens',
});
列出和检索计量器
// List all meters
const meters = await client.meters.list();
// Retrieve a specific meter
const meter = await client.meters.retrieve('mtr_abc123');
归档和取消归档
计量器会被归档,而非删除。归档后的计量器将停止接收新事件,但会保留历史数据。
// Archive a meter
await client.meters.archive('mtr_abc123');
// Unarchive to resume
await client.meters.unarchive('mtr_abc123');
事件摄取
发送事件
async function recordApiCall(
customerId: string,
requestId: string,
occurredAt: string,
): Promise<number> {
const response = await client.usageEvents.ingest({
events: [{
event_id: `api-call:${requestId}`,
customer_id: customerId,
event_name: 'api.call',
timestamp: occurredAt,
metadata: {
endpoint: '/v1/users',
method: 'GET',
},
}],
});
return response.ingested_count;
}
requestId 必须标识底层 API 操作,并在重试期间保持不变。不要在摄取尝试中生成它。
事件架构
| 字段 | 必填 | 说明 |
|---|---|---|
event_id | 是 | 用于实现幂等性的唯一标识符。同一请求中存在重复 ID 时,整个请求都会被拒绝。 |
customer_id | 是 | Dodo Payments 客户 ID。 |
event_name | 是 | 必须与计量器的事件名称完全匹配(区分大小写)。 |
timestamp | 否 | ISO-8601 日期时间。默认为当前 UTC 时间。时间必须在过去一小时内或未来五分钟内。 |
metadata | 否 | 包含字符串、整数、数字或布尔值的对象。最多 50 对;键长度为 100,值长度为 500。不允许嵌套对象或数组。 |
幂等性与去重
- 每个不同的操作都分配一个全局唯一的
event_id。 - 根据不可变的请求、作业、生成或快照 ID 推导此 ID,并在每次重试时重复使用。
- 单个请求中的重复 ID 会导致整个批次被拒绝。
- 先前请求中已摄取的 ID 会被静默忽略,因此重试是安全的。
批量摄取
每个请求最多发送 1,000 个事件:
async function trackBatchUsage(
events: Array<{
operationId: string;
customerId: string;
eventName: string;
occurredAt: string;
metadata: Record<string, string | number | boolean>;
}>
) {
const formattedEvents = events.map((event) => ({
event_id: `usage:${event.operationId}`,
customer_id: event.customerId,
event_name: event.eventName,
timestamp: event.occurredAt,
metadata: event.metadata,
}));
await client.usageEvents.ingest({ events: formattedEvents });
}
// Batch track multiple API calls
await trackBatchUsage([
{ operationId: 'req_101', customerId: 'cus_abc', eventName: 'api.call', occurredAt: '2026-08-01T10:00:00Z', metadata: { endpoint: '/v1/users' } },
{ operationId: 'req_102', customerId: 'cus_abc', eventName: 'api.call', occurredAt: '2026-08-01T10:00:01Z', metadata: { endpoint: '/v1/orders' } },
{ operationId: 'req_103', customerId: 'cus_xyz', eventName: 'api.call', occurredAt: '2026-08-01T10:00:02Z', metadata: { endpoint: '/v1/products' } },
]);
查询事件
// List events for a customer
const events = await client.usageEvents.list({
customer_id: 'cus_abc123',
});
// Retrieve a specific event
const event = await client.usageEvents.retrieve('evt_abc123');
定价模型
按单位定价
这是目前唯一有文档说明且可操作的定价模式。计量器附件使用:
price_per_unit:十进制字符串(最多 5 位整数、12 位小数)free_threshold:可选整数(低于此用量不收费)
计费公式:(usage − threshold) × price_per_unit
product-catalog-management 技能是完整产品创建请求的权威来源。它定义了单数形式的 price 对象,其类型为 usage_based_price,并包含嵌套的 meters 数组;请勿在此处另行定义一套产品架构。
注意: Dodo Payments API 目前没有记录分层、阶梯、按量或阶梯式定价模式。暂时请使用带免费阈值的按单位定价。
为应用添加用量埋点
完整指南:references/instrumentation.md。
涵盖:
- 跟踪 API 调用
- 跟踪 AI 令牌用量
- 跟踪存储用量
查询用量以供展示
检索用量历史记录
// Auto-paginates across ALL billing periods; narrow with start_date/end_date/meter_id.
for await (const period of client.subscriptions.retrieveUsageHistory('sub_abc123', {
start_date: '2025-01-01T00:00:00Z',
page_size: 20,
})) {
console.log(period); // one record per billing period, with per-meter usage
}
这是按计费周期整理的分页用量历史记录,涵盖整个订阅期限,而不仅仅是当前周期。可使用 start_date、end_date 和 meter_id 进行筛选,并遍历各页(或使用 for await),而不是只读取第一页。
基于额度的计费集成
用量事件可以从客户的额度余额中扣除,而不是按单位收费。有关额度权益、余额和账本管理的完整详情,请参阅 credit-based-billing 技能。
将计量器关联到额度:
- 创建额度权益(例如“AI Credits”)。
- 将额度权益附加到同一产品。
- 在计量器上启用 以额度计费用量。
- 设置
credit_entitlement_id和meter_units_per_credit(例如,1,000 个令牌 = 1 个额度)。
免费阈值不适用于以额度计费的计量器:每个单位都会计入额度扣除(它仅适用于以货币计费的计量器)。大约每分钟,后台工作进程会汇总新增用量,根据计量器与额度的换算比例进行转换,并从未过期的额度发放中扣除;优先消耗最早到期的额度。额度用尽时,将应用已配置的超额用量处理方式。
Webhook 集成
用量事件会触发 Webhook,以便进行监控和对账。有关 Webhook 设置和验证,请参阅 webhook-integration 技能。
常见错误
1. 使用不稳定或重复的事件 ID
根据不可变的业务操作生成一个 ID。将同一个 ID 用于不同操作会导致用量被丢弃;而每次重试都生成时间戳或随机 ID,则可能对同一操作重复计费。
// WRONG — a retry creates a new billable event
await client.usageEvents.ingest({
events: [{
event_id: `api-call:${Date.now()}:${crypto.randomUUID()}`,
customer_id: 'cus_abc',
event_name: 'api.call',
}],
});
// CORRECT — retry request req_123 with this same ID
await client.usageEvents.ingest({
events: [{
event_id: 'api-call:req_123',
customer_id: 'cus_abc',
event_name: 'api.call',
}],
});
请勿将 api-call:req_123 重复用于不同请求。这与额度账本指南一致:超时并不意味着可以生成新的幂等键。
2. 使用即发即弃方式摄取数据
在响应之前,若未先持久化事件,请勿启动摄取流程。进程可能会在用户收到成功响应后、用量传送到 Dodo 之前崩溃。
// WRONG — an acknowledged operation can lose its usage event
app.post('/api/generate', async (req, res) => {
const result = await generateAI(req.body);
res.json(result);
void client.usageEvents.ingest({ events: [result.usageEvent] });
});
// CORRECT — durable persistence completes before success is returned
app.post('/api/generate', async (req, res) => {
const result = await generateAI(req.body);
await usageOutbox.persist(result.usageEvent);
res.json(result);
});
如 跟踪 API 调用 所示,重试工作进程会使用原始 event_id 摄取已持久化的有效载荷。如果持久化失败,请返回错误,以便重试该操作,而不是悄然少计费用量。
3. 时间戳的时钟偏差
时间戳必须在过去一小时内或未来五分钟内。请确保服务器时钟已同步。
// WRONG — timestamp is 2 hours old
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
await client.usageEvents.ingest({
events: [{
event_id: 'evt_123',
customer_id: 'cus_abc',
event_name: 'api.call',
timestamp: oldTime, // Rejected
metadata: {}
}]
});
// CORRECT — use current time
await client.usageEvents.ingest({
events: [{
event_id: 'evt_123',
customer_id: 'cus_abc',
event_name: 'api.call',
timestamp: new Date().toISOString(),
metadata: {}
}]
});
4. 在客户端摄取数据
绝不要从客户端代码发送事件。请始终从后端进行摄取,以避免暴露 API 密钥。
// WRONG — client-side
const trackUsage = async (eventName: string) => {
await fetch('https://test.dodopayments.com/events/ingest', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.DODO_PAYMENTS_API_KEY}`, // Exposed!
'Content-Type': 'application/json'
},
body: JSON.stringify({ events: [...] })
});
};
// CORRECT — send the event from a backend worker using its persisted payload
await client.usageEvents.ingest({
events: [{
event_id: 'api-call:req_123',
customer_id: 'cus_abc',
event_name: 'api.call',
timestamp: '2026-08-01T10:30:00Z',
metadata: { endpoint: '/v1/users' },
}],
});
5. 事件名称不匹配
事件名称区分大小写,必须与计量器的事件名称完全一致。
// WRONG — meter expects "api.call", event sends "API.CALL"
const meter = await client.meters.create({
name: 'API Requests',
event_name: 'api.call',
aggregation: { type: 'count' },
measurement_unit: 'calls',
});
await client.usageEvents.ingest({
events: [{
event_id: 'api-call:req_123',
customer_id: 'cus_abc',
event_name: 'API.CALL', // Won't match
}]
});
// CORRECT
await client.usageEvents.ingest({
events: [{
event_id: 'api-call:req_123',
customer_id: 'cus_abc',
event_name: 'api.call', // Matches exactly
}]
});