退款接口(Actions API)
自研商城实现一个签名的 HTTPS 接口,purser 通过它询问退款金额、发起退款;包括请求格式、签名校验、幂等、重试和结果回报。
purser 不经手钱,也不保存任何支付凭证。退款时,它调用你们自己的一个接口,由你们的系统去退。本页是这个接口的技术规格;AI 什么时候提出退款、谁来批准、额度和冻结,见 退款。
整个流程里 purser 会调你的接口三种请求:
| 类型 | 什么时候 | 你要做什么 |
|---|---|---|
ping | 控制台里点「测试连接」 | 回 { "ok": true } |
refund.preview | AI 准备提出退款、或客服发起退款时,先问金额 | 只计算,不动钱,返回金额或拒绝 |
refund.create | 顾客确认、并且自动执行或客服批准之后 | 真正退款(或只登记申请),返回结果 |
退款金额永远来自你的 refund.preview,不是模型。
在控制台开通
「设置 → 退款」(店主和管理员):
- 「退款接口地址」:你服务器上的一个地址,必须是
https://; - 「生成密钥」:签名密钥只显示这一次,复制到服务器的环境变量里(例如
PURSER_ACTIONS_SECRET)。之后只显示最后 4 位; - 「测试连接」:purser 发一个签名的
ping,结果会显示在页面上; - 「怎么退」:「直接退款」(接口收到就退)或「只提交退款申请」(接口只登记,处理完再回报结果);
- 打开「启用」。
要换密钥,点「换新密钥」:新密钥同样只显示一次,旧密钥立刻失效,你们的服务器要同时更新。
退款动作需要 Growth 或 Scale 套餐(测试工作区可以直接用),见 套餐与账单。
请求格式
POST <退款接口地址>
content-type: application/json
user-agent: purser-actions/1
purser-signature: t=<unix 秒>,v1=<hex HMAC-SHA256(密钥, "<t>.<原始 body>")>
purser-idempotency-key: <幂等键>{
"id": "01K…",
"type": "refund.create",
"workspace": "<工作区 ID>",
"createdAt": 1790000000000,
"idempotencyKey": "<幂等键>",
"data": {
"actionId": "01K…",
"orderId": "ord_2001",
"customerId": "cus_mia",
"lines": [{ "itemId": "li_1", "quantity": 1 }],
"reason": "defect",
"amount": 1599,
"currency": "USD",
"mode": "execute"
}
}| 字段 | 说明 |
|---|---|
id | 这一次投递的 ID,每次重试都不同 |
type | ping、refund.preview 或 refund.create |
workspace | 工作区 ID,和 GET /api/v1/ping 返回的 orgId 相同 |
createdAt | 投递时间,毫秒 |
idempotencyKey | 幂等键,和请求头 purser-idempotency-key 相同 |
data.actionId | purser 这笔退款的 ID,回报结果时用它 |
data.orderId | 你系统里的订单 ID(推送订单时的 id) |
data.customerId | 你系统里的顾客 ID,可能为 null |
data.lines | 要退的商品行(itemId 是订单商品行的 id);为空表示由你按订单和原因决定 |
data.reason | defect、not_received、return、other 之一,客服填了备注时后面接 : <备注> |
data.amount、data.currency | 只在 refund.create 里有:顾客(或客服)确认过的金额,最小货币单位 |
data.mode | execute(直接退款)或 request(只登记申请),对应控制台里的「怎么退」 |
ping 的 data 是空对象 {}。
客服在收件箱里先看金额时,refund.preview 可能在这笔退款还没建立时就发出,这时 data.actionId 是空字符串。
你要返回什么
都是 JSON,状态码 200:
| 类型 | 成功 | 拒绝 |
|---|---|---|
ping | { "ok": true } | — |
refund.preview | { "ok": true, "amount": 2500, "currency": "USD", "note": "…" } | { "ok": false, "code": "final_sale", "message": "…" } |
refund.create | { "ok": true, "status": "succeeded", "refundId": "re_…" } | { "ok": false, "code": "…", "message": "…" } |
amount必须是非负整数(最小货币单位),currency是三位大写代码。不符合时当作「没有回答」。refund.create的status只能是succeeded或pending。pending表示你接受了,稍后回报结果(「只提交退款申请」模式下就应该回pending)。- 拒绝时
code是你自己定的机器可读的原因,message会记录下来给客服看。拒绝就是最终结果,不会重试。
校验签名
purser-signature 的格式是 t=<unix 秒>,v1=<十六进制签名>。签名是用签名密钥对字符串 "<t>.<原始 body>" 做 HMAC-SHA256:
- 密钥就是控制台给你的完整字符串(以
whsec_开头,前缀也算在内); - 必须用收到的原始 body 计算,不要先解析 JSON 再序列化;
t和你服务器当前时间相差超过 5 分钟(300 秒) 就拒绝;- 用常量时间比较。
验签失败请回 401 和 { "ok": false, "code": "bad_signature" }。purser 会把这笔记为失败,不会重试。
Node.js(Express)示例,不依赖 SDK:
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyPurserSignature(secret, rawBody, header, nowS = Math.floor(Date.now() / 1000)) {
if (!secret || !header) return false;
const parts = Object.fromEntries(
header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(nowS - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = String(parts.v1 ?? "");
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
const app = express();
// 用 text() 拿原始 body,验签之后再解析
app.post("/purser/actions", express.text({ type: "application/json" }), async (req, res) => {
if (!verifyPurserSignature(process.env.PURSER_ACTIONS_SECRET, req.body, req.get("purser-signature"))) {
return res.status(401).json({ ok: false, code: "bad_signature" });
}
const delivery = JSON.parse(req.body);
const { type, idempotencyKey, data } = delivery;
try {
if (type === "ping") return res.json({ ok: true });
if (type === "refund.preview") {
const order = await db.orders.get(data.orderId);
if (order.finalSale) return res.json({ ok: false, code: "final_sale", message: "Final sale" });
return res.json({ ok: true, amount: order.total, currency: order.currency });
}
if (type === "refund.create") {
// 同一个幂等键只退一次
const done = await db.refunds.findByKey(idempotencyKey);
if (done) return res.json({ ok: true, status: "succeeded", refundId: done.id });
const refundId = await refundOrder(data.orderId, data.amount, data.currency, idempotencyKey);
await db.refunds.save({ key: idempotencyKey, id: refundId });
return res.json({ ok: true, status: "succeeded", refundId });
}
return res.status(400).json({ ok: false, code: "unknown_type" });
} catch (e) {
// 5xx = 让 purser 稍后用同一个幂等键重试
return res.status(500).json({ ok: false, code: "error" });
}
});db 和 refundOrder 换成你们自己的实现。
幂等与重试
refund.create的幂等键就是这笔退款的actionId,每次重试都一样(id每次不同)。你的系统对同一个幂等键最多退一次,重复收到时返回第一次的结果。这样 purser 重试永远不会让顾客被退两次钱。- 每次请求最多等 10 秒。purser 不跟随跳转。
- 超时、连不上、非 2xx 且不是上面那种
{ "ok": false, … }拒绝、2xx 但不是合法的 JSON 回答(或status不认识):都算「没有回答」。 refund.create没有回答时会退避重试,最多重试 5 次(第一次间隔 20 秒,之后按指数增长)。全部失败后这笔退款记为失败,客服能在「退款」页看到原因。ping和refund.preview不重试:没有回答就是这一次失败(测试连接显示失败;AI 不会提出这笔退款;客服看到错误)。
回报结果
refund.create 回了 pending(包括「只提交退款申请」模式),处理完之后用 API 密钥(sk_…,见 REST API 与 Node SDK)调:
curl -X POST https://askpurser.com/api/v1/actions/$ACTION_ID/status \
-H "Authorization: Bearer $PURSER_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "succeeded", "refundId": "re_123" }'| 字段 | 说明 |
|---|---|
status | succeeded 或 failed(必填) |
refundId | 你系统里的退款号(可选) |
message | 失败原因(可选),会记录给客服看 |
返回 { "id": "…", "status": "…" };这个工作区里没有这笔退款时返回 404。只有还在等结果(pending,或还在发送中)的退款会被更新,已经结束的退款保持原状,返回它当前的状态。顾客会在原来的对话里收到结果。
退款的状态
控制台「退款」页、MCP 的 list_refunds 工具里看到的状态:
| 状态 | 含义 |
|---|---|
awaiting_customer | 金额已经给顾客看了,等顾客确认 |
awaiting_approval | 超出额度、币种不是额度币种、自动退款被冻结,或这类问题的档位不到 R4,等客服批准 |
executing | 正在调你的接口(含重试) |
pending | 你的接口接受了,等你回报结果 |
succeeded / failed | 你的接口给出的结果(或全部重试失败) |
declined | 顾客说不用了 |
rejected | 客服拒绝了 |
cancelled / expired | 没人及时处理,或对话已经往下走了 |
用 SDK 写(发布后)
@purser-ai/node 里的 actionsHandler 会替你验签、按类型分发、返回正确的 JSON;你的函数抛异常时它回 500,purser 就会重试。这个包还没有发布到 npm,发布之前请用上面的写法。
import { actionsHandler } from "@purser-ai/node";
const handle = actionsHandler({
secret: process.env.PURSER_ACTIONS_SECRET,
refund: {
// 只计算,不动钱。金额用最小货币单位。
async preview({ orderId, lines, reason }) {
const order = await db.orders.get(orderId);
if (order.finalSale) return { refused: "final_sale", message: "Final sale" };
return { amount: order.total, currency: order.currency };
},
// 真退款。同一个 idempotencyKey 只能退一次。
async create({ orderId, amount, currency, idempotencyKey, mode }) {
const done = await db.refunds.findByKey(idempotencyKey);
if (done) return { status: "succeeded", refundId: done.id };
const refundId = await refundOrder(orderId, amount, currency, idempotencyKey);
await db.refunds.save({ key: idempotencyKey, id: refundId });
return { status: "succeeded", refundId };
},
},
});
// Cloudflare Workers / Bun / Deno / Hono 这类 fetch 风格的服务器:
export default { fetch: (request) => handle(request) };verifyActionSignature(secret, body, header) 也单独导出,可以只用它验签。
安全要点
- 金额永远来自你的
refund.preview;refund.create里的amount就是顾客确认过的那个数; - AI 只会为已登录顾客自己的订单、在你的退货政策允许的期限内提出退款;同一个订单同时只有一笔,已经通过 purser 退过的不会再提;
- 控制台「试聊」里顾客可以点确认,但永远不会调
refund.create。