Refund endpoint (Actions API)
A signed HTTPS endpoint your custom store implements so purser can ask for a refund amount and start a refund: request format, signature check, idempotency, retries and reporting the outcome.
purser never touches money and stores no payment credentials. To refund, it calls an endpoint of your own, and your system does the refund. This page is the technical specification of that endpoint; when the AI offers a refund, who approves it, limits and freezing are covered in Refunds.
purser sends your endpoint three kinds of request:
| Type | When | What you do |
|---|---|---|
ping | Someone clicks Test connection (测试连接) in the console | Answer { "ok": true } |
refund.preview | Before the AI offers a refund, or when an agent starts one: purser asks for the amount | Calculate only, move no money; return an amount or a refusal |
refund.create | After the customer confirms and the refund is either automatic or approved by an agent | Refund for real (or only record the request) and return the outcome |
The refund amount always comes from your refund.preview, never from the model.
Turning it on in the console
Settings (设置) → Refunds (退款), owners and admins:
- Refund endpoint URL (退款接口地址): an address on your server; it must be
https://; - Create secret (生成密钥): the signing secret is shown once; copy it into your server's environment (for example
PURSER_ACTIONS_SECRET). Afterwards only its last 4 characters are shown; - Test connection (测试连接): purser sends a signed
pingand shows the result on the page; - How to refund (怎么退): Refund directly (直接退款, your endpoint refunds when it receives the request) or Only submit a refund request (只提交退款申请, your endpoint records it and reports the outcome later);
- Switch on Enabled (启用).
To change the secret, click New secret (换新密钥): the new one is also shown once, and the old one stops working immediately, so update your server at the same time.
Refund actions need the Growth or Scale plan (test workspaces can use them right away); see Plan and billing.
Request format
POST <your endpoint URL>
content-type: application/json
user-agent: purser-actions/1
purser-signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
purser-idempotency-key: <idempotency key>{
"id": "01K…",
"type": "refund.create",
"workspace": "<workspace ID>",
"createdAt": 1790000000000,
"idempotencyKey": "<idempotency key>",
"data": {
"actionId": "01K…",
"orderId": "ord_2001",
"customerId": "cus_mia",
"lines": [{ "itemId": "li_1", "quantity": 1 }],
"reason": "defect",
"amount": 1599,
"currency": "USD",
"mode": "execute"
}
}| Field | Meaning |
|---|---|
id | This delivery's ID; different on every retry |
type | ping, refund.preview or refund.create |
workspace | The workspace ID, the same as the orgId returned by GET /api/v1/ping |
createdAt | When it was sent, in milliseconds |
idempotencyKey | The idempotency key, the same as the purser-idempotency-key header |
data.actionId | purser's ID for this refund; use it to report the outcome |
data.orderId | The order's ID in your system (the id you pushed the order with) |
data.customerId | The customer's ID in your system; may be null |
data.lines | The order lines to refund (itemId is the order line's id); empty means you decide from the order and the reason |
data.reason | One of defect, not_received, return, other, followed by : <note> when an agent wrote a note |
data.amount, data.currency | Only in refund.create: the amount the customer (or an agent) agreed to, in minor units |
data.mode | execute (refund directly) or request (only record the request), matching How to refund (怎么退) in the console |
For ping, data is an empty object {}.
When an agent checks the amount in the inbox before confirming, refund.preview can be sent before the refund exists; data.actionId is then an empty string.
What to answer
Always JSON, status 200:
| Type | Success | Refusal |
|---|---|---|
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": "…" } |
amountmust be a non-negative integer (minor units) andcurrencya three-letter code in capitals. Anything else counts as "no answer".- The
statusofrefund.createmust besucceededorpending.pendingmeans you accepted it and will report the outcome later (in Only submit a refund request mode, answerpending). - In a refusal,
codeis your own machine-readable reason andmessageis recorded for your agents. A refusal is final and is not retried.
Checking the signature
purser-signature looks like t=<unix seconds>,v1=<hex signature>. The signature is HMAC-SHA256 with the signing secret over the string "<t>.<raw body>":
- the key is the whole string the console gave you (it starts with
whsec_, and the prefix is part of it); - compute it over the raw body you received; do not parse the JSON and serialise it again;
- refuse it if
tis more than 5 minutes (300 seconds) away from your server's clock; - compare in constant time.
If the signature does not check out, answer 401 with { "ok": false, "code": "bad_signature" }. purser records the refund as failed and does not retry.
A Node.js (Express) example without the 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();
// Take the raw body with text(); parse it only after the signature is checked
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") {
// Refund at most once per idempotency key
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 retries later with the same idempotency key
return res.status(500).json({ ok: false, code: "error" });
}
});Replace db and refundOrder with your own code.
Idempotency and retries
- The idempotency key of
refund.createis the refund'sactionId, and it is the same on every retry (idchanges each time). Refund at most once per idempotency key, and answer with the first result when you see a key again. Then a retry from purser can never refund a customer twice. - purser waits at most 10 seconds per request and does not follow redirects.
- A timeout, a failed connection, a non-2xx response that is not a
{ "ok": false, … }refusal like the ones above, or a 2xx that is not a valid JSON answer (or has an unknownstatus): all count as "no answer". - With no answer,
refund.createis retried with backoff, up to 5 retries (the first after 20 seconds, then growing exponentially). If every attempt fails, the refund is recorded as failed and your agents see why on the Refunds (退款) page. pingandrefund.previeware not retried: no answer means that attempt failed (Test connection shows a failure; the AI does not offer that refund; the agent sees an error).
Reporting the outcome
If refund.create answered pending (including in Only submit a refund request mode), call this with your API secret key (sk_…, see REST API and Node SDK) once the refund is done:
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" }'| Field | Meaning |
|---|---|
status | succeeded or failed (required) |
refundId | Your refund reference (optional) |
message | Why it failed (optional), recorded for your agents |
The answer is { "id": "…", "status": "…" }, or 404 if the refund is not in this workspace. Only a refund still waiting for its outcome (pending, or still being sent) is updated; a finished one stays as it is and its current status is returned. The customer is told the outcome in the original conversation.
Refund statuses
The statuses you see on the Refunds (退款) page and in the list_refunds tool of MCP:
| Status | Meaning |
|---|---|
awaiting_customer | The amount has been shown to the customer; waiting for them to confirm |
awaiting_approval | Over a limit, not in the limit currency, automatic refunds frozen, or the rung for this kind of issue is below R4: waiting for an agent to approve |
executing | Your endpoint is being called (including retries) |
pending | Your endpoint accepted it; waiting for you to report the outcome |
succeeded / failed | Your endpoint's outcome (or every retry failed) |
declined | The customer said no |
rejected | An agent said no |
cancelled / expired | Nobody acted in time, or the conversation moved on |
With the SDK (once published)
actionsHandler in @purser-ai/node checks the signature, dispatches by type and answers in the right JSON; if your function throws, it answers 500 and purser retries. The package is not published on npm yet; until it is, use the code above.
import { actionsHandler } from "@purser-ai/node";
const handle = actionsHandler({
secret: process.env.PURSER_ACTIONS_SECRET,
refund: {
// Calculate only, move no money. Amounts in minor units.
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 };
},
// The real refund. Refund at most once per 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 };
},
},
});
// Fetch-style servers such as Cloudflare Workers, Bun, Deno, Hono:
export default { fetch: (request) => handle(request) };verifyActionSignature(secret, body, header) is also exported, if you only want the signature check.
Safety
- The amount always comes from your
refund.preview; theamountinrefund.createis exactly the number the customer confirmed; - the AI only offers refunds for a signed-in customer's own orders, within the periods your return policy allows; one refund per order at a time, and an order already refunded through purser is not offered again;
- in the console's Try it (试聊), the customer can click confirm, but
refund.createis never called.
REST API and Node SDK
Push customers, products, orders, shipments and past tickets to purser from your server with a secret key, and how the Node SDK does it for you.
Connect your own AI assistant over MCP
Let Claude, Cursor or any MCP-capable assistant read conversations and customers, tag, add knowledge and, if you allow it, approve refunds, acting as you.