文档导航收款与 Webhook

接入文档

灵策中台接入指南:应用收款与 Webhook

本页目录

接入灵策中台的产品(后台登记的「中台应用」)可以用这套接口卖自己的商品:在你的服务端向中台下单,用户用微信支付或支付宝付款,钱进灵策商户号,付款、退款、关单的结果通过 Webhook 推给你的服务端。

这套接口和灵策积分无关:不扣也不加任何灵策积分,订单只记在中台收款单里,由你自己发货。登录接入见 统一登录。

#1. 开通

找灵策运营(超级管理员)在后台「灵策中台 → 应用」里,对你的应用依次做:

  1. 生成密钥:得到 client_id + client_secret(形如 lcs_…)。密钥只显示一次,后台只存哈希,丢了只能轮换。
  2. 收款与 Webhook:
    • 打开「开通收款」,勾选允许的支付方(微信支付、支付宝);
    • 填 Webhook 地址(生产必须 https,不能是内网地址);
    • 生成签名密钥(形如 whsec_…,同样只显示一次)。没有签名密钥时事件不会投递。
  3. 轮换密钥有 24 小时宽限期(默认):
    • 轮换 client_secret 后,旧密钥在 24 小时内仍能调用接口,过期自动失效。请在这段时间内把新密钥部署到你的服务端;
    • 轮换签名密钥后,24 小时内每次投递的 X-Lynxce-Signature 同时带新旧两个 v1(见 4.3),用任一把都能验签;
    • 怀疑密钥泄露时,运营可勾选「立即作废旧密钥」:旧密钥马上失效(你的调用会 401 / 验签失败,直到换上新密钥)。

client_secret 与 whsec_ 只能放在你的服务端。绝不能出现在浏览器、App、小程序代码里。

#2. 鉴权

所有接口都在 https://center.lynxce.cn/api/center/pay/*(与 https://api.lynxce.cn 同一组服务)。二选一:

文本
Authorization: Basic base64(client_id:client_secret)

或

文本
X-Lynxce-App-Id: <client_id>
X-Lynxce-App-Secret: <client_secret>
  • 凭证错误、应用停用:401 invalid_client(不区分原因);
  • 应用没开通收款:403 center_pay_disabled;
  • 同一 IP 10 分钟内失败 20 次、或单个应用每分钟超过 600 次调用:429 rate_limited。

成功响应统一 {"code":0,"data":…},错误统一 {"error":{"code":"…","message":"…"}} + 对应 HTTP 状态码。全部错误码和处理办法见 错误码。

#3. 接口

#3.1 下单 POST /api/center/pay/orders

JSON
{
  "outTradeNo": "demo-20261029-0001",
  "subject": "专业版月卡",
  "amountFen": 1990,
  "provider": "wechat",
  "scene": "native",
  "userId": "灵策账号 sub(可选)",
  "expireMinutes": 30,
  "returnUrl": "https://demo.example.com/paid",
  "clientIp": "1.2.3.4",
  "openid": "…",
  "metadata": { "sku": "pro-monthly" }
}
字段 说明
outTradeNo 你的单号,6-64 位字母数字 _ -。幂等键:同号同参重放返回原单(不会重复下单);同号但金额 / 标题 / 支付方 / 场景 / userId 不同 → 409 out_trade_no_conflict
amountFen 金额,整数分,1 起,单笔上限默认 100 万元(运营可调低)
provider / scene 微信 wechat:native(扫码,返回 qrCode)、h5(手机浏览器,返回 payUrl,必填 clientIp)、jsapi(微信内网页,返回 clientParams,必填灵策服务号下的 openid);支付宝 alipay:page(电脑网站,payUrl)、wap(手机网站,payUrl)、precreate(当面付扫码,qrCode)。支付方必须在后台为你的应用勾选过,且灵策已开通该场景,否则 403 payment_provider_not_allowed / 503 payment_provider_unavailable
expireMinutes 订单有效期,5-120,默认 30。过期仍未付款的单会被自动关闭(见 3.6)
returnUrl page / wap / h5 付款后回跳页,必须与应用首页或登录回调白名单同源,否则 400 return_url_not_allowed
metadata 原样存、原样在查询和 Webhook 里返回,≤ 4KB

返回订单对象(下文 3.5)。payment 字段给出调起所需的东西,只在待付时有值。

支付方下单失败时返回 502,这张单会被记为 closed,请换一个 outTradeNo 重新下单。

#3.2 查单 GET /api/center/pay/orders/:orderNo

orderNo 是中台单号(CTR 开头)。只能查自己应用的单,别人的单号一律 404。待付单会顺带向支付方查一次(每单 10 秒最多一次),已付款就地更新为 paid。

#3.3 关单 POST /api/center/pay/orders/:orderNo/close

只能关待付单:先向支付方查单,已经付了 → 记为 paid 并返回 409 order_already_paid;查单失败 → 502(请重试);未付 → 同时向支付方关单(撤销二维码 / 支付链接),订单记为 closed,并推送 order.closed(data.reason = "app_closed")。

注意支付方侧关单是尽力而为:极少数情况下(关单请求失败、或用户恰好在关单瞬间付款),订单之后仍可能变成 paid,你会收到 payment.succeeded 且 data.reopenedAfterClose = true,请按业务决定补发货或发起退款。

#3.4 退款 POST /api/center/pay/orders/:orderNo/refund

JSON
{ "reason": "用户申请退款", "amountFen": 500, "outRefundNo": "rf-20261029-0001" }
字段 说明
amountFen 本次退款金额(分)。不传 = 退剩余全部(兼容 v1 全额退款)
outRefundNo 你的退款单号,1-64 位字母数字 _-,同一订单内唯一,是退款的幂等键。部分退款必须传;全额退款可不传
reason 退款原因,≤ 80 字

规则:

  • 只能退 paid 的单(否则 409 order_not_paid),一张单可以多次部分退款,累计不超过实付(超了 409 refund_amount_exceeded);
  • 部分退款后订单仍是 paid,refundedFen 累加;退满才变 refunded(退满后再退 409 order_fully_refunded);
  • 同一 outRefundNo 重复调用幂等,不会退两次;金额不同 409 refund_conflict;
  • 支付方明确拒绝(422 payment_refund_rejected,比如商户余额不足):这笔退款记为失败、额度释放,同号再调返回 409 refund_failed,换一个 outRefundNo 重新发起;
  • 支付方结果未知(502,如超时):额度保持占用,用同一个 outRefundNo 重试(支付方侧按退款单号幂等);
  • 每笔成功(或支付方已受理,refundStatus = processing)的退款推送一个 refund.succeeded;
  • 注意refund.succeeded 且 refund.status = "processing" 只代表支付方已受理,钱还没到账,之后可能失败。失败时推送 refund.failed(见 3.4.1)。

#3.4.1 退款状态与收敛

状态 含义 额度 下一步
pending 支付方结果未知(超时 / 5xx) 占用 你可以用同一 outRefundNo 重试;不重试中台也会查询收敛
processing 支付方已受理,处理中 占用 等待,中台持续查询
success 钱已退回 占用 终态
failed 退款失败 已释放 终态。要再退就换一个新的 outRefundNo

中台每隔几分钟向支付方查询创建超过 5 分钟的 pending / processing 退款,微信的退款结果通知也会触发同样的处理:

  • 查到成功:记 success。之前是 pending 时推送 refund.succeeded;之前是 processing 时不会再推送(受理时已推过),需要最终结果请查退款明细;
  • 查到失败:记 failed、释放额度,订单若已是 refunded 会变回 paid,推送 refund.failed;
  • pending 的退款,支付方持续查不到:从最后一次调用支付方算起 24 小时后,且确认这笔交易在支付方可见,才判定支付方没收到,按失败处理。所以结果未知的退款最长可能占用额度一天多;
  • processing 的退款支付方查不到,或状态不明、查询失败:保持原状继续重试,不会自动判失败,由平台运营跟进。支付宝的查询接口分不清「未收到」和「退款失败」,所以支付宝已受理的退款如果失败,不会自动转成 failed,需要运营人工处理;
  • 运营人工标记的退款走同样的状态变化和事件:标记成功时,之前是 pending 才推送 refund.succeeded;标记失败时释放额度、推送 refund.failed,failReason 为 manual_failed。

收到 refund.failed 你要做的事:

  1. 按事件 id 去重;
  2. 把这笔退款在你系统里的状态改回未退款(如果你在 refund.succeeded 时已经回收权益或标记已退,要撤销或转人工);
  3. 以 data.order 的 status、refundedFen 为准,这笔金额已回到可退余额;
  4. 仍要退款就用新的 outRefundNo 重新发起,同号会得到 409 refund_failed。

refund.failed 一定晚于这笔退款的受理,但 Webhook 不保证顺序,可能比 refund.succeeded 先到:同一笔退款收到过 refund.failed 后,迟到的 refund.succeeded 要忽略。

返回订单对象(见 3.5)。退款明细:GET /api/center/pay/orders/:orderNo/refunds → { items: [{ refundNo, outRefundNo, amountFen, reason, status, failReason, createdAt, updatedAt }] }, status 为 pending(结果未知,额度占用中)/ success / processing / failed,含义见 3.4.1。

#3.5 订单对象

JSON
{
  "orderNo": "CTRMGB7Q1K2A3B4C5D6E7F8G9H",
  "outTradeNo": "demo-20261029-0001",
  "appId": "demo",
  "subject": "专业版月卡",
  "amountFen": 1990,
  "provider": "wechat",
  "scene": "native",
  "status": "pending",
  "providerTradeNo": null,
  "paidAt": null,
  "closedAt": null,
  "refundedAt": null,
  "refundStatus": null,
  "refundedFen": 0,
  "expiresAt": "2026-10-29T08:30:00.000Z",
  "userId": null,
  "metadata": { "sku": "pro-monthly" },
  "payment": { "qrCode": "weixin://wxpay/bizpayurl?pr=…" },
  "createdAt": "…",
  "updatedAt": "…"
}

状态:pending(待付)→ paid(已付,可多次部分退款,refundedFen 累加)→ refunded(已退满);pending → closed(已关闭); closed → paid(关单后迟到的成功付款,见 3.3)。

少数情况下灵策运营会人工处理订单:用户付了款、付款通知丢失,之后在商户平台退了款。这张单会从 pending 直接变成终点状态:全额退款变成 refunded,只推送 refund.succeeded,不推 payment.succeeded,你不用发货;部分退款变成 paid 并带 refundedFen,推送 payment.succeeded 和 refund.succeeded,请按已付款处理。这类退款的事件里 refund.outRefundNo 是 null,因为不是你发起的;退款明细接口里它的 outRefundNo 是 upstream:manual(全额时是 full)。

#3.6 过期自动关单

待付单过了 expiresAt(再留约 2 分钟余量)后,中台会自动处理,你不需要自己调关单:

  1. 先向支付方查单:已经付了 → 记为 paid,推送 payment.succeeded(和回调同一条路径,不会重复);
  2. 没付 → 向支付方关单,订单记为 closed,推送 order.closed,data.reason = "expired";
  3. 查单失败或支付方状态不明时订单保持 pending,稍后重试(不会在不确定时关单)。

一般过期后 1-3 分钟内收到 order.closed。同样适用 3.3 的 reopenedAfterClose 说明。

#4. Webhook

#4.1 投递

  • POST <你的 Webhook 地址>,Content-Type: application/json,请求头:
    • X-Lynxce-Signature: t=<unix 秒>,v1=<hex>(签名密钥轮换后 24 小时内为 t=…,v1=<新>,v1=<旧>,见 4.3)
    • X-Lynxce-Event-Id: <事件 ID>
    • X-Lynxce-Event-Type: payment.succeeded | refund.succeeded | refund.failed | order.closed
  • 你返回 2xx 才算送达(3xx 也算失败,中台不跟随重定向),超时 10 秒。
  • 失败重试 8 次,间隔 15 秒、1 分钟、5 分钟、30 分钟、2 小时、6 小时、12 小时、24 小时(约 45 小时);之后放弃,运营可在后台「投递记录」里手动重发。灵策运营会收到投递失败告警,但修好你的接收端、补发事件要你们一起确认。
  • 投递成功的记录保留 90 天,之后清理,不能再在后台查看或重发;收款单和退款记录不清理。
  • 同一事件可能送达多次(例如你处理成功但响应超时),请按 id(事件 ID)去重;同一个事件只有一个 ID。部分退款一单有多笔,每笔退款各有自己的事件 ID。
  • 不保证顺序:以订单对象里的 status 为准,必要时调查单接口确认。

#4.2 事件体

JSON
{
  "id": "evt_pay_CTRMGB7Q1K2A3B4C5D6E7F8G9H",
  "type": "payment.succeeded",
  "version": "v1",
  "appId": "demo",
  "createdAt": "2026-10-29T08:01:02.000Z",
  "data": {
    "order": { "orderNo": "CTR…", "outTradeNo": "demo-20261029-0001", "status": "paid", "amountFen": 1990, "…": "…" },
    "reopenedAfterClose": false
  }
}
事件 时机 data
payment.succeeded@v1 支付方确认付款(回调或查单),金额与订单一致 order、reopenedAfterClose
refund.succeeded@v1 每笔退款成功或已受理(部分退款一单可能多个) order、refund {refundNo, outRefundNo, amountFen, status}(不带单号的全额退款 outRefundNo 为 null)
refund.failed@v1 支付方受理后退款失败,或支付方始终没收到这笔退款(每笔退款最多一个);发起时被同步拒绝的退款不推送,接口已直接报错 order(已释放额度后的状态)、refund {refundNo, outRefundNo, amountFen, status: "failed", failReason},处理方式见 3.4.1
order.closed@v1 应用关单、过期自动关单或平台运营关单 order、reason(app_closed / expired / admin_closed)

事件只会新增字段,不会改已有字段;不兼容的改动会用新的 version。

#4.2.1 事件样例

下面是四种事件的完整样例,字段和真实投递一致(文档和代码由测试对账)。order 是投递那一刻的订单快照,字段同 3.5,但不带 payment。事件 ID 的规则:付款 evt_pay_<orderNo>、关单 evt_close_<orderNo>、退款 evt_refund_<refundNo>、退款失败 evt_refund_failed_<refundNo>。不带 outRefundNo 的全额退款,后两种事件的 ID 用 orderNo 代替 refundNo。

payment.succeeded@v1:reopenedAfterClose = true 表示订单关闭后才付款成功,见 3.3。

JSON
{
  "id": "evt_pay_CTRMGB7Q1K2A3B4C5D6E7F8G9H",
  "type": "payment.succeeded",
  "version": "v1",
  "appId": "demo",
  "createdAt": "2026-10-29T08:01:02.000Z",
  "data": {
    "order": {
      "orderNo": "CTRMGB7Q1K2A3B4C5D6E7F8G9H",
      "outTradeNo": "demo-20261029-0001",
      "appId": "demo",
      "subject": "专业版月卡",
      "amountFen": 1990,
      "provider": "wechat",
      "scene": "native",
      "status": "paid",
      "providerTradeNo": "4200002900202610290000000001",
      "paidAt": "2026-10-29T08:01:02.000Z",
      "closedAt": null,
      "refundedAt": null,
      "refundStatus": null,
      "refundedFen": 0,
      "expiresAt": "2026-10-29T08:30:00.000Z",
      "userId": "cmvsym1j60002eu4x2y8qchxq",
      "metadata": { "sku": "pro-monthly" },
      "createdAt": "2026-10-29T08:00:00.000Z",
      "updatedAt": "2026-10-29T08:01:02.000Z"
    },
    "reopenedAfterClose": false
  }
}

refund.succeeded@v1:refund.status 是 success(钱已退)或 processing(支付方已受理,之后可能失败)。部分退款后 order.status 仍是 paid。

JSON
{
  "id": "evt_refund_CTRMGB7Q1K2A3B4C5D6E7F8G9HR5f3a9c1e",
  "type": "refund.succeeded",
  "version": "v1",
  "appId": "demo",
  "createdAt": "2026-10-30T02:00:05.000Z",
  "data": {
    "order": {
      "orderNo": "CTRMGB7Q1K2A3B4C5D6E7F8G9H",
      "outTradeNo": "demo-20261029-0001",
      "appId": "demo",
      "subject": "专业版月卡",
      "amountFen": 1990,
      "provider": "wechat",
      "scene": "native",
      "status": "paid",
      "providerTradeNo": "4200002900202610290000000001",
      "paidAt": "2026-10-29T08:01:02.000Z",
      "closedAt": null,
      "refundedAt": "2026-10-30T02:00:05.000Z",
      "refundStatus": "success",
      "refundedFen": 500,
      "expiresAt": "2026-10-29T08:30:00.000Z",
      "userId": "cmvsym1j60002eu4x2y8qchxq",
      "metadata": { "sku": "pro-monthly" },
      "createdAt": "2026-10-29T08:00:00.000Z",
      "updatedAt": "2026-10-30T02:00:05.000Z"
    },
    "refund": {
      "refundNo": "CTRMGB7Q1K2A3B4C5D6E7F8G9HR5f3a9c1e",
      "outRefundNo": "rf-20261029-0001",
      "amountFen": 500,
      "status": "success"
    }
  }
}

refund.failed@v1:order 是释放额度之后的状态,这笔金额已回到可退余额。处理方式见 3.4.1。

JSON
{
  "id": "evt_refund_failed_CTRMGB7Q1K2A3B4C5D6E7F8G9HR5f3a9c1e",
  "type": "refund.failed",
  "version": "v1",
  "appId": "demo",
  "createdAt": "2026-10-30T02:20:00.000Z",
  "data": {
    "order": {
      "orderNo": "CTRMGB7Q1K2A3B4C5D6E7F8G9H",
      "outTradeNo": "demo-20261029-0001",
      "appId": "demo",
      "subject": "专业版月卡",
      "amountFen": 1990,
      "provider": "wechat",
      "scene": "native",
      "status": "paid",
      "providerTradeNo": "4200002900202610290000000001",
      "paidAt": "2026-10-29T08:01:02.000Z",
      "closedAt": null,
      "refundedAt": null,
      "refundStatus": "failed",
      "refundedFen": 0,
      "expiresAt": "2026-10-29T08:30:00.000Z",
      "userId": "cmvsym1j60002eu4x2y8qchxq",
      "metadata": { "sku": "pro-monthly" },
      "createdAt": "2026-10-29T08:00:00.000Z",
      "updatedAt": "2026-10-30T02:20:00.000Z"
    },
    "refund": {
      "refundNo": "CTRMGB7Q1K2A3B4C5D6E7F8G9HR5f3a9c1e",
      "outRefundNo": "rf-20261029-0001",
      "amountFen": 500,
      "status": "failed",
      "failReason": "ABNORMAL"
    }
  }
}

order.closed@v1:reason 是 app_closed、expired 或 admin_closed。

JSON
{
  "id": "evt_close_CTRMGC8R2L3B4C5D6E7F8G9H0J",
  "type": "order.closed",
  "version": "v1",
  "appId": "demo",
  "createdAt": "2026-10-29T08:32:10.000Z",
  "data": {
    "order": {
      "orderNo": "CTRMGC8R2L3B4C5D6E7F8G9H0J",
      "outTradeNo": "demo-20261029-0002",
      "appId": "demo",
      "subject": "专业版月卡",
      "amountFen": 1990,
      "provider": "alipay",
      "scene": "page",
      "status": "closed",
      "providerTradeNo": null,
      "paidAt": null,
      "closedAt": "2026-10-29T08:32:10.000Z",
      "refundedAt": null,
      "refundStatus": null,
      "refundedFen": 0,
      "expiresAt": "2026-10-29T08:30:00.000Z",
      "userId": null,
      "metadata": {},
      "createdAt": "2026-10-29T08:00:00.000Z",
      "updatedAt": "2026-10-29T08:32:10.000Z"
    },
    "reason": "expired"
  }
}

#4.3 验签

签名 = HMAC-SHA256(whsec_密钥, "<t>.<原始请求体>") 的十六进制。务必:

  1. 用原始请求体字符串算(不要把解析后的 JSON 再序列化);
  2. 常量时间比较;
  3. 校验 t 与当前时间相差不超过 5 分钟(防重放);
  4. 签名头里可能有多个 v1(签名密钥轮换后的 24 小时宽限期内,新密钥在前、旧密钥在后),任一个与你算出的值相等即通过。 SDK 已处理;自己验签时别只取第一个或最后一个。

用 SDK(零依赖,Node 20+):

TypeScript
import { constructWebhookEvent, WebhookSignatureError } from "@ai-employee/center-sdk";

// Express 示例:必须拿到原始 body
app.post("/webhooks/lynxce", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const event = await constructWebhookEvent({
      payload: req.body, // Buffer
      signature: req.header("x-lynxce-signature"),
      secret: process.env.LYNXCE_WEBHOOK_SECRET!,
    });
    if (await alreadyHandled(event.id)) return res.sendStatus(200);
    if (event.type === "payment.succeeded") await fulfill(event.data.order.outTradeNo);
    await markHandled(event.id);
    res.sendStatus(200);
  } catch (error) {
    res.sendStatus(error instanceof WebhookSignatureError ? 400 : 500);
  }
});

不用 SDK 时(Node):

TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  const pairs = header.split(",").map((kv) => kv.split("=") as [string, string]);
  const t = Number(pairs.find(([key]) => key === "t")?.[1]);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"));
  // 轮换宽限期内会有多个 v1:任一匹配即可
  return pairs
    .filter(([key]) => key === "v1")
    .some(([, given]) => given.length === expected.length && timingSafeEqual(Buffer.from(given), expected));
}

#5. SDK:服务端客户端

TypeScript
import { CenterServerClient, CenterApiError } from "@ai-employee/center-sdk";

const pay = new CenterServerClient({
  baseUrl: "https://center.lynxce.cn",
  clientId: process.env.LYNXCE_CLIENT_ID!,
  clientSecret: process.env.LYNXCE_CLIENT_SECRET!,
});

const order = await pay.createOrder({
  outTradeNo: "demo-20261029-0001",
  subject: "专业版月卡",
  amountFen: 1990,
  provider: "alipay",
  scene: "page",
  returnUrl: "https://demo.example.com/paid",
});
// 浏览器跳转 order.payment.payUrl;之后以 Webhook / getOrder 为准,别信前端回跳

await pay.getOrder(order.orderNo);
await pay.closeOrder(order.orderNo);
await pay.refund(order.orderNo, { reason: "用户申请" }); // 退剩余全部
await pay.refund(order.orderNo, { amountFen: 500, outRefundNo: "rf-0001", reason: "部分退" }); // 部分退款
await pay.listRefunds(order.orderNo);

错误抛 CenterApiError(code / message / status,全部错误码见 错误码),网络失败抛 CenterSdkError("network_error")。 Webhook 验签:签名不对抛 WebhookSignatureError(应回 400);签名密钥没配置抛 CenterSdkError("invalid_config"),属于你的服务配置错误(回 500,中台会按退避重试,配好密钥后能收到)。下单遇到网络错误可以用同一个 outTradeNo 安全重试。

#6. 对接检查清单

  • client_secret、whsec_ 只在服务端,走环境变量 / 密钥管理;
  • 下单前在你自己的库里先记一笔,outTradeNo 用它的主键,网络错误用同号重试;
  • 发货只认 Webhook(验签 + 事件 ID 去重)或服务端查单,不认前端回跳;
  • 核对 amountFen 与你的订单金额一致后再发货;
  • 处理 reopenedAfterClose = true 的付款(补发或退款);
  • 自己验签时支持签名头里的多个 v1(密钥轮换宽限期);轮换密钥后 24 小时内换上新密钥;
  • 不必自己清理过期待付单:会收到 order.closed(reason = "expired");
  • Webhook 处理要快(10 秒内返回 2xx),耗时逻辑放进你自己的队列。