Skip to content
All posts

Building a refund policy that protects take rate without hurting sellers

A refund policy is pre-made allocation decisions encoded as explicit API flags: design the decision table once, enforce it in code, version every refund.

FeeGuard13 min read

Building a refund policy that protects take rate without hurting sellers

Who funds what when a marketplace refund hits, decided before the ticket exists and enforced mechanically: that is how founders and ops leads build a refund policy that protects take rate without quietly taxing sellers.

Mechanics do not negotiate

Stripe will execute any allocation you ask for. On a destination charge, the default refund debits your platform balance while the seller keeps the transferred funds; pass reverse_transfer=true and the funds come back proportionally (destination charges). The application fee stays yours unless you set refund_application_fee=true or refund it separately afterward (refunds). On direct charges the roles flip: the refund debits the connected account, and the fee is returned only if you act (direct charges). Every combination is available. None of them is enforced on your behalf.

That means a platform without a written refund policy still has one. It is whatever the defaults plus whoever clicked the button produced. The default on a destination charge, for instance, allocates the entire refund to the platform: on the canonical sale below, one full refund with no flags moves your position from +$6.80 to −$93.20 while the seller sits untouched at +$90.00. If nobody decided that, the defaults decided it.

The charge model you picked changes where the levers sit, so write the policy per charge type. On direct charges the refund debits the seller automatically, and the danger runs the other way: without refund_application_fee=true, the connected account loses your fee amount out of its own balance and lands negative by exactly the processing cost while your fee sits untouched (direct charges). On separate charges and transfers, refunding the charge has no effect on any transfer, so recovery is whatever manual or scheduled reversal process you build (separate charges and transfers). Same buyer outcome, three different sets of moving parts.

A policy is simply the set of these decisions made before any support ticket exists: who funds the principal, what happens to your fee, who eats the loss when a dispute goes badly. Once written down, each decision maps to a specific flag or API call, and enforcement becomes a code review problem instead of a judgment call under pressure.

The decision inventory

The table below lists every lever in a marketplace refund, the options for each, and where the decision physically lives. Work through it top to bottom and you have a policy.

Decision pointOptionsHow it is encoded
Does the buyer get their money backYes, always; this is not a leveramount on the refund
Who funds the refunded principalSeller funds it, platform absorbs it, or proportional split on partialsreverse_transfer: true pulls funds back, false leaves them
Stripe processing fee on the saleNever returned by Stripe; someone's margin absorbs itNo flag exists; allocated implicitly by the pattern above
Your application feeReturned in full or proportionally, kept entirely, or partially returned by exact amountrefund_application_fee, or a separate call to /v1/application_fees/{id}/refunds with amount (fee refunds)
Dispute lossesRecover from the seller when the dispute opens, or only after it is lostOperational rule driving transfer reversals; events are charge.dispute.created and charge.dispute.closed (disputes)
Deadline for demanding seller repaymentNothing native; pure contract territorySeller agreement terms plus netting against future payouts

Three rows deserve emphasis. First, the processing fee row: Stripe's own documentation states that processing fees from the original transaction are not returned (refunds). You cannot dodge the $3.20; you can only decide whose P&L shows it. Second, the custom fee slice: because the separate fee-refund endpoint accepts an explicit amount, "seller gets half the fee back" is encodable, not aspirational. Third, dispute recovery: recovering at created protects cash flow but risks clawing back a seller who then wins the dispute; waiting for lost is safer but slower, and cross-border destination charges made with on_behalf_of should generally wait until the dispute is lost before you recover (disputes). Pick per your risk appetite and write the choice down.

Partial refunds slot into the same inventory without new decisions: with reverse_transfer=true the API reverses the transfer proportionally to the amount refunded, and with refund_application_fee=true it returns the fee proportionally the same way (refunds). The one genuinely manual corner is separate charges and transfers, where nothing reverses unless you create the reversal yourself with an optional amount.

A fault taxonomy you can enforce

Allocation decisions only become consistent when something classifies each refund. The simplest classifier that survives contact with real support volume has three classes:

Fault classRecommended encodingRationale in one line
Seller fault: wrong, damaged, not as describedreverse_transfer=true, refund_application_fee=falseThe seller caused the loss, so the seller funds the buyer and you keep the fee you earned sourcing the sale
Platform fault: bad order data, listing errors, your bugreverse_transfer=false, refund_application_fee=falseThe seller delivered; the platform pays for its own mistake out of its own margin, fee included
Customer goodwill: remorse, no fault anywherePartial refund with reverse_transfer=true plus a proportional fee return via the separate endpointSplit the pain so neither side fully funds a nobody's-fault outcome

These are commercial choices Stripe cannot make for you. Reasonable platforms disagree on all three rows; the failure mode is not picking the "wrong" row, it is having no row until the third escalation of the week. Someone still has to assign the class at refund time, so keep classification boring: support picks exactly one class from a required dropdown, attaches a one-line evidence note, and the wrapper writes both into the refund metadata next to policy_version. A refund whose class cannot be determined defaults to the encoding that moves no seller funds until a human confirms, never to silence. Note also that reason on the refund (duplicate, fraudulent, requested_by_customer) is a card-network classification, not your fault taxonomy; keep them separate fields.

