Skip to content

Stripe refund approval workflow: route high-value refunds

Build a Stripe refund approval workflow that shows a reviewer the amount and reason, checks the human decision, and avoids duplicate refunds on retries.

Contactless payment being made at a card terminal

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.

Architecture Flow
Low risk High risk Approve Reject / timeout Refund request Policy threshold Process automatically Actionbox approval Issue Stripe refund Keep ticket pending Record outcome

Decide which refunds need review

Policy lives in code, not in the manager's head:

RequestUnder $100$100–$500Over $500Policy deviation
RefundAgent auto-approvesAsk managerAsk managerAsk manager
Credit / compAgentAgentAskAsk

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.

python
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

Try it

  1. Install the CLI or grab the Python SDK
  2. Create a Source and store its key as ACTIONBOX_API_KEY
  3. Route your refunds above the threshold through the ask pattern

Create a free Source · Python + webhook reference · Cron jobs that ask before acting

About the author

Suson Sapkota

Suson founded ActionBox and works in software and data engineering. He writes about approval workflows, background jobs, and how to verify what happened after a human decision.

Turn the next risky operation into a reviewable decision.

Create a free Source, run the example from this guide, and keep the decision and execution outcome connected.