A support agent receives a request to refund $425 for a duplicate charge. The refund should not happen before a manager checks it. Sending an email can work for a small team, but the support system also needs to know which request was approved, when the answer arrived, and whether the payment was actually returned.
A Stripe refund approval workflow puts that decision between the support request and the Stripe call. ActionBox holds the review and records the response, subject to your plan's retention period. Your application owns the refund policy, checks that the response came from a person for the same request, and calls Stripe only after approval.
Decide which refunds need review
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: create the review request
Your support application checks the threshold before calling Stripe. This Python example uses the published actionbox-sdk package. It assumes your application provides load_refund_request and stripe_refund; those are your own payment functions, not ActionBox SDK methods. stripe_refund must pass the supplied idempotency key to Stripe and reconcile an uncertain payment result before retrying.
import os
from actionbox import Actionbox
AB = Actionbox(api_key=os.environ["ACTIONBOX_API_KEY"])
def maybe_refund(ticket: dict, amount_cents: int) -> dict:
THRESHOLD_CENTS = 10_000 # $100
request_id = ticket["refund_request_id"] # unique for this refund, not just this ticket
stripe_key = f"stripe-refund:{request_id}"
if amount_cents < THRESHOLD_CENTS:
return stripe_refund(ticket, amount_cents, idempotency_key=stripe_key)
action = AB.actions.create(
title=f"Refund ${amount_cents / 100:.2f} for ticket #{ticket['id']}?",
description=(
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=[{
"type": "key_value",
"title": "Refund request",
"items": {
"amount": f"${amount_cents / 100:.2f}",
"customer_tier": ticket["tier"],
"request_type": ticket["reason"],
"ticket": ticket["id"],
"refund_request_id": request_id,
},
}],
expires_at=ticket["approval_expires_at"],
on_expire={"type": "return_expired"},
idempotency_key=f"refund-review:{request_id}",
)
binding = (action.action_version, action.fingerprint)
decision = action.wait(timeout=1800)
if decision is None and action.status == "open":
AB.cancel(action.id, "Refund review timed out")
approved = (
action.status == "resolved"
and action.resolved_by == "user"
and binding[1] is not None
and (action.action_version, action.fingerprint) == binding
and decision == "approve"
)
if approved:
current = load_refund_request(request_id)
if (
current["id"] != ticket["id"]
or current["payment_intent"] != ticket["payment_intent"]
or current["amount_cents"] != amount_cents
):
return {"status": "changed_after_review", "action_id": action.id}
return stripe_refund(current, amount_cents, idempotency_key=stripe_key)
if (
action.status == "resolved"
and action.resolved_by == "user"
and binding[1] is not None
and (action.action_version, action.fingerprint) == binding
and decision == "reject"
):
return {"status": "declined"}
return {"status": "not_approved", "action_id": action.id}The reviewer sees the amount, reason, and request ID together. The example leaves out the customer's email address because it is not needed to decide this refund. A wait timeout is a local deadline; cancelling an open Action keeps it from looking actionable after this worker stops. If the cancellation or payment call fails, the code does not treat that failure as approval.
Save approval_expires_at with the original refund request instead of recalculating it on a retry. ActionBox rejects the same idempotency key with a changed request body. Its key protects create retries within the documented 24-hour window; for longer recovery, save the Action ID and re-read that Action. The Stripe idempotency key protects the separate money movement. Store the request ID and payment result in your own system; Stripe's stored idempotency responses are not a permanent record.
Step 2: the decision flows back to the ticket
After the reviewer responds, update the support ticket from your application's result. An approved Action is permission to attempt a refund; it is not proof that Stripe processed it. Tell the customer that a refund was issued only after your application confirms the Stripe result. A rejection, changed request, expiry, or local timeout leaves the refund unissued and needs a separate support follow-up.
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.
Over time, the record can help you see which request types are escalated and approved, and whether the $100 threshold needs adjustment. That's how you tune policy with actual decisions.
Application event example: route a refund request before calling Stripe
Your support application can call maybe_refund(ticket, ticket["amount_cents"]) when it receives a refund request. Pass a record with refund_request_id, id, amount_cents, payment_intent, reason, tier, and a future approval_expires_at timestamp. The refund request ID and deadline must stay the same across retries; a later refund on the same ticket needs a new request ID. This is an application event, not a Stripe refund webhook: Stripe's refund.created event arrives after the refund has been created.
For a long-running workflow, persist the Action ID alongside the refund request so a restarted worker can re-read the existing decision. Before retrying a payment after a network timeout, check its status with Stripe or your payment record. Do not assume that an unanswered HTTP request means the refund failed.
Why this beats "email the manager"
- Decisions, not threads — a structured request with context instead of a lost email chain
- A review deadline — set a future expiry when creating the request; no answer is not approval
- The request reason is part of the record — reviewers see why the refund was requested
- One queue for refunds, credits, and exceptions — the same ask pattern covers every escalation
Sources & Research
- Stripe API: Create a refund — The refund call and its payment reference.
- Stripe API: Event types —
refund.createdfires after a refund is created. - Stripe API: Idempotent requests — Safe retries and the lifetime of stored idempotency keys.
Try it
- Install the CLI or grab the Python SDK
- Create a Source and store its key as
ACTIONBOX_API_KEY - Route your refunds above the threshold through the ask pattern
Create a free Source · Python + webhook reference · Cron jobs that ask before acting
