商户通知与 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)。
请求格式
1{2 "id": "<DELIVERY_UUID>",3 "type": "billing.purchase",4 "projectId": "<APP_UUID>",5 "environment": "sandbox",6 "customerId": "<CUSTOMER_UUID>",7 "occurredAt": "2026-10-22T08:00:00.000Z",8 "data": {}9}
请求头| 请求头 | 内容 |
|---|
X-Indiana-Event-Id | 事件 ID,等于请求体的 id,重试时不变。 |
X-Indiana-Signature | t=<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 1import { createHmac, timingSafeEqual } from "node:crypto";2 3const key = Buffer.from(process.env.INDIANA_NOTIFICATION_KEY!, "base64url");4const toleranceSeconds = 300;5 6/** rawBody must be the exact request bytes, read before any JSON parsing. */7export function verifyIndianaSignature(rawBody: Buffer, header: string | null) {8 const parts = Object.fromEntries(9 (header ?? "").split(",").map((part) => part.split("=", 2)),10 );11 const timestamp = Number(parts.t);12 if (!Number.isSafeInteger(timestamp) || !parts.v1) return false;13 if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;14 const expected = createHmac("sha256", key)15 .update(`${timestamp}.`)16 .update(rawBody)17 .digest();18 const received = Buffer.from(parts.v1, "hex");19 return received.length === expected.length && timingSafeEqual(received, expected);20}21 22// Your own storage: returns false when this event ID was already recorded.23declare function recordOnce(eventId: string, event: unknown): Promise<boolean>;24 25export async function POST(request: Request) {26 const rawBody = Buffer.from(await request.arrayBuffer());27 if (!verifyIndianaSignature(rawBody, request.headers.get("X-Indiana-Signature")))28 return new Response("invalid signature", { status: 401 });29 const event = JSON.parse(rawBody.toString("utf8")) as { id: string; type: string };30 await recordOnce(event.id, event); // persist first, process asynchronously31 return new Response(null, { status: 200 });32}
回应 Funnel 外部任务
Funnel 的外部任务节点会向同一接收地址发送 funnel.task 请求,签名方式相同。在 2xx 响应中返回 outcome 和 data;data 的字段必须与节点声明的输出一致,不超过 8 KiB。
1{2 "type": "funnel.task",3 "eventId": "<RECEIPT_UUID>",4 "runId": "<RUN_UUID>",5 "nodeId": "<NODE_ID>",6 "visit": 1,7 "projectId": "<APP_UUID>",8 "environmentId": "<ENVIRONMENT_UUID>",9 "customerId": "<CUSTOMER_UUID>",10 "taskKey": "check_eligibility",11 "input": { "plan": "pro" }12}
429 和 5xx 会按节点设置重试(默认最多 4 次);其他错误直接失败。按 eventId 去重。