Approve Refunds Issued by an AI Support Agent
Give a support agent the power to resolve tickets by issuing refunds and you've also given it the power to drain your Stripe balance on a bad day — gate every refund behind human approval and the agent can still resolve the ticket fast, just not unsupervised.
Why refunds are a different risk class than replies
Most AI support agent mistakes are annoying: a wrong tone, a repeated answer, a missed nuance. A refund mistake is a wire transfer. If the agent misreads a policy ("full refund for any complaint within 30 days"), gets talked into it by a customer who knows the trigger phrases, or simply hallucinates an order total, the money moves. Stripe doesn't ask the agent if it's sure — refunds.create executes on the API call, not on the intent behind it.
This is also the failure mode most likely to compound: a support agent processing a queue of 200 tickets doesn't make one mistake, it makes the same mistake 200 times if the bad reasoning is systemic (e.g., a misconfigured refund policy in its prompt). A human checkpoint before the Stripe call catches that on ticket one instead of ticket two hundred.
Wiring the gate into the refund path
The agent's refund tool should never call Stripe directly — it should call a wrapper that pushes the proposal to Impri and only calls Stripe once approved:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const IMPRI_KEY = process.env.IMPRI_API_KEY!;
const BASE = "https://api.impri.dev";
interface RefundProposal {
chargeId: string;
amountCents: number;
customerEmail: string;
reason: string;
}
async function proposeRefund(p: RefundProposal): Promise<string> {
const res = await fetch(`${BASE}/v1/actions`, {
method: "POST",
headers: {
Authorization: `Bearer ${IMPRI_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
kind: "payment.refund",
title: `Refund $${(p.amountCents / 100).toFixed(2)} — ${p.customerEmail}`,
preview: {
format: "markdown",
body: `**Charge:** ${p.chargeId}\n**Amount:** $${(p.amountCents / 100).toFixed(2)}\n**Reason given by agent:** ${p.reason}`,
},
expires_in: 43200, // 12h — support tickets shouldn't sit longer than that
idempotent: false,
undo: "No automatic undo — a reversed refund requires a new manual charge",
}),
});
const { id } = await res.json();
return id;
}
async function waitForDecision(actionId: string) {
while (true) {
const res = await fetch(`${BASE}/v1/actions/${actionId}`, {
headers: { Authorization: `Bearer ${IMPRI_KEY}` },
});
const data = await res.json();
if (data.status !== "pending") return data;
await new Promise((r) => setTimeout(r, 5000));
}
}
async function refundWithApproval(p: RefundProposal) {
const actionId = await proposeRefund(p);
const decision = await waitForDecision(actionId);
if (decision.status !== "approved") {
return { executed: false, status: decision.status };
}
const refund = await stripe.refunds.create({ charge: p.chargeId, amount: p.amountCents });
await fetch(`${BASE}/v1/actions/${actionId}/result`, {
method: "POST",
headers: { Authorization: `Bearer ${IMPRI_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ status: "executed", payload: { refundId: refund.id } }),
});
return { executed: true, refundId: refund.id };
}The stripe.refunds.create call sits behind the decision.status !== "approved" early return — that's the whole point. The agent's ticket-resolution logic can decide a refund is warranted, draft the reasoning, and call refundWithApproval, but the actual money movement is one if away from a human decision, not from the agent's own judgment.
What the reviewer sees, and how it maps to risk
The preview.body above deliberately surfaces the charge ID, the dollar amount, and the agent's stated reasoning — a reviewer approving refunds from a phone needs those three facts and nothing else to make a fast, correct call. undo is set because refunds genuinely have none through this API; the reviewer should know that before tapping approve, not find out after.
| Refund path | Recommended handling |
|---|---|
| Under a set threshold (e.g. $20), clear policy match | Still gate it — small refunds add up across a queue |
| Above threshold, or ambiguous policy match | Gate it, and consider routing to a senior reviewer via a separate key/inbox |
| Duplicate charge, customer-verified error | Gate it — idempotent: false warns the reviewer a retry would double-refund |
| Chargeback already filed | Don't refund via the agent at all — that's a payments-team process, not a support ticket |
Result payload and audit trail
Reporting result with payload: { refundId: refund.id } means the Stripe refund ID is attached to the approval record — when finance asks "who approved this and why," the answer is in one place instead of split across Stripe's dashboard and your support tool's logs. See audit log for how these records are retained and queried.
Boundaries
Impri does not know your refund policy and does not evaluate whether $340 is reasonable for this ticket — it shows the reviewer what the agent proposed and holds the yes/no. The agent still owns the reasoning; the reviewer still owns the judgment call; Impri owns making sure the Stripe call can't happen without both. For the full three-call pattern (REST and MCP) and how to make the wrapper an actual chokepoint rather than a suggestion, see the main integration guide. If your team wants a Slack-based approve/reject flow instead of the web inbox, see approving agent actions from Slack.
New to Impri? Start with the quickstart.