跳到正文
Indiana
控制台
开发文档
章节目录 / 商户通知与 Webhook

商户通知与 Webhook

接收购买与用量的签名通知、验证签名,并处理 Funnel 外部任务回调。

API v1更新于

配置接收地址

  • 打开 Console 的开发者 → App notifications(/workspace/developers?tab=notifications),选择 App 和 Sandbox 环境。
  • 填写 HTTPS 接收地址,点击 Generate signing key 生成签名密钥,保存到后端(如 INDIANA_NOTIFICATION_KEY)。
  • 需要购买通知时打开 Billing events,然后启用并保存。
接收地址目前只能为 Sandbox 环境配置;开启前的历史事件不会补发。

事件类型

通知类型
type何时发送
billing.purchase一笔购买已支付并验证(需开启 Billing events)。
usage.consume一次用量被记录。
usage.confirm一笔预留被确认。
usage.refund一笔用量被退回。
usage.limit用量达到上限的设定比例(manageBillingLimits 的 thresholds)。
usage.allowance用量达到套餐内额度的设定比例(includedThresholds)。
usage.alert用量达到设定的数量(absoluteThresholds)。

通知类型

  • billing.purchase

    何时发送
    一笔购买已支付并验证(需开启 Billing events)。
  • usage.consume

    何时发送
    一次用量被记录。
  • usage.confirm

    何时发送
    一笔预留被确认。
  • usage.refund

    何时发送
    一笔用量被退回。
  • usage.limit

    何时发送
    用量达到上限的设定比例(manageBillingLimits 的 thresholds)。
  • usage.allowance

    何时发送
    用量达到套餐内额度的设定比例(includedThresholds)。
  • usage.alert

    何时发送
    用量达到设定的数量(absoluteThresholds)。

请求格式

notification.jsonJSON
{  "id": "<DELIVERY_UUID>",  "type": "billing.purchase",  "projectId": "<APP_UUID>",  "environment": "sandbox",  "customerId": "<CUSTOMER_UUID>",  "occurredAt": "2026-10-22T08:00:00.000Z",  "data": {}}
请求头
请求头内容
X-Indiana-Event-Id事件 ID,等于请求体的 id,重试时不变。
X-Indiana-Signaturet=<Unix seconds>,v1=<HMAC-SHA256 hex>
X-Indiana-Key-Id签名密钥 ID,轮换密钥时使用。

请求头

  • X-Indiana-Event-Id

    内容
    事件 ID,等于请求体的 id,重试时不变。
  • X-Indiana-Signature

    内容
    t=<Unix seconds>,v1=<HMAC-SHA256 hex>
  • X-Indiana-Key-Id

    内容
    签名密钥 ID,轮换密钥时使用。
  • 8 秒内返回 2xx;先保存事件,再异步处理。
  • 同一事件可能收到多次,按事件 ID 去重。非 2xx 或超时会重试,最多 10 次。
  • 请求体不超过 64 KiB,金额与数量是十进制字符串。

验证签名

用 Base64URL 解码后的密钥,对 <时间戳>.<原始请求体> 计算 HMAC-SHA256,与 v1 比较,并拒绝超过 5 分钟的时间戳。必须使用原始请求字节。

app/api/indiana/route.tsTypeScript
import { createHmac, timingSafeEqual } from "node:crypto"; const key = Buffer.from(process.env.INDIANA_NOTIFICATION_KEY!, "base64url");const toleranceSeconds = 300; /** rawBody must be the exact request bytes, read before any JSON parsing. */export function verifyIndianaSignature(rawBody: Buffer, header: string | null) {  const parts = Object.fromEntries(    (header ?? "").split(",").map((part) => part.split("=", 2)),  );  const timestamp = Number(parts.t);  if (!Number.isSafeInteger(timestamp) || !parts.v1) return false;  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;  const expected = createHmac("sha256", key)    .update(`${timestamp}.`)    .update(rawBody)    .digest();  const received = Buffer.from(parts.v1, "hex");  return received.length === expected.length && timingSafeEqual(received, expected);} // Your own storage: returns false when this event ID was already recorded.declare function recordOnce(eventId: string, event: unknown): Promise<boolean>; export async function POST(request: Request) {  const rawBody = Buffer.from(await request.arrayBuffer());  if (!verifyIndianaSignature(rawBody, request.headers.get("X-Indiana-Signature")))    return new Response("invalid signature", { status: 401 });  const event = JSON.parse(rawBody.toString("utf8")) as { id: string; type: string };  await recordOnce(event.id, event); // persist first, process asynchronously  return new Response(null, { status: 200 });}

回应 Funnel 外部任务

Funnel 的外部任务节点会向同一接收地址发送 funnel.task 请求,签名方式相同。在 2xx 响应中返回 outcome 和 data;data 的字段必须与节点声明的输出一致,不超过 8 KiB。

task.request.jsonJSON
{  "type": "funnel.task",  "eventId": "<RECEIPT_UUID>",  "runId": "<RUN_UUID>",  "nodeId": "<NODE_ID>",  "visit": 1,  "projectId": "<APP_UUID>",  "environmentId": "<ENVIRONMENT_UUID>",  "customerId": "<CUSTOMER_UUID>",  "taskKey": "check_eligibility",  "input": { "plan": "pro" }}

429 和 5xx 会按节点设置重试(默认最多 4 次);其他错误直接失败。按 eventId 去重。