分销商 API Webhook
Webhook 事件、载荷、Python 和 Node 中的 HMAC 签名验证、带退避的重试、URL 安全规则,以及按事件 ID 去重。
Webhook 事件、载荷、用 Python 和 Node 验证 HMAC 签名、退避重试、URL 安全规则,以及按事件 id 去重。
全部模型、功能与价格 面向 AI 智能体的文档索引:https://oneprovider.dev/docs/llms.txt 创建 Webhook
POST /webhooks(权限 webhooks):仅限 https 的 URL 和一个非空的事件列表。最多 5 个 Webhook。Idempotency-Key 可选:重复调用返回同一个 Webhook,但不含 secret。last_delivered_at 是您的服务器最近一次返回 2xx 的时间(在此之前为 null)。
curl -s -X POST https://app.oneprovider.dev/partner/v1/webhooks \
-H "Authorization: Bearer $OP_PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://shop.example.com/op-webhook","events":["order.completed","balance.low"]}' json {
"id": "<webhook id>",
"url": "https://shop.example.com/op-webhook",
"events": ["order.completed", "balance.low"],
"active": true,
"secret_last4": "a1b2",
"created_at": "2026-10-08T12:00:00Z",
"last_delivered_at": null,
"secret": "whsec_..."
} 签名 secret 只显示一次 secret 字段只出现在创建响应中。请把它保存在您的服务器上;丢失的 secret 无法找回,需要重新创建 Webhook。 事件
| 事件 | 触发时机 | data |
|---|---|---|
key.created | 已发放密钥(来自 API、个人中心、订单或兑换码)。 | key_id, name, limit_usd, customer_id, source (api, cabinet, order, code) |
key.paused | 密钥已暂停(手动暂停,或其客户被封禁时)。 | key_id、reason(仅封禁时:customer_blocked) |
key.resumed | 密钥已恢复(手动恢复,或其客户被解封时)。 | key_id、reason(仅解封时:customer_unblocked) |
code.redeemed | 兑换码已被兑换。 | batch_id, code_tail, key_id, amount_usd |
order.completed | 订单已完成。 | order_id, external_order_id, kind, key_id, amount_usd |
balance.topped_up | 分销商余额已充值。 | amount_usd, order_id |
balance.low | 余额低于阈值(由 OneProvider 设定,默认 $10,对所有人相同)。只触发一次,只有余额回到阈值之上后才会再次触发。 | balance_usd, threshold_usd |
key.exhausted | 密钥额度已用完。每个密钥的每笔售出额度只触发一次,在用完后几分钟内发出;充值后会重新启用。 | key_id, limit_usd |
事件数据从不包含机密:没有密钥、token 或兑换码。测试(POST /webhooks/{id}/test)会发送服务事件 webhook.test,数据为 {"message": "test"};该事件无法订阅。
请求与签名
我们发送带 JSON 请求体的 POST:
json{
"id": "evt_...",
"type": "order.completed",
"created_at": "2026-10-08T12:00:00Z",
"data": {
"order_id": 42,
"external_order_id": "shop-1042",
"kind": "new_key",
"key_id": "<key id>",
"amount_usd": "10.00"
}
} 请求体以紧凑的 JSON 发送,键按字母顺序排列;请对收到的原始字节验证签名,而不要对重新构造的 JSON 验证。
| 请求头 | 值 |
|---|---|
X-OP-Signature | t=<unix>,v1=<hex> |
X-OP-Event | 事件类型。 |
X-OP-Delivery | 与事件 id 相同。 |
User-Agent | OneProvider-Webhooks/1 |
v1 是 HMAC-SHA256 的十六进制值,密钥为 Webhook secret(whsec_...),消息为字符串 "<t>" + "." + raw_body。
验证签名
- 读取原始请求体:签名针对的是精确的字节,重新序列化后的 JSON 无法匹配。
- 重新计算 HMAC,并以恒定时间与 v1 比较(hmac.compare_digest、timingSafeEqual)。
- 如果 |now - t| 超过 5 分钟,则拒绝该请求,以免旧签名被重放。
- 尽快返回 2xx(超时为 10 秒),把耗时的工作放到异步中处理。
Python
pythonimport hashlib
import hmac
import time
TOLERANCE = 300 # seconds
def verify(secret: str, header: str, raw_body: bytes) -> bool:
"""header is X-OP-Signature: t=<unix>,v1=<hex>; raw_body is the unparsed request body."""
parts = dict(p.strip().partition("=")[::2] for p in header.split(","))
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE:
return False
signed = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1) python import json
import os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
SECRET = os.environ["OP_WEBHOOK_SECRET"] # whsec_...
seen: set[str] = set() # use a database table with a unique index in production
@app.post("/op-webhook")
async def op_webhook(request: Request):
raw = await request.body() # the raw bytes, before any JSON parsing
if not verify(SECRET, request.headers.get("X-OP-Signature", ""), raw):
raise HTTPException(status_code=400, detail="bad signature")
event = json.loads(raw)
if event["id"] in seen: # retries resend the same event id
return {"ok": True}
seen.add(event["id"])
if event["type"] == "order.completed":
... # your logic
return {"ok": True} # any 2xx marks the event as delivered Node.js
javascriptimport crypto from "node:crypto"
import express from "express"
const SECRET = process.env.OP_WEBHOOK_SECRET // whsec_...
const TOLERANCE = 300 // seconds
const seen = new Set() // use a database table with a unique index in production
function verify(secret, header, raw) {
const parts = Object.fromEntries(
String(header).split(",").map((p) => p.trim().split("=")),
)
const t = Number(parts.t)
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE) return false
const expected = crypto
.createHmac("sha256", secret)
.update(parts.t + ".")
.update(raw)
.digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(parts.v1 ?? "")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
const app = express()
// express.raw keeps the body as a Buffer: do not use express.json() on this route
app.post("/op-webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(SECRET, req.get("X-OP-Signature"), req.body)) return res.sendStatus(400)
const event = JSON.parse(req.body.toString("utf8"))
if (!seen.has(event.id)) {
seen.add(event.id) // retries resend the same event id
if (event.type === "order.completed") {
// your logic
}
}
res.sendStatus(200) // any 2xx marks the event as delivered
})
app.listen(3000) 重试
只有 2xx 响应才算已送达。其他情况(包括 3xx,因为不会跟随重定向,以及超时和连接错误)都会按指数退避重试:间隔 1 分钟、2 分钟、4 分钟,依此类推,每次间隔最长 4 小时,直到事件发生后 24 小时;之后该投递变为 failed。每次重试都带有相同的事件 id,因此请据此去重:把已处理的 id 保存在带唯一索引的表中。
URL 规则
- 仅限 https、端口 443(其他端口返回 400 invalid_url),且必须是主机名;IP 字面量会被拒绝。URL 最长 500 个 ASCII 字符,不能含用户名、密码或片段。
- 域名解析出的每个地址都不能是内网、回环、链路本地或保留地址。
- 创建时和每次投递时都会检查。创建时违规会返回 400
invalid_url。
测试与日志
bashcurl -s -X POST https://app.oneprovider.dev/partner/v1/webhooks/<webhook id>/test -H "Authorization: Bearer $OP_PARTNER_TOKEN"
curl -s "https://app.oneprovider.dev/partner/v1/webhooks/<webhook id>/deliveries?limit=20" -H "Authorization: Bearer $OP_PARTNER_TOKEN" 投递对象:id、event_id、event、status(pending、delivered、failed)、attempts、next_attempt_at、last_error(http_<status>、timeout、blocked_address 或连接错误的名称)、last_status(您的服务器返回的 HTTP 状态,未响应时为 null)、last_body(响应的开头部分,最多 300 个字符,单行)、created_at。删除 Webhook:DELETE /webhooks/{id}。测试和(在个人中心的)手动重试合计每分钟最多 6 次;只有未送达的事件才能重试,已送达的事件会返回 409 already_delivered。手动重试只能在个人中心进行,无法用 token 操作。