Z Zise Developers

card.3ds.received

卡片 3DS / OTP 验证码已收到

何时发

发卡上游把 3DS / OTP 验证码回调给我方、并成功落库到 card_3ds_events 之后。 目前生产者只有两条:WAS 与 INT。 data.id 直接就是公开卡资源 ID(crd_<id>),拿它回查仍走 GET /v1/cards/{id};真正给你展示验证码的, 是事件体里受控附带的三个字段: - otp_code:给用户手输的短码; - expires_at:这条码的失效时间(上游有给才会带值)。 - application_id:若这张卡来自申请单,会带公开格式的 cap_<id>, 方便你直接关联自己那笔开卡申请;没有申请单来源的卡则不带这个字段。 ⚠ 事件体不会暴露我方内部 event_key,也不会告诉你上游 provider 是谁; 商户侧应该只依赖自己已经持有的会员/卡/申请单坐标做关联。 ⚠ 这条事件与 card.status.updated 不是一回事:收到验证码并不等于卡状态发生变化; 尤其 WAS 会在收到激活码后继续尝试自动激活,成不成要看后续 card.activated 或卡状态回查,别把这条事件当成“激活成功”。

这条事件不由任何 API 调用发起

发卡上游的 3DS 或 OTP 回调落库后触发,不由商户 OpenAPI 端点直接发起。

事件体

{
  "event_id": "evt_8dc2a13d65904ef29c5ea2f2fe7a1dc2",
  "event_type": "card.3ds.received",
  "created_at": "2026-09-07T11:38:19Z",
  "merchant_id": "acme",
  "livemode": true,
  "data": {
    "object": "card",
    "id": "crd_e70b3d41-5a28-4c96-91f0-6b2a8c05d7e3",
    "external_member_id": "u_88123",
    "status": "received",
    "status_version": 13,
    "application_id": "cap_3882c371-572f-4d5a-af08-f61fc0964de9",
    "otp_code": "54695634",
    "expires_at": "2026-09-07T11:38:19.549Z"
  }
}

事件体字段

字段类型说明
idstring去重用这个。evt_…,同一事件重投时不变。
typestring固定为 card.3ds.received
created_atstring事件产生时刻(RFC3339)。不是投递时刻 —— 重投时它不变。
data.objectstring对象类型,决定 data.id 该拿去查哪个端点
data.idstring对象 ID,拿它回查详情
data.statusstring认不出的值按未知处理并告警,不要 fallback 成「处理中」
data.status_versionnumber单调递增,用它把慢到的旧状态丢掉

验签与去重

验签用原始请求体字节,不要先解析再重新序列化 —— 你的 JSON 库与我方的键顺序、空格几乎一定不同,重新序列化出来的签名一定对不上, 而那个错误长得像「密钥配错了」。

// Node · 放在解析 JSON 之前
const raw = await readRawBody(req);            // Buffer / string,别用已解析的 req.body
const expect = crypto.createHmac("sha256", WEBHOOK_SECRET).update(raw).digest("hex");
const got = req.headers["z-signature"];        // 形如 t=<unix>,v1=<hex>
if (!timingSafeEqual(expect, parseV1(got))) return res.status(400).end();

// 去重:用信封的 id,不是 data.id
if (await seen(JSON.parse(raw).id)) return res.status(200).end();

完整做法(含时间戳容差与重投语义)见 Webhook 指南。