DEV Community

Veristria
Veristria

Posted on Originally published at feeguard.dev

Stripe Connect refunds: the two flags that silently cost platforms

Problem

When a Stripe Connect platform refunds a charge, the platform often assumes that the original transfer and the application fee are automatically undone. In practice, two boolean flags—refund_application_fee and reverse_transfer—control whether the fee and the transfer are reversed. Their defaults are non‑obvious (false for both), so a refund can leave the platform with a net loss that never appears in Stripe’s error logs. The loss shows up only as a discrepancy between the amount refunded to the customer and the amount that actually returns to the platform’s balance.

Mechanism

A Connect refund touches several objects:

Object Role in a refund Default behavior
Charge Original payment N/A
Transfer Moves funds from platform to connected account Not reversed unless reverse_transfer: true
ApplicationFee Platform’s fee on the charge Not refunded unless refund_application_fee: true
Refund Credits the customer Always creates a credit on the original charge

The two flags are independent:

refund_application_fee reverse_transfer Platform net outcome
false false Platform keeps the fee and the transfer stays settled → net loss = fee + any FX spread
false true Transfer is reversed but fee stays → net loss = fee
true false Fee is refunded but transfer stays → net loss = transferred amount (minus fee)
true true Both fee and transfer are undone → net zero (aside from timing/FX)

Because the defaults are false, a typical “refund” only credits the customer; the platform’s money remains locked in the connected account and the fee stays on the platform’s ledger. This silent leak is why many platforms see “missing” revenue after a batch of refunds.

Detection

FeeGuard can audit the last 90 days (or any custom window) without any credentials—just a read‑only API token or a CSV export. The audit consists of three steps:

  1. Export refunds – Pull all Refund objects for the target date range.
  2. Join to transfers and fees – For each refund, locate the associated Transfer (transfer field on the original Charge) and ApplicationFee (application_fee field).
  3. Apply the truth table – Compute the expected platform balance change based on the two flags. Compare it to the actual balance delta reported by Stripe.
# Python example (requires only read‑only Stripe secret)
import stripe
from datetime import datetime, timedelta

stripe.api_key = "sk_test_readonly_..."

def fetch_refunds(days=90):
    start = int((datetime.utcnow() - timedelta(days=days)).timestamp())
    refunds = stripe.Refund.list(created={'gte': start}, limit=100)
    return refunds.auto_paging_iter()

def audit():
    total_gap = 0
    for r in fetch_refunds():
        charge = stripe.Charge.retrieve(r.charge)
        transfer = stripe.Transfer.retrieve(charge.transfer) if charge.transfer else None
        fee = stripe.ApplicationFee.retrieve(charge.application_fee) if charge.application_fee else None

        # Flags (defaults are false if not present)
        rev_fee = r.refund_application_fee or False
        rev_transfer = r.reverse_transfer or False

        # Expected platform delta
        fee_amount = fee.amount if fee else 0
        transfer_amount = transfer.amount if transfer else 0

        expected = 0
        if not rev_fee:
            expected -= fee_amount
        if not rev_transfer:
            expected -= transfer_amount

        # Actual delta is the amount Stripe reports as returned to the platform
        actual = r.amount  # amount refunded to customer (negative for platform)

        gap = expected - actual
        total_gap += gap

    print(f"Total unrecovered loss: {total_gap/100:.2f} USD")
Enter fullscreen mode Exit fullscreen mode

Running the script (or using FeeGuard’s UI) yields the exact dollar amount that never made it back to the platform.

Fix

There are two complementary approaches:

  1. Preventive configuration – When creating a refund via the API, explicitly set the flags to true if you want the fee and transfer reversed:
curl https://api.stripe.com/v1/refunds \
  -u sk_test_readonly_: \
  -d charge=ch_1ABC... \
  -d amount=5000 \
  -d refund_application_fee=true \
  -d reverse_transfer=true
Enter fullscreen mode Exit fullscreen mode

Tip: Wrap this call in a helper function in your backend so every refund uses the same parameters.

  1. Post‑refund recovery – If a batch of refunds has already been processed without the flags, use FeeGuard’s recovery flow:
  • Run a read‑only audit to quantify the loss.
  • For each affected refund, issue a separate “fee reversal” and “transfer reversal” using the ApplicationFeeRefund and TransferReversal endpoints. Both are independent operations and can be performed after the original refund.
# Reverse fee
stripe.ApplicationFeeRefund.create(
    application_fee=fee.id,
    amount=fee.amount,
)

# Reverse transfer
stripe.TransferReversal.create(
    transfer=transfer.id,
    amount=transfer.amount,
)
Enter fullscreen mode Exit fullscreen mode

Because FeeGuard’s pricing is “pay‑only‑on‑recovery,” you only incur cost when the above steps actually return money to the platform.

Caveats

Issue Detail Mitigation
Timing Transfer reversals can take up to a few business days to settle. Track reversal status via TransferReversal objects.
FX exposure If the original transfer involved currency conversion, the reversal may settle at a different rate, leaving a small residual. Include FX variance in the audit’s “gap” calculation.
Partial refunds When only part of a charge is refunded, you must decide whether to reverse the proportional fee/transfer or keep the remainder. Use the same proportion (refund.amount / charge.amount) when creating ApplicationFeeRefund and TransferReversal.
Disconnected accounts Some connected accounts may have custom payout schedules that block immediate reversals. Verify the account’s payout_schedule before attempting a reversal; fallback to manual payout if needed.
Read‑only audit limits The audit does not require any write credentials, but it cannot detect unreversed transfers that were never created (e.g., a missing transfer field). Ensure your platform always creates a Transfer for every Connect charge; otherwise, the audit will flag the missing link.

Summary

Stripe Connect refunds can silently leak platform revenue because refund_application_fee and reverse_transfer default to false. By systematically joining refunds to their associated transfers and fees, applying the truth table, and either configuring refunds correctly up front or reversing the missed amounts afterward, a platform can recover the hidden loss. FeeGuard’s read‑only audit makes the detection step trivial, and its pay‑only‑on‑recovery model ensures you only pay when money is actually returned.

For a deeper dive, see the full blog post on the FeeGuard site.

Top comments (0)