Payments PendingStripe Refund Workflows and Partial Refund Edge Cases

Stripe Refund Workflows and Partial Refund Edge Cases

Stripe's refund model breaks down in partial refunds, API changes, and payment method rules.

· 11 min read

A Stripe refund looks simple from the API reference: send a request, get money back to a customer. The edge cases sit in the cumulative math across partial refunds, a breaking API change that quietly deleted an object type from two workflows, and payment-method rules that block a refund path without ever returning a useful error. This piece maps the happy path first, then works through each place where the model breaks.

How Stripe refunds work on the happy path

A refund reverses a successful payment by sending money back to the customer's original payment method. The Refund object that represents this points back at the original charge or PaymentIntent, and it moves funds in the opposite direction from the original transaction. You create one through the Refunds API or the Dashboard, and it enters pending status right away: Stripe has accepted the request, but the money hasn't landed yet.

Amounts are specified in the smallest currency unit, so a refund in US dollars is expressed in cents. If you leave the amount field off entirely, Stripe refunds the full charge by default. The processing fees from the original transaction stay with Stripe, and the refund itself draws from your available Stripe balance. If that available balance can't cover the refund, card transactions get held as pending until the balance catches up, but refunds against other payment method types fail outright instead of waiting in a queue.

That's the whole model: create a refund, wait for it to move from pending to complete, and expect the money to return to wherever it came from. Everything past this point in the article is about the ways that model stops holding.

How partial refunds accumulate

A single charge can absorb more than one partial refund, but the running total across all of them can never exceed the original charge amount. Stripe enforces that ceiling with a hard rejection at the API level, not a warning or a soft cap.

The trap occurs in staffing, not in code. A support team working a single customer complaint across multiple agents, or a high-volume operation issuing refunds in batches, often has no clean record of how much has already gone back on a given charge. The second partial refund gets built against an assumption about the remaining balance that turns out to be wrong, and the rejection arrives at the moment the refund is submitted, before anyone has planned for it. By then, a customer may already have been told a dollar amount is coming.

The fix is structural rather than procedural: pull the running refunded amount from the API before issuing each partial refund, instead of trusting the Dashboard view or an agent's memory of what happened last week. It's also worth knowing that Dashboard bulk refund tools only support full refunds. Partial refunds have to be issued one at a time, which matters directly to any team trying to automate a refund pipeline.

What the 2025-03-31.basil breaking change removed

The most consequential change to the refund surface in recent memory is a removal, already live for any integration running on API version 2025-03-31.basil or later.

Before this version, partially capturing a payment or canceling it, whether manually or through an automated expiration, created a Refund object representing the reversed amount. That object showed up when you listed refunds associated with the PaymentIntent, carried fields on the Charge object, triggered webhook events, and appeared in the Dashboard. As of basil, none of that happens. No Refund object gets created for a partial capture or a cancellation. It is absent from the refunds list, from the Charge object, from webhook payloads, and from the Dashboard.

The balance transaction side changed too. A partial capture used to generate two balance transactions, one for the captured amount and a second representing the reversed portion along with its fees. Under basil, a partial capture produces exactly one balance transaction, and it reflects only the amount actually captured. On top of that, the amount_refunded field on the Charge object no longer updates as a result of cancellation flows, and refunded no longer flips to true when a payment is canceled.

None of this is a deprecation notice to plan around later. Any integration already running on basil or above that still expects a Refund object out of a partial capture or a cancellation is already broken, likely silently. Reconciliation logic that joins on Refund objects, webhook handlers listening for refund-related events tied to captures, and Dashboard reports built around amount_refunded all need to be audited line by line and rewritten to read from the charge fields and events that basil actually populates, not the ones it used to.

Diagram: What the 2025-03-31.basil Change Removed. Visualizes: Show a before/after comparison of what the 2025-03-31.basil API version removed from partial captures and cancellations.

Why reversal timing changes what the customer actually sees

Timing changes what a customer sees on their statement, not just how fast they see it. A refund issued shortly after the original charge can be processed as a reversal rather than a refund, and in that case, the original charge disappears from the statement. No separate credit line appears, because there's nothing left to credit against.

