A refund over $100 triggers an email to the manager. The manager is in meetings. The customer waits a day and leaves a one-star review. The email thread — the only record of the decision — is lost in a search. That's not an approval workflow, that's a bottleneck with extra steps.
The fix is a refund approval queue: the support agent's system asks a named decision with the amount, reason, and customer context attached; the manager approves or rejects; the outcome flows back to the ticket and the record is queryable forever.
Stripe refund approval workflow: threshold, not exception
Policy lives in code, not in the manager's head:
| Request | Under $100 | $100–$500 | Over $500 | Policy deviation |
|---|---|---|---|---|
| Refund | Agent auto-approves | Ask manager | Ask manager | Ask manager |
| Credit / comp | Agent | Agent | Ask | Ask |
Step 1: the bot asks for the refund
Your support bot (Zendesk, Intercom, or a script) checks the threshold and creates the decision instead of processing it:
import os
from datetime import datetime, timedelta, timezone
from actionbox import Actionbox
AB = Actionbox(api_key=os.environ["ACTIONBOX_TOKEN"])
def maybe_refund(ticket: dict, amount_cents: int) -> dict:
THRESHOLD_CENTS = 10_000 # $100
if amount_cents < THRESHOLD_CENTS:
return stripe_refund(ticket, amount_cents) # policy allows
action = AB.actions.create(
title=f"Refund ${amount_cents / 100:.2f} for ticket #{ticket['id']}?",
description=(
f"Customer: {ticket['customer_email']}\n"
f"Reason: {ticket['reason']}\n"
f"Refund amount: ${amount_cents / 100:.2f}"
),
options=[
{"id": "approve", "label": "Approve refund", "style": "primary"},
{"id": "reject", "label": "Reject refund", "style": "destructive"},
],
context=[
{"key": "amount", "value": f"${amount_cents / 100:.2f}"},
{"key": "customer_tier", "value": ticket["tier"]},
{"key": "request_type", "value": ticket["reason"]},
{"key": "ticket", "value": ticket["id"]},
],
expires_at=(datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat(),
on_expire={"type": "return_expired"},
idempotency_key=f"refund:{ticket['id']}:{amount_cents}",
)
decision = action.wait(timeout=1800)
if decision == "approve":
return stripe_refund(ticket, amount_cents)
return {"status": "declined", "reason": "rejected or expired"}The manager sees everything needed to decide in thirty seconds — amount, customer, reason — no tab-hopping into the helpdesk.
Step 2: the decision flows back to the ticket
Approved or declined, the outcome returns to where the agent works:
if decision == "approve":
zendesk.add_comment(ticket["id"], "Refund approved and processed by finance policy.")
else:
zendesk.add_comment(
ticket["id"],
"Refund declined or expired. "
"Offering store credit instead.",
)The customer gets an answer while the context is still warm — not a silent wait while the manager "gets to it".
Step 3: the record is the policy review
Every request becomes a data point, not a memory:
Use Actionbox History or the documented user-scoped Actions API to review
resolved refund decisions by Source, amount, reason, and timestamp. The CLI
get command requires one Action ID; there is no actionbox list command.
Patterns show up fast: which agents escalate most, which request types get approved, whether the $50 threshold is right, whether refund reasons are calibrated. That's how you tune policy instead of guessing.
Full example: Stripe webhook with threshold routing
import json
import os
from datetime import datetime, timedelta, timezone
from actionbox import Actionbox
AB = Actionbox(api_key=os.environ["ACTIONBOX_TOKEN"])
def on_refund_request(event: dict) -> dict:
payload = event["data"]["object"]
amount_cents = payload["amount"]
THRESHOLD_CENTS = 10_000 # $100
if amount_cents < THRESHOLD_CENTS:
return {"decision": "auto_approved", "amount": amount_cents}
action = AB.actions.create(
title=f"Customer refund request: ${amount_cents / 100:.2f}",
description=payload.get("description") or "No description provided.",
options=[
{"id": "approve", "label": "Approve refund", "style": "primary"},
{"id": "reject", "label": "Reject refund", "style": "destructive"},
],
context=[
{"key": "amount", "value": f"${amount_cents / 100:.2f}"},
{"key": "payment_intent", "value": payload["payment_intent"]},
{"key": "chargeback_risk", "value": payload.get("risk_level", "unknown")},
],
expires_at=(datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat(),
on_expire={"type": "return_expired"},
idempotency_key=f"stripe-refund:{payload['id']}",
)
decision = action.wait(timeout=1800)
if decision == "approve":
# Only the approved refund executes.
stripe_refund(payload)
return {"decision": "approved"}
return {"decision": "declined", "reason": "rejected or expired"}Why this beats "email the manager"
- Decisions, not threads — a structured request with context instead of a lost email chain
- A decision SLA — 30 minutes, enforced; timeouts fail closed instead of silently stalling
- The reason is part of the record — declined refunds with reasons are how you find policy gaps
- One queue for refunds, credits, and exceptions — the same ask pattern covers every escalation
Try it
- Install the CLI or grab the Python SDK
- Create a Source →
ACTIONBOX_TOKEN - Route your refunds above the threshold through the ask pattern
Create a free Source · Python + webhook reference · Cron jobs that ask before acting