Worked policy run-through

Define the sample policy concretely, then run two tickets through it.

Canonical inputs (US platform, standard US card pricing of 2.9% + $0.30 per Stripe's published pricing):

  • Destination charge: $100.00 = 10,000¢, application_fee_amount = 1,000¢
  • Processing fee: 0.029 × 10,000¢ + 30¢ = 320¢ = $3.20
  • Transfer to seller: 10,000 − 1,000 = 9,000¢ = $90.00
  • Positions right after the sale: platform +$6.80, seller +$90.00, Stripe +$3.20, buyer −$100.00

Sample policy

Reason classreverse_transferrefund_application_feeSeparate fee refund
Seller fault, full refundtruefalsenone
Platform fault, full refundfalsefalsenone
Goodwill, 50% refundtruefalsehalf the fee, exact amount

Scenario A: seller ships the wrong item, full refund.

  • Refund to buyer: −10,000¢ = −$100.00
  • Expected reversal: round((10,000 / 10,000) × 9,000) = 9,000¢ = +$90.00
  • Fee: kept, no movement

Resulting positions: platform 6.80 − 100.00 + 90.00 = −$3.20; seller 90.00 − 90.00 = $0.00; buyer $0.00; Stripe +$3.20. Conservation check: −10.00 (platform change) − 90.00 (seller change) + 100.00 (buyer change) = 0. The seller funded the entire principal; your take rate survived; the only thing anyone lost was the non-refundable $3.20.

Scenario B: customer remorse, goodwill refund of 50%.

  • Refund to buyer: −5,000¢ = −$50.00
  • Expected reversal: round((5,000 / 10,000) × 9,000) = 4,500¢ = +$45.00 (the API reverses proportionally)
  • Fee gesture check: half of the 1,000¢ fee = 500¢, inside the fee's unrefunded remainder
  • Separate fee refund of 500¢ = −$5.00 to the seller

Resulting positions: platform 6.80 − 50.00 + 45.00 − 5.00 = −$3.20; seller 90.00 − 45.00 + 5.00 = +$50.00; buyer net cash −$50.00; Stripe +$3.20. Conservation check: −10.00 − 40.00 + 50.00 = 0.

Side by side:

LineScenario A: seller faultScenario B: goodwill
Refund created−$100.00−$50.00
Transfer reversal+$90.00+$45.00
Application feekept (+$0.00 movement)−$5.00 returned via fee-refund API
Platform position−$3.20−$3.20
Platform change vs. pre-refund−$10.00−$10.00
Seller position$0.00+$50.00
Buyer net cash$0.00−$50.00

Read the platform-change line twice: identical in both scenarios. Under this policy the platform's cost of any refund is capped at roughly the processing fee plus whatever fee gesture goodwill chooses, while the seller's cost scales with culpability. That is what "protecting take rate" actually looks like in cents. For completeness, the platform-fault row on the same sale runs with no flags at all: the platform lands at 6.80 − 100.00 = −$93.20 while the seller keeps $90.00 — expensive enough to reserve for genuine platform errors, and honest precisely because it is. Model variants of the goodwill split yourself with the application fee refund calculator; a fuller treatment of the design space sits in the platform refund policy design guide.

Encoding and enforcement

A policy that lives in a wiki page will drift. Encode it once:

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

type ReasonClass = "seller_fault" | "platform_fault" | "goodwill";
const POLICY_VERSION = "2026-08-v3";

interface Plan {
  reverseTransfer: boolean;
  refundApplicationFee: boolean;
  feeRefundShare?: number;
}

const PLANS: Record<ReasonClass, Plan> = {
  seller_fault: { reverseTransfer: true, refundApplicationFee: false },
  platform_fault: { reverseTransfer: false, refundApplicationFee: false },
  goodwill: { reverseTransfer: true, refundApplicationFee: false, feeRefundShare: 0.5 },
};

export async function issuePolicyRefund(
  charge: Stripe.Charge,
  reasonClass: ReasonClass,
  refundAmount: number
): Promise<Stripe.Refund> {
  const plan = PLANS[reasonClass];

  const refund = await stripe.refunds.create(
    {
      charge: charge.id,
      amount: refundAmount,
      reverse_transfer: plan.reverseTransfer,
      refund_application_fee: plan.refundApplicationFee,
      metadata: { order_id: charge.metadata.order_id ?? "", policy_version: POLICY_VERSION },
    },
    { idempotencyKey: `refund:${charge.id}:${reasonClass}:${POLICY_VERSION}:${refundAmount}` }
  );

  if (plan.feeRefundShare && charge.application_fee) {
    const feeAmount = Math.round(1000 * plan.feeRefundShare);
    await stripe.applicationFees.createRefund(charge.application_fee as string, {
      amount: feeAmount,
      metadata: { order_id: charge.metadata.order_id ?? "", policy_version: POLICY_VERSION },
    });
  }
  return refund;
}

Every allocation decision from the tables above appears here as an explicit flag: reverse_transfer, refund_application_fee, and the separately computed fee-refund amount. The idempotency key makes retries safe; keys are honored for 24 hours (idempotency), which pairs naturally with queue-based retries for the reversal leg described in idempotency for reversals.

Then pin it with a unit test that asserts the flags, so nobody can silently soften the policy:

test("seller_fault encodes full reversal and keeps the fee", async () => {
  const create = jest.spyOn(stripe.refunds, "create")
    .mockResolvedValue({ id: "re_test" } as Stripe.Response<Stripe.Refund>);
  await issuePolicyRefund(fixtureCharge, "seller_fault", 10000);
  expect(create.mock.calls[0][0]).toMatchObject({
    reverse_transfer: true,
    refund_application_fee: false,
  });
});

One enforcement gap remains: refunds initiated directly in the Dashboard bypass your wrapper entirely, carrying no policy_version and possibly wrong flags. Detect them after the fact with a scheduled sweep:

const refunds = await stripe.refunds.list({
  created: { gte: lastSweepWatermark },
  limit: 100,
});
for await (const refund of refunds) {
  if (refund.metadata?.policy_version == null) {
    await reviewQueue.add({ refundId: refund.id, chargeId: refund.charge });
  }
}

Any refund missing your metadata prefix came from outside the wrapper. Each hit goes back through the expected-value math (expected reversal, expected fee return), gets a class assigned, and is reconciled by hand before the next watermark advances. The sweep is ten lines and turns an invisible leak into a visible queue with owners attached.

Telling sellers

None of this survives contact with a seller agreement that does not mention it. Clawbacks read as betrayal when they are a surprise and as process when they were disclosed, so put the allocation table in the seller agreement before launch: which fault classes trigger reversal, whether fees come back, and what happens when their balance cannot cover a reversal. When you do recover, say what happened and why, with amounts and object IDs; the clawback communication template covers the message structure. Timing matters as much as wording: a reversal executed after the seller's payout has landed pushes their Stripe balance negative rather than pulling cash back directly, so pair every message with an honest statement of when you expect the account to recover. Dispute losses deserve their own advance disclosure, since recovering a disputed amount weeks after the sale is the single most surprising event in a seller's month, and the timing choice (recover at open versus at loss) belongs in the same clause. Where recovery needs automation at volume, designing auto-clawback rules walks the threshold and netting decisions that keep mechanical recovery fair.

Versioning and review

Write policy_version into the metadata of every refund and fee refund, exactly as the wrapper does above. Six months from now, when an audit asks why seller X was charged back $90.00 in March, the ledger answers: this refund ran under version 2026-08-v3, whose seller-fault row says full reversal, fee retained. Historical audits can then apply the expectations of the era instead of judging the past by today's table. Bump the version whenever any cell changes, and keep old versions' definitions in version control next to the code that encoded them.

Review the table itself on a cadence, quarterly at minimum, and off-cycle whenever one of these fires:

  • A new product line changes average order value or fault patterns, which changes how much a fee gesture costs.
  • A new geography adds currencies and cross-border disputes, where recovery timing rules differ.
  • Dispute rates shift in either direction, which re-prices the recover-at-open versus recover-at-lost trade-off.

The review is short because the inventory is finite: six decision points, three fault classes, one table. Platforms drift into leaks when those cells get decided ad hoc, one refund at a time, by whoever answered the ticket.

Frequently asked questions

Can Stripe enforce my policy automatically?

No. Stripe executes exactly the calls you make, with the flags you pass; there is no native rule engine that inspects a refund reason and decides who funds it. Enforcement lives in your wrapper function, your tests, and your Dashboard-bypass sweep. Treat anything outside those three paths as unmanaged spend waiting to happen.

What happens when a seller cannot cover the reversal?

On destination charges, if you request the refund together with a transfer reversal and the connected account lacks funds, the API returns an error rather than creating a pending refund. On separate charges and transfers, a reversal succeeds only if the connected account's available balance covers it. Both cases resolve the same way: net against future payouts, or wait for activity to fund the account, rather than abandoning the recovery.

Does refund_application_fee work on separate charges and transfers?

No. That pattern creates no application fee objects at all, because you collect your fee by transferring less than you collected. There is nothing to flag and nothing to refund; when a refund happens there, adjust the next transfer's amount instead, and encode that adjustment in the same policy table.

Who pays Stripe's processing fee on a refund?

Stripe keeps it; the documentation is plain that processing fees from the original transaction are not returned. Your policy decides whose margin absorbs it. In the worked scenarios above, both allocations left the platform holding the same $3.20; on direct charges without a fee refund, the connected account absorbs it by ending at exactly minus the fee. Decide it deliberately and write the answer down.

Check your own last 90 days

FeeGuard exists because this arithmetic runs silently on every refund your platform issues. The free audit reads your last 90 days of Connect activity through a restricted, read-only API key and reports every unreclaimed application fee, unreversed transfer, and uncovered dispute loss with the amounts attached. You get the answer first; monitoring is optional afterward.

Run the free 90-day audit

FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.