To a customer checking their bank app, that looks indistinguishable from nothing having happened. They were told a refund was issued, they look for a credit, and they find neither a charge nor a credit, because the charge itself is gone. That's a predictable source of support tickets, not a sign of a failed refund.

The fix lives in language, not code: refund confirmation messages and support scripts need to account for both outcomes, a visible credit or a vanished charge, rather than promising a specific credit line that a fast reversal will never produce.

Interac Debit and the card-present rules that break remote workflows

Most card-present refunds processed through Stripe Terminal don't require the card to be physically present again. A refund against a Visa, Mastercard, Amex, or Discover transaction can be issued later from the Dashboard, the API, or a point-of-sale order screen, well after the customer has left.

Interac Debit is the exception, and it's not a minor one given how dominant Interac is as a debit network in Canada. A refund against an Interac Debit transaction has to happen in person, with the cardholder re-presenting their card at a reader. There is no Dashboard path and no API path for it at all; the SDK and the physical reader are the only route. Other debit networks that require in-person completion follow the same rule, and the boundary is set by network policy, not by anything configurable in Stripe's settings.

The way this fails in practice is quiet. A support agent or an automated back-office system that attempts a Dashboard or API refund against an Interac transaction doesn't get a clear error explaining the in-person requirement. Staff need to know the rule before a customer ever walks up asking for a refund, not after a refund attempt has already failed with no useful explanation. A newer API version compounds this for these deployments by also removing the manual capture method for this debit card type, adding a second constraint on top of the in-person-only refund rule. So any Terminal integration operating in Canada needs to branch on payment method before choosing a refund path, routing Interac PaymentIntents to an in-person reader flow and never to a Dashboard or API call.

ACH and bank debit partial refund asymmetry

Bank debit refunds carry a different kind of hidden constraint: an asymmetry between API surfaces. For ACH Direct Debit payments, whether a partial refund is available through the Dashboard depends on which API the original payment used. On the Charges API, partial refunds can be processed through the API but aren't available through the Dashboard. On the PaymentIntents API, partial refunds work through both.

So if your support staff expect Dashboard parity across payment methods, they can instead find a partial refund option grayed out depending on how the original charge was created, a limitation that traces back to API choice.

A second risk sits alongside this one, and it spans the broader family of bank debit methods, SEPA Direct Debit, Bacs Direct Debit, ACH Direct Debit, ACSS, AU BECS, and NZ bank account debits. If a business proactively issues a refund on one of these at the same moment the customer's bank independently initiates a dispute on the same transaction, the customer can end up with two credits for a single charge. Bank transfer refunds also carry a distinct failure mode: if the customer has closed the bank account or payment method the refund is targeting, the bank sends the refund back to Stripe, which marks it failed and returns the amount to the platform's balance, a round trip that can take up to thirty days. Any integration handling bank debit methods needs to listen for refund.failed events and have a defined escalation path ready, because assuming a refund against one of these payment methods completed just because it was created is not a safe assumption.

Connect platform refunds and the funds-recovery trap

Connect platforms add a layer of refund complexity that sits above everything described so far. On destination charges and on separate charges and transfers, Stripe debits the platform's own balance when a refund is issued. Getting that money back from the connected account requires explicitly reversing the transfer that moved it there in the first place; nothing about issuing the refund does this automatically.

Setting reverse_transfer=true on a partial refund against a destination charge reverses a proportional share of the transfer, matching the proportion of the charge that was refunded. If the refund covers the entire charge, the entire transfer reverses. Leaving reverse_transfer unset means the transfer never reverses, leaving the platform to absorb the refunded amount with no automatic recovery from the connected account.

The sharper asymmetry occurs when the connected account doesn't have enough funds to support the reversal. If you pass reverse_transfer=true against an account with insufficient balance, Stripe returns an error immediately. That's actually the better failure mode of the two possible designs: the platform learns at the moment of the refund attempt that recovery isn't possible, instead of discovering a stranded pending refund days later with no clear reason attached.

