Action gateway: Stripe refunds under your agent's policy
A support agent that can refund customers is useful until a message in a ticket convinces it to refund someone who never paid. The Burnbound action gateway puts refunds under the agent's spending policy. It is a small service you run, next to your agents, with your Stripe key. The agent asks it for a refund; the gateway reads the real charge from Stripe, asks Burnbound whether the agent's policy allows it, refunds only if it does, and reports the outcome. Burnbound never holds your Stripe key.
How a refund is decided
- The agent calls the MCP tool
stripe_refundwith a charge (ch_…) or a payment intent (pi_…) and, for a partial refund, an amount in minor units (cents). - The MCP sends it to your gateway with the agent's own Burnbound key.
- The gateway reads the charge from Stripe. The currency, the customer and what is still refundable come from Stripe, never from the agent. An amount over what is refundable is refused.
- Burnbound decides with the agent's policy, as for any payment: method allowed, maximum per payment, daily cap, team budgets, your own rules and the approval threshold. A refund is money leaving the company, so it counts as spend.
- Allowed: the gateway creates the refund in Stripe, with Stripe idempotency key
bb_<intent id>, and reports it. Denied: nothing is refunded and the agent gets the reason. Over the approval threshold: it waits for a human.
The same idempotencyKey never refunds twice, even when the agent asks again after the refund happened.
What you need
- A Stripe account. Start in test mode, with a test charge to refund.
- An agent in Burnbound with an agent key (
bb_agent_…), and@burnbound/mcp0.10.0 or later in your MCP client:stripe_refundfirst ships in 0.10.0, and an older version does not have the tool. - Docker on the machine that runs the gateway. The gateway ships as a public image for amd64 and arm64; you do not need the Burnbound source.
Step 1: Create a restricted Stripe key
In Stripe, open Developers → API keys → Create restricted key and give it only:
| Resource | Permission |
|---|---|
| Charges | Read |
| Customers | Read |
| PaymentIntents | Read |
| Refunds | Write |
Customers read lets rules use the customer's age. Without it the gateway logs once that it cannot read customers, and a rule on the customer's age treats it as unknown on every refund.
Use the test-mode key first (rk_test_…). The gateway refuses a live key (sk_live_, rk_live_) unless you set GATEWAY_ALLOW_LIVE=1. A full secret key works but is not recommended. Save the key in a file only the gateway can read, for example stripe.key.
Step 2: Create the gateway credential in Burnbound
The gateway proves to Burnbound that it is your gateway. In the dashboard, under Organization, create an action gateway. Burnbound shows its id and a secret that starts with bbgw_, once: copy both. The gateway signs every call to Burnbound with that secret, so a request that does not come from your gateway is refused even with a valid agent key. Keep the secret like a password. Rotate gives a new secret and keeps the old one valid for 24 hours, so you can update the gateway without a failed refund. That is not enough after a leak, because the leaked secret would keep working for those 24 hours: use Rotate without a grace period (the old secret stops at once, and the gateway fails until you give it the new one), or Revoke the gateway and create another one.
Step 3: Run the gateway
From the folder that holds stripe.key:
docker run --rm -p <loopback address>:8787:8787 \
-v "$PWD/stripe.key:/run/secrets/stripe:ro" \
-e STRIPE_SECRET_KEY_FILE=/run/secrets/stripe \
-e BURNBOUND_GATEWAY_ID=<gateway id> \
-e BURNBOUND_GATEWAY_SECRET=bbgw_… \
ghcr.io/martinvarelaa/burnbound-action-gateway:0.1.0
Pin a version, as above, rather than latest, so an update happens when you choose it. <loopback address> is the machine's own IPv4 loopback address: Docker then publishes the port to processes on that machine only. The container runs as an unprivileged user and holds one bundled file: no sources and no node_modules. It listens on port 8787 inside the container (GATEWAY_PORT). It talks to https://app.burnbound.dev (BURNBOUND_API). If you point BURNBOUND_API at a URL with a path, such as https://host/api, put a proxy in front of the API that strips that path and set GATEWAY_API_PATH_PREFIX_STRIPPED=1; without it the gateway refuses to start. Keep it on loopback or on a private network: anyone who reaches it with one of your agent keys can ask for refunds. GET /healthz answers { "status": "ok" }. Its logs carry ids and codes, never a key.
Step 4: Allow refunds on the agent
On the agent's page in Burnbound:
- Under Payment methods, turn on Stripe · refund. It is off by default, for every agent.
- Add
stripe-refund.actionto the allowed hosts: that is the host of every refund. - Set the maximum per payment, the daily cap and, for refunds a person should look at, the approval threshold. Refunds count toward them in USD; EUR, GBP and other currencies are converted with the ECB's daily rate, rounding up.
Step 5: Point the MCP at the gateway
Add BURNBOUND_GATEWAY_URL to the MCP server's environment, next to the agent key. In Claude Code:
claude mcp add burnbound --scope user -e BURNBOUND_KEY=bb_agent_… \
-e BURNBOUND_GATEWAY_URL=<gateway URL> -- npx -y @burnbound/mcp@latest
In a JSON MCP config (Cursor, Claude Desktop):
{
"mcpServers": {
"burnbound": {
"command": "npx",
"args": ["-y", "@burnbound/mcp@latest"],
"env": {
"BURNBOUND_KEY": "bb_agent_…",
"BURNBOUND_GATEWAY_URL": "<gateway URL>"
}
}
}
}
<gateway URL> is where the gateway listens: http:// with the loopback address and port 8787 when it runs on the same machine, https:// anywhere else; the MCP refuses plain http:// to any other host. Without it, stripe_refund answers that the gateway is not configured and refunds nothing. Then ask the agent, for example: "Refund 3 euros of charge ch_… to the customer."
How approvals work
A refund at or above the agent's approval threshold is not executed. stripe_refund returns pending_approval with an approvalId and an idempotencyKey, Burnbound notifies you (inbox, and Slack or email if you set them up) and nothing reaches Stripe. A human opens the refund in the dashboard's approvals inbox and approves or denies it; the agent cannot approve its own refund. Once approved, the agent calls stripe_refund again with the same arguments and the same idempotencyKey, and the gateway refunds once. A denied or expired approval is not retried. The timeouts are those of human approvals.
Currencies
- USD always works. EUR, GBP and the other currencies the European Central Bank publishes are converted with its daily rate (refreshed hourly, rounded up). A currency without a rate (for example KWD, unless the server pins one in
FIAT_USD_RATES) is denied withcurrency_not_supportedand nothing reaches Stripe. - Decimals follow Stripe, not ISO 4217:
amountMinoris in minor units of the charge's currency. 12.50 EUR is1250, 500 JPY is500, 1.500 KWD is1500(thousandths) and 5 ISK is500(Stripe counts ISK and UGX with two decimals).stripe_refundanswers with thecurrency, thedecimalsand theamountas text (12.50 EUR). - Conversions round up to the micro-dollar, so a refund never looks smaller than it is to the caps.
Known limits
- A refund whose result never reached Burnbound stays submitted. If Stripe made the refund but the gateway could not report it (the API was unreachable at that moment),
stripe_refundstill answersrefundedwithreported: false, and the intent stayssubmittedin Burnbound: its amount keeps counting as spend, and it is not shown as settled. Find the refund in Stripe by its metadataburnbound_intent. - One action for now: Stripe refunds. Other actions will use the same gateway and the same policy.
- You run it. Burnbound does not host the gateway: your Stripe key stays on your machine.
- Daily exchange rates. Other currencies use the European Central Bank's reference rate of the day, not the live market rate (see Currencies above).
Burnbound itself is set up in the quickstart. The same policy covers the agent's Stripe Issuing card and its x402 payments to APIs from the catalog; every control is in How to limit an AI agent's spending.