ActionBoxBlog

AutomationPlatformBackend

Stripe refund approval workflow: route high-value refunds

A copy-paste Stripe refund approval workflow that routes high-value refunds to a manager with context, a decision SLA, idempotency, and an audit trail.

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:

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

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:

python
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:

python
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

python
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

  1. Install the CLI or grab the Python SDK
  2. Create a Source → ACTIONBOX_TOKEN
  3. Route your refunds above the threshold through the ask pattern

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

Try this workflow in minutes

Create a free Source, then run the exact commands from this post against the live API — no approval infrastructure to build.

S
Suson Sapkota

Founder, Actionbox