开发者/功能接入

Webhook 通知

Webhook 是订单完成时,CardV 主动发到你服务器的一条通知。有了它就不用反复轮询。通知里不含卡密:收到后,请用 GET/api/v1/orders/{order_id} 查询订单。

Webhook 地址在商户后台配置(Owner,Integrations → Webhooks),正式环境和沙盒需要分别配置。API 不提供这项操作。

相关文档:商品与下单 · 安全

#通知类型

事件触发时机:订单变为
order.succeededsucceeded(成功)
order.partially_succeededpartially_succeeded(部分成功)
order.failedfailed(失败,还不等于已退款)
order.refundedrefunded(已退款,钱已回到钱包)
  • 每种事件对每个订单、每个 Webhook 地址最多发送一次(失败重试除外)。
  • accepted、processing 以及钱包加款不会发送通知。

#通知内容

CardV 会向你的 HTTPS 地址发送一个 POST 请求:

HTTP
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

请求体如下(这里为了阅读做了格式化,实际发送时是一行):

JSON
{
  "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 或重置密钥时显示一次。

签名规则:

Text
X-CardV-Signature: t=<unix seconds>,v1=<lowercase hex>
v1 = HMAC-SHA256(key = signing secret, message = "<t>" + "." + raw body bytes)

验证步骤:

  1. 读取原始请求体字节,先不要解析 JSON。
  2. 从请求头中取出 t 和 v1。
  3. 如果 t 和你的服务器时间相差超过 300 秒,拒绝这条消息。
  4. 计算出应有的 v1,用恒定时间比较函数与收到的值比较。
  5. 验证通过后,再解析 JSON。

签名只覆盖 t 和请求体。事件类型请以请求体里的 event 字段为准,不要依据 X-CardV-Event 请求头。

测试数据(密钥是假的):

Text
secret   whsec_TEST_ONLY_not_a_real_secret_000000000000
t        1790000100
v1       2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

对应的请求体就是下面这一行(266 字节,末尾无换行):

JSON
{"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)

Python
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 later

Node.js(Express)

Node.js
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。