purser文档
purser文档
首页价格English登录控制台

开始

purser 文档10 分钟上线

接入渠道

网站聊天窗口App 客服页邮件WhatsApp 和 Instagram

AI 客服

知识库AI 怎么回答试聊与发布

收件箱与团队

收件箱团队与分流顾客

更多功能

退款产品反馈

账号与计费

套餐与账单账号与数据

开发者

身份令牌:告诉 purser 顾客是谁REST API 与 Node SDK退款接口(Actions API)用 MCP 连接你自己的 AI 助手

退款接口(Actions API)

自研商城实现一个签名的 HTTPS 接口,purser 通过它询问退款金额、发起退款;包括请求格式、签名校验、幂等、重试和结果回报。

purser 不经手钱,也不保存任何支付凭证。退款时,它调用你们自己的一个接口,由你们的系统去退。本页是这个接口的技术规格;AI 什么时候提出退款、谁来批准、额度和冻结,见 退款。

整个流程里 purser 会调你的接口三种请求:

类型什么时候你要做什么
ping控制台里点「测试连接」回 { "ok": true }
refund.previewAI 准备提出退款、或客服发起退款时,先问金额只计算,不动钱,返回金额或拒绝
refund.create顾客确认、并且自动执行或客服批准之后真正退款(或只登记申请),返回结果

退款金额永远来自你的 refund.preview,不是模型。

在控制台开通

「设置 → 退款」(店主和管理员):

  1. 「退款接口地址」:你服务器上的一个地址,必须是 https://;
  2. 「生成密钥」:签名密钥只显示这一次,复制到服务器的环境变量里(例如 PURSER_ACTIONS_SECRET)。之后只显示最后 4 位;
  3. 「测试连接」:purser 发一个签名的 ping,结果会显示在页面上;
  4. 「怎么退」:「直接退款」(接口收到就退)或「只提交退款申请」(接口只登记,处理完再回报结果);
  5. 打开「启用」。

要换密钥,点「换新密钥」:新密钥同样只显示一次,旧密钥立刻失效,你们的服务器要同时更新。

退款动作需要 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,每次重试都不同
typeping、refund.preview 或 refund.create
workspace工作区 ID,和 GET /api/v1/ping 返回的 orgId 相同
createdAt投递时间,毫秒
idempotencyKey幂等键,和请求头 purser-idempotency-key 相同
data.actionIdpurser 这笔退款的 ID,回报结果时用它
data.orderId你系统里的订单 ID(推送订单时的 id)
data.customerId你系统里的顾客 ID,可能为 null
data.lines要退的商品行(itemId 是订单商品行的 id);为空表示由你按订单和原因决定
data.reasondefect、not_received、return、other 之一,客服填了备注时后面接 : <备注>
data.amount、data.currency只在 refund.create 里有:顾客(或客服)确认过的金额,最小货币单位
data.modeexecute(直接退款)或 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" }'
字段说明
statussucceeded 或 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。

REST API 与 Node SDK

用密钥从你的服务器把顾客、商品、订单、物流和历史工单推给 purser,以及 Node SDK 的用法。

用 MCP 连接你自己的 AI 助手

让 Claude、Cursor 等支持 MCP 的 AI 助手以你的身份查对话和顾客、打标签、补知识库,经你授权后批退款。

本页内容

在控制台开通请求格式你要返回什么校验签名幂等与重试回报结果退款的状态用 SDK 写(发布后)安全要点