开发者/功能接入
Webhook 通知
Webhook 是订单完成时,CardV 主动发到你服务器的一条通知。有了它就不用反复轮询。通知里不含卡密:收到后,请用
GET/api/v1/orders/{order_id} 查询订单。
Webhook 地址在商户后台配置(Owner,Integrations → Webhooks),正式环境和沙盒需要分别配置。API 不提供这项操作。
#通知类型
| 事件 | 触发时机:订单变为 |
|---|---|
order.succeeded | succeeded(成功) |
order.partially_succeeded | partially_succeeded(部分成功) |
order.failed | failed(失败,还不等于已退款) |
order.refunded | refunded(已退款,钱已回到钱包) |
- 每种事件对每个订单、每个 Webhook 地址最多发送一次(失败重试除外)。
accepted、processing以及钱包加款不会发送通知。
#通知内容
CardV 会向你的 HTTPS 地址发送一个 POST 请求:
POST /cardv/webhook HTTP/1.1
Content-Type: application/json
User-Agent: CardV-B2B-Webhook/1.0
X-CardV-Event: order.succeeded
X-CardV-Delivery: 5521
X-CardV-Timestamp: 1790000100
X-CardV-Signature: t=1790000100,v1=2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27请求体如下(这里为了阅读做了格式化,实际发送时是一行):
{
"event": "order.succeeded",
"order": {
"id": "O-00000001",
"order_id": "O-00000001",
"external_order_id": "TEST-0001",
"status": "succeeded",
"currency": "USD",
"total_amount": "9.2500",
"items": [
{
"sku_id": "S000001",
"product_name": "Example Card",
"quantity": 1,
"delivery_count": 1
}
]
}
}order.order_id是 CardV 订单号,order.id的值与它相同。order.external_order_id是你的商户订单号。order.status是事件发生时的状态,可能已经不是最新状态。X-CardV-Delivery是这条通知的编号,可以在商户后台的推送记录里找到。
#验证签名
每条 Webhook 都用这个 Webhook 的签名密钥(whsec_...)签名。签名密钥只在创建 Webhook 或重置密钥时显示一次。
签名规则:
X-CardV-Signature: t=<unix seconds>,v1=<lowercase hex>
v1 = HMAC-SHA256(key = signing secret, message = "<t>" + "." + raw body bytes)验证步骤:
- 读取原始请求体字节,先不要解析 JSON。
- 从请求头中取出
t和v1。 - 如果
t和你的服务器时间相差超过 300 秒,拒绝这条消息。 - 计算出应有的
v1,用恒定时间比较函数与收到的值比较。 - 验证通过后,再解析 JSON。
签名只覆盖 t 和请求体。事件类型请以请求体里的 event 字段为准,不要依据 X-CardV-Event 请求头。
测试数据(密钥是假的):
secret whsec_TEST_ONLY_not_a_real_secret_000000000000
t 1790000100
v1 2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27对应的请求体就是下面这一行(266 字节,末尾无换行):
{"event":"order.succeeded","order":{"id":"O-00000001","order_id":"O-00000001","external_order_id":"TEST-0001","status":"succeeded","currency":"USD","total_amount":"9.2500","items":[{"sku_id":"S000001","product_name":"Example Card","quantity":1,"delivery_count":1}]}}Python(Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["CARDV_WEBHOOK_SECRET"].encode()
def verify(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
try:
parts = dict(item.split("=", 1) for item in header.split(","))
t, v1 = parts["t"].strip(), parts["v1"].strip()
except (KeyError, ValueError):
return False
if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
signed = t.encode() + b"." + raw_body
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), v1.encode()) # constant time
@app.post("/cardv/webhook")
def cardv_webhook():
raw = request.get_data() # raw bytes, before JSON parsing
if not verify(raw, request.headers.get("X-CardV-Signature", "")):
abort(400)
event = request.get_json()
store_event(request.headers["X-CardV-Delivery"], event) # your code
return "", 204 # answer fast; do the work laterNode.js(Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.CARDV_WEBHOOK_SECRET;
function verify(rawBody, header, toleranceSeconds = 300) {
const parts = {};
for (const p of String(header || "").split(",")) {
const i = p.indexOf("=");
parts[p.slice(0, i).trim()] = p.slice(i + 1).trim();
}
const { t, v1 } = parts;
if (!t || !v1 || !/^\d+$/.test(t)) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(t)) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", SECRET)
.update(Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]))
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the exact bytes. Do not use express.json() on this route.
const rawJson = express.raw({ type: "application/json" });
app.post("/cardv/webhook", rawJson, async (req, res) => {
if (!verify(req.body, req.get("X-CardV-Signature"))) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
await storeEvent(req.get("X-CardV-Delivery"), event); // your code
res.sendStatus(204);
});两个示例都能通过上面的测试数据。
#失败重试
- 请在 15 秒内返回任意 2xx 状态码,并且要在保存好通知之后再返回。读取订单、发货等耗时操作请放到之后处理。
- 其他任何情况都算失败:非 2xx 状态码、超时或连接错误。
- CardV 不跟随跳转,返回 3xx 也算失败。请直接登记最终的地址。
- 失败的通知会重试,总共最多发送 6 次:
| 第几次 | 发送时间 |
|---|---|
| 1 | 事件发生后立即发送 |
| 2 | 第 1 次失败后约 1 分钟 |
| 3 | 第 2 次失败后约 5 分钟 |
| 4 | 第 3 次失败后约 15 分钟 |
| 5 | 第 4 次失败后约 30 分钟 |
| 6 | 第 5 次失败后约 60 分钟 |
实际时间可能再晚最多 1 分钟。第 6 次仍失败后,CardV 不再重试。所有通知都可以在商户后台的推送记录里查看,并手动重新推送。
#重复通知
同一个事件可能收到不止一次,事件到达的顺序也可能是乱的。
- 已经处理过的事件直接忽略。判断依据是验签通过后请求体里的
order.order_id加event。从商户后台手动重新推送的通知会有新的X-CardV-Delivery编号,所以不能只看这个编号。 - 请求体是事件发生那一刻的快照。请始终重新查询订单,按订单的当前状态处理。
- Webhook 有可能丢失。建议再跑一个定时任务,检查超过几分钟仍未完成的订单。
#地址要求
- 地址必须以
https://开头,并且指向公网地址。内网和本机地址会被拒绝。 - 可以订阅全部四种事件,也可以只选其中几种。
- 重置签名密钥会立即生效,旧密钥马上失效。想要不停机切换,可以先新增一个使用新密钥的 Webhook,部署好之后,再停用旧的那个。
- CardV 会保留你响应内容的前 1,000 个字符,用于排查问题。请不要在响应里返回密钥或个人信息。
接入遇到问题?请发送邮件至 [email protected],并附上 Merchant ID 与订单号或请求 ID。