Application fees follow their own rule, and it's easy to miss. Refunding a payment does not automatically return the application fee to the connected account. refund_application_fee=true has to be set explicitly, and even then the fee refund is proportional to how much of the payment was refunded. A partial refund of a charge returns only the proportional share of the application fee if refund_application_fee=true is set. If a connected account's balance goes negative as a result of a dispute or a refund, Stripe holds a reserve against its available balance, and for US and Canada accounts with payments.debit_negative_balances=true, Stripe automatically attempts to debit the connected account's external bank account to cover it. None of this happens by accident; a Connect platform has to decide on the transfer-reversal behavior, check connected account balances before issuing refunds, and set refund_application_fee on purpose.

Handling failed refunds and the webhook events that signal them

A created refund is not a guaranteed refund. Several things can cause one to fail after it's already been issued: a bank account that's been closed, a card reported lost or stolen, a charge that gets disputed while its refund is still pending, insufficient funds once the pending window has expired, or a flat decline from one of Stripe's financial partners.

When any of these happen, the bank sends the money back to Stripe, which returns it to the platform's Stripe balance, a process that can take up to thirty days from the post date. The Refund object itself reflects this: its status moves to failed, and it populates failure_reason along with failure_balance_transaction. For some payment methods, a network_decline_code is also available inside destination_details, giving a more specific reason than the general failure category.

One failure reason deserves particular attention: charge_for_pending_refund_disputed, which fires when a customer disputes a charge while a refund against it is still pending. In that situation, leaving the refund pending risks the customer receiving two credits, one from the dispute and one from the refund completing later. Stripe's guidance is to accept or challenge the dispute directly rather than let the pending refund run its course untouched.

The operational checklist is short but non-negotiable: listen for refund.failed, know what each failure_reason value means before it occurs in production, and have a defined response ready: notifying staff, notifying the customer, or deciding whether to re-issue the refund through a different path. Assuming a created refund always completes is the single most common cause of funds quietly getting stranded.

Testing refund edge cases without hitting live credentials

Every edge case above shares one property: a mock that returns a 200 on POST /v1/refunds says nothing about whether any of them are handled correctly. A 200 proves the request was shaped right. It proves nothing about whether an integration respects the cumulative refund limit, correctly follows a refund's status transitions, responds properly to a refund.failed webhook, or behaves correctly under the basil API version's removal of Refund objects from partial captures.

Stripe's own stripe-mock tool is explicitly stateless by design. It can't enforce the cumulative refund limit across multiple calls, can't transition a refund from pending to failed over time, and generates its responses from fixed fixtures. Stripe has no plans to make it stateful.

The failure pattern that results from this is familiar to anyone who's shipped against a payments API: a CI pipeline mocks POST /v1/payment_intents, asserts a 200, and the merge button lights up green. The first real checkout in staging then fails, because the handler never actually stored the PaymentIntent ID anywhere, a bug the mock had no way to catch since it only checked the shape of a request, never the lifecycle around it.

A test suite built to catch the edge cases covered in this piece needs a few specific capabilities. It needs stateful enforcement of the cumulative refund limit, so that a second partial refund pushing past the original charge amount returns amount_too_large. It needs the ability to reproduce refund.failed webhooks carrying specific failure_reason values, so handler branching logic gets exercised. It needs correct simulated behavior under the 2025-03-31.basil API version: no Refund object appearing on a partial capture, no charge.refunded event firing, and the correct single balance transaction count. And it needs fault injection, 429 responses during refund submission, 503s during webhook delivery, and latency spikes, to surface the timeouts and retry bugs that occur when something besides a clean 200 comes back. None of that is optional if the goal is confidence that an integration handles what Stripe's refund API actually does, not just what it does on its best day.

Sources

  1. Refund and cancel payments
  2. Partially capturing or canceling payments no longer creates a Refund
  3. Handle refunds and disputes
  4. Refunds
  5. Partial Refunds: A Guide for Businesses
  6. Changes to balance transaction behavior for partial capture and payment cancellation flows : Stripe: Help & Support
  7. Where is my customer's refund? : Stripe: Help & Support
  8. How disputes work

More in Stripe Integration