接入文档
灵策中台接入指南:应用收款与 Webhook
本页目录
接入灵策中台的产品(后台登记的「中台应用」)可以用这套接口卖自己的商品:在你的服务端向中台下单,用户用微信支付或支付宝付款,钱进灵策商户号,付款、退款、关单的结果通过 Webhook 推给你的服务端。
这套接口和灵策积分无关:不扣也不加任何灵策积分,订单只记在中台收款单里,由你自己发货。登录接入见 统一登录。
#1. 开通
找灵策运营(超级管理员)在后台「灵策中台 → 应用」里,对你的应用依次做:
- 生成密钥:得到
client_id+client_secret(形如lcs_…)。密钥只显示一次,后台只存哈希,丢了只能轮换。 - 收款与 Webhook:
- 打开「开通收款」,勾选允许的支付方(微信支付、支付宝);
- 填 Webhook 地址(生产必须 https,不能是内网地址);
- 生成签名密钥(形如
whsec_…,同样只显示一次)。没有签名密钥时事件不会投递。
- 轮换密钥有 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
{
"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
{ "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 你要做的事:
- 按事件
id去重; - 把这笔退款在你系统里的状态改回未退款(如果你在
refund.succeeded时已经回收权益或标记已退,要撤销或转人工); - 以
data.order的status、refundedFen为准,这笔金额已回到可退余额; - 仍要退款就用新的
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 订单对象
{
"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 分钟余量)后,中台会自动处理,你不需要自己调关单:
- 先向支付方查单:已经付了 → 记为
paid,推送payment.succeeded(和回调同一条路径,不会重复); - 没付 → 向支付方关单,订单记为
closed,推送order.closed,data.reason = "expired"; - 查单失败或支付方状态不明时订单保持
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 事件体
{
"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。
{
"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。
{
"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。
{
"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。
{
"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>.<原始请求体>") 的十六进制。务必:
- 用原始请求体字符串算(不要把解析后的 JSON 再序列化);
- 常量时间比较;
- 校验
t与当前时间相差不超过 5 分钟(防重放); - 签名头里可能有多个
v1(签名密钥轮换后的 24 小时宽限期内,新密钥在前、旧密钥在后),任一个与你算出的值相等即通过。 SDK 已处理;自己验签时别只取第一个或最后一个。
用 SDK(零依赖,Node 20+):
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):
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:服务端客户端
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),耗时逻辑放进你自己的队列。