Refund rules
A support agent can refund a Stripe charge, credit a Stripe customer's balance or refund a Shopify order. Each request goes through the action gateway you host, which reads the real facts from Stripe or Shopify and asks Burnbound. Burnbound decides with the agent's policy and its custom rules: allow, deny, or hold for a human. This page lists the facts a rule can read, the conditions on them, the ready-made templates and the three actions.
Where do the refund facts come from?
The agent only names the charge or the order, and maybe an amount and a reason. Everything else comes from the provider, read by your action gateway with your own key:
- Stripe: the charge (when it was created, its amount, what was already refunded, whether it is disputed), its customer, and the customer's charges of the last 90 days.
- Shopify: the order (when it was created, its amount, what was already refunded, its disputes), its customer, and the customer's orders of the last 90 days.
The gateway sends Burnbound the facts, never the key. Your Stripe key and your Shopify token stay in the gateway. Burnbound computes the ages and the share refunded at the moment of the decision, with its own clock, not the gateway's; the gateway counts the 90-day history when it reads it.
Which facts does the policy see?
| Fact | What it is | Condition |
|---|---|---|
| Age of the charge | Whole days from the charge (or the Shopify order) to the decision, rounded down | refundOriginalAgeDays |
| Share refunded | (Already refunded + this refund) / the original amount, from 0 to 1, with 4 decimals, rounded up | refundFraction |
| Disputed | The Stripe charge is disputed, or the Shopify order has a dispute | refundDisputed |
| Customer's age | Whole days from the customer's creation to the decision, rounded down | refundCustomerAgeDays |
| Customer's refunds (90d) | The customer's charges or orders of the last 90 days that were already refunded: how many, and their amount in USD | refundCustomerCount90d, refundCustomerUsd90d |
| Reason | The reason the agent asked with: duplicate, fraudulent, requested_by_customer, or none |
refundReason |
A full refund of a charge that was never refunded has a share of 1. Half of it is 0.5.
The customer's history works the same way for Stripe and Shopify:
- It counts the customer's charges (or orders) created in the 90 days before the gateway reads them that already carry a refund. The refund being decided is not in it, but the charge being refunded counts if it was partly refunded before.
- It counts charges, not refunds: a charge refunded in two parts counts once.
- Only the refund's currency counts. Charges in other currencies are left out. The USD amount uses the same rate as the refund, rounded up.
- The gateway reads one page of 100 charges or orders. If the customer has more, the history is truncated: the figures are lower bounds.
A fact is unknown when the gateway did not send it: a guest checkout without a customer, a deleted Stripe customer (no age), a read that failed, an older gateway, or a key that cannot read customers.
Which conditions can a rule use?
Add these to a rule's when, next to any other condition (see What are custom rules?).
| Condition | Takes | Values |
|---|---|---|
refundOriginalAgeDays |
A range: gt, gte, lt, lte |
Whole days, 0 to 1,000,000 |
refundFraction |
A range: gt, gte, lt, lte |
A number from 0 to 1, with 4 decimals at most |
refundCustomerAgeDays |
A range: gt, gte, lt, lte |
Whole days, 0 to 1,000,000 |
refundCustomerCount90d |
A range: gt, gte, lt, lte |
A whole count, 0 to 1,000,000 |
refundCustomerUsd90d |
A range: gt, gte, lt, lte |
USD as a decimal string, like amountUsd ("250") |
refundDisputed |
true or false |
|
refundReason |
One value or a list | duplicate, fraudulent, requested_by_customer, none |
A range needs at least one bound. A list matches any of its values. none is a refund the agent asked for without a reason; a Shopify refund never has one, so it is always none.
For example, a rules file for the refund audit that holds a large share refunded to a repeat customer, and any refund without a reason:
[
{
"id": "repeat-customer-review",
"when": {
"refundCustomerCount90d": { "gte": 3 },
"refundFraction": { "gte": 0.5 }
},
"then": { "action": "require_approval" }
},
{
"id": "refund-needs-reason",
"when": { "refundReason": "none" },
"then": { "action": "require_approval" }
}
]
What happens when a fact is missing?
| The payment | The condition |
|---|---|
| Is not a refund | Never matches |
| Is a refund, the fact is known | Compares |
| Is a refund, the fact is unknown | Matches: the restrictive branch |
| Has a truncated history | refundCustomerCount90d and refundCustomerUsd90d match |
A missing fact never lets a refund through. A deny on it denies, and a hold holds. A truncated history is a lower bound: when what was read already meets the bound, the condition holds; when it does not, the real total is unknown, so it holds too. The reason is never unknown: no reason is none.
This is why the key's permissions matter. If your Stripe key cannot read customers, every customer's age is unknown, and a rule on refundCustomerAgeDays takes the restrictive branch on every refund with a customer. The gateway logs it once, at the first refund.
As with any custom rule, a refund rule can only restrict. deny beats require_approval, which beats allow, and no rule makes a refund pass that the agent's caps refuse.
Refunds decided before 3 November 2026 were stored without these facts. A simulation does not count them for a refund rule, and the panel warns that the rule could block more than it shows.
What are policy templates and packs?
A template turns a few parameters into ordinary custom rules. A pack is several templates applied together. Their rules are named tpl-<template>, count toward your plan's rules, and can be switched between enforced and observe, or deleted, like any other.
The refund templates:
| Template | In the panel | What it writes | Default |
|---|---|---|---|
refund-amount-approval |
Approval for large refunds | Holds a refund above an amount | 100 USD |
refund-customer-monthly-limit |
Refunds per customer and month | A limit on the number of refunds per customer and UTC calendar month (a refund without a customer counts per charge or order); the next one is denied | 1 |
refund-old-charge-approval |
Approval for refunds of old charges | Holds a refund of a charge older than some days (refundOriginalAgeDays) |
30 days |
refund-disputed-deny |
No refunds of disputed charges | Denies a refund of a disputed charge (refundDisputed) |
|
refund-new-customer-full-approval |
Approval for full refunds to new customers | Holds a full refund (refundFraction from 1) to a customer younger than some days |
7 days |
refund-repeat-customer-approval |
Approval for repeat refund customers | Holds a refund to a customer with this many refunded charges in 90 days (refundCustomerCount90d) |
2 |
refund-daily-cap |
Daily refund cap | A limit on all refunds of a UTC day, in USD: per agent in an agent's rules, everyone's together in a subteam's or the organization's | 500 USD |
The support refunds pack (support-refunds, «Support agent with refunds» in the panel) applies the first four: large refunds and refunds of old charges wait for a human, one refund per customer a month, and none on a disputed charge.
The amount, per-customer and daily templates name the refund methods (action.stripe.refund and action.shopify.refund), so they never touch the agent's other payments. Stripe refunds and Shopify refunds add up to the same limits. A rule applied before Shopify refunds existed names only Stripe: delete its rules and apply the template again to cover Shopify. The other templates read refund facts, which only refunds have. No template covers Stripe credits.
How do I apply a template from the panel?
- Open the agent and turn on the refund method in its Network tab, under Payment methods. Once the saved policy allows
action.stripe.refund, the same tab offers the support refunds pack. - Or open Rules · custom rules. The gallery New rules from the catalog lists every template and pack; Refunds filters them.
- Pick one and adjust its values. Choose Enforced (denies or holds) or Observe (only notes it in the audit log).
- Preview shows the rules it writes and what each would have done to the last 30 days of payments. Nothing is saved.
- Apply writes all its rules in one policy version, all or nothing.
Subteam and organization rules have the same gallery, and every refund template and the pack work there too, on Team and Business. Changing rules needs an owner or an admin.
A template applies once per agent, subteam or organization: applying it again answers «Already applied» with the rules that are there. The approval for old charges, the disputed deny and the repeat customer approval open in the Protected refunds form. The other refund templates have no edit form: to change a value, delete their rules and apply the template again. If they do not fit in your plan, nothing is written and the panel says how many places are missing: Free has room for 3 custom rules and the pack writes 4.
For a condition the catalog does not cover (the reason, a free comparison), use Protected refunds in the agent's custom rules: one refund condition per rule (the fact, how it compares and the value; the share as a percentage from 0 to 100), then deny the refund or hold it for approval. Through the API, one rule can combine several conditions.
The API behind the gallery is GET /v1/policy-templates for the catalog and POST /v1/agents/:agentId/rules/from-template (also /v1/org/teams/:teamId/rules/from-template and /v1/org/rules/from-template) with { "packId": "support-refunds", "mode": "observe", "dryRun": true }. Reading the catalog takes any signed-in role or an org key; applying a template, also with dryRun, needs a signed-in owner or admin.
Which actions can a support agent take?
| MCP tool | Method | What the gateway reads | Rules |
|---|---|---|---|
stripe_refund |
action.stripe.refund |
The charge: currency, customer, livemode and what is still refundable; then the customer and their history | The refund conditions |
stripe_credit |
action.stripe.credit |
The customer: their balance currency, livemode and age. The credit goes to their balance and applies to their next invoices | creditCustomerAgeDays |
shopify_refund |
action.shopify.refund |
The order: presentment currency, what is still refundable on its payment, its disputes, whether it is a test order; then the customer's age and refunded orders of 90 days | The refund conditions |
In a refund, the currency, the customer and what is still refundable always come from the provider, never from the agent; the agent may only ask for an amount up to that. In a credit, the agent names the customer and the amount; the currency is the customer's balance currency, or the agent's only if the customer has none yet (another one is currency_mismatch). A refund above what is still refundable is refused before Burnbound is asked (amount_exceeds_refundable).
A credit is not a refund: the refund conditions never match it. Its own fact is the customer's age, creditCustomerAgeDays, a range of whole days with the same rule for an unknown value. A Shopify refund returns money to the order's original payment, without restocking.
All three are off by default. Turn each one on per agent with allowMethods (the Payment methods in the Network tab). Each one counts as spend toward the agent's caps, goes through the same approvals, and runs once per idempotencyKey. A held one comes back as pending_approval; whoever approves sees the refund facts in the panel, Slack or email. Once it is approved, the agent sends the same request again with the same idempotencyKey.
The MCP tools reach the gateway at BURNBOUND_GATEWAY_URL. Details of every field and error code are in the MCP tools reference.
Which permissions do the keys need?
The Stripe key is a restricted key (rk_…) with only:
| Resource | Permission |
|---|---|
| Charges | Read |
| Customers | Read (Write for credits) |
| PaymentIntents | Read |
| Refunds | Write |
Credits need Customers in Write, because customer balance transactions are created and listed under Customers. A full secret key works, but the gateway warns about it.
The Shopify token is the Admin API access token of a custom app in your store's admin (shpat_…), with only the scopes read_orders, write_orders and read_customers. Without read_customers, the customer's age and history reach Burnbound as unknown.
The gateway turns on each action only when its provider is configured. A Stripe key turns on refunds and credits; SHOPIFY_SHOP_DOMAIN and SHOPIFY_ADMIN_TOKEN turn on Shopify refunds. An action without its provider answers action_not_configured.
How do I move real money?
Start in Stripe's test mode and on a Shopify development store. Live mode needs GATEWAY_ALLOW_LIVE=1 in the gateway:
- Stripe: the gateway refuses to start with a live key unless it is set.
- Shopify: a token does not say whether it moves real money. Unless it is set, the gateway refunds only on a development store, checked on every request, and answers
live_not_allowedotherwise.
How do I try the rules first?
The refund audit replays your Stripe refunds of the last days against the support refunds pack, or your own rules file, on your machine. It builds the facts the same way the gateway does and tells you how many refunds would have been denied or held, by which rule. Nothing is sent to Burnbound.
In Burnbound, apply the pack in Observe mode first: decisions record what the rules would have done, without blocking.
Where do I set up the gateway?
- Run the action gateway in 5 minutes: the gateway's credential, the restricted key and a deployment on Render, Railway, Fly.io or Docker Compose, from the panel's assistant.
- Action gateway: Stripe refunds under your agent's policy: the whole flow, from the agent's request to the reported refund.