mcpField
U
usage-based-billing

usage-based-billing

github.com/dodopayments开发13794 次安装3 周前更新
代码仓库

关于 usage-based-billing

这是一份在 SaaS、AI 和基础设施产品中实现 Dodo Payments 用量计费的参考技能,介绍计量器与定价、事件接入及用量历史查询。

主要功能

  1. 01定义事件计量器与聚合方式
  2. 02幂等接入用量事件
  3. 03设置单价和免费额度
  4. 04查询客户及订阅用量
  5. 05将计量器关联到账户额度

适用场景

  1. 01按 API 调用或 AI Token 用量计费
  2. 02跟踪存储或带宽并计费
  3. 03将按量费用与订阅结合
  4. 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 技能。

将计量器关联到额度:

  1. 创建额度权益(例如“AI Credits”)。
  2. 将额度权益附加到同一产品。
  3. 在计量器上启用 以额度计费用量。
  4. 设置 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
  }]
});

资源

评论

支持 Markdown