Most fee logic in a payments integration is stateless. You take a transaction amount, apply a percentage and a fixed component, and you're done. PayPal's US dispute fee doesn't work that way. The fee charged on a dispute depends on your account's recent history, not on the dispute itself.
As of the fee schedule cited in this roundup of PayPal statistics on volume, fees, and fraud, disputes on PayPal-account and Guest Checkout payments cost $15 by default and $30 once your dispute rate reaches 1.5% or more across more than 100 sales in the previous three full months. Card chargebacks that don't go through a PayPal account or Guest Checkout are handled separately, with a flat $20 fee.
This article treats that rule as an engineering problem. The goal is a small service that computes your current tier, forecasts next month's tier, and tells you how much headroom you have before the fee doubles.
Why this deserves code instead of a spreadsheet
A tiered fee driven by a rolling window has three properties that make it easy to get wrong by hand:
- Lag. A bad month doesn't hurt you immediately. It hurts you for the next three months.
- A volume gate. Below 101 sales in the window, the rate doesn't matter at all. Cross that line and a rate you've been running for months suddenly counts.
- A hard threshold. At 1.5% exactly, you're in the higher tier. One dispute can flip it.
If you already ingest dispute events, through webhooks such as CUSTOMER.DISPUTE.CREATED or by polling the Disputes API, you have everything you need to compute this yourself.
Defining the window precisely
"Previous three full months" means calendar months that have fully closed. It doesn't mean the last 90 days. For a date in March, the window is December 1 through the end of February.
That has a consequence that's easy to miss. A spike in January sits in the window for February, March, and April:
| Billing month | Window | Includes January? |
|---|---|---|
| February | Nov–Jan | Yes |
| March | Dec–Feb | Yes |
| April | Jan–Mar | Yes |
| May | Feb–Apr | No |
One bad month can mean a full quarter of doubled dispute fees. Your monitor should surface that horizon explicitly.
The data you need
Per account, per calendar month:
-
sales_count: completed sales that count toward the denominator. -
dispute_count: disputes that count toward the numerator.
Two open questions you must answer against PayPal's current terms before trusting your numbers:
- Attribution. Is a dispute counted in the month it was opened, or in the month of the original sale? These give different results when disputes arrive weeks after purchase.
- Scope. Which dispute stages count (inquiries, claims, chargebacks), and which payment types count as "sales"?
Don't hardcode either answer. Make them configuration, because the definition can change and you'll want to recompute history when it does.
Implementation
Here's a minimal Python version. It uses integer arithmetic for the threshold comparison so floating-point rounding can't put you on the wrong side of 1.5%.
from dataclasses import dataclass
from datetime import date
from math import ceil
# Configurable: verify against the current PayPal fee page.
THRESHOLD_BPS = 150 # 1.5% expressed in basis points
MIN_SALES_EXCLUSIVE = 100 # "more than 100" => 101+
STANDARD_FEE = 15_00 # cents
HIGH_FEE = 30_00 # cents
@dataclass
class MonthStats:
sales: int
disputes: int
def window_months(billing_month: date) -> list[tuple[int, int]]:
"""Three full calendar months preceding billing_month."""
y, m = billing_month.year, billing_month.month
out = []
for _ in range(3):
m -= 1
if m == 0:
y, m = y - 1, 12
out.append((y, m))
return out
def tier_for(billing_month: date, stats: dict[tuple[int, int], MonthStats]):
months = window_months(billing_month)
sales = sum(stats.get(k, MonthStats(0, 0)).sales for k in months)
disputes = sum(stats.get(k, MonthStats(0, 0)).disputes for k in months)
gated = sales > MIN_SALES_EXCLUSIVE
# disputes / sales >= 1.5% <=> disputes * 10000 >= 150 * sales
over = gated and disputes * 10_000 >= THRESHOLD_BPS * sales
# Largest dispute count that stays below the threshold for this volume
max_safe = ceil(THRESHOLD_BPS * sales / 10_000) - 1 if gated else None
return {
"window": months,
"sales": sales,
"disputes": disputes,
"tier": "high" if over else "standard",
"fee_cents": HIGH_FEE if over else STANDARD_FEE,
"headroom": (max_safe - disputes) if max_safe is not None else None,
}
Note that ceil of a float can misbehave at exact boundaries. For production, compute max_safe with integer math: (THRESHOLD_BPS * sales + 9999) // 10000 - 1.
Worked example
It's March. Your window is December through February:
| Month | Sales | Disputes |
|---|---|---|
| December | 950 | 12 |
| January | 780 | 14 |
| February | 670 | 8 |
| Total | 2,400 | 34 |
The rate is 34 / 2,400 = 1.417%. You're in the standard tier.
Now the headroom: 1.5% of 2,400 is exactly 36, and 36 disputes would meet the threshold. So the maximum safe count is 35, and your headroom is one dispute. If a single late dispute gets attributed back into February, March flips to $30 per dispute.
That's the number worth alerting on. Rate alone (1.42%, comfortably under 1.5%) gives a false sense of safety.
Forecasting April
April's window drops December and adds March. December had a relatively low rate (1.26%) on high volume. If March comes in at 700 sales and 15 disputes, April's window is 2,150 sales and 37 disputes, which is 1.72%. You'd enter the high tier in April even though no single month changed dramatically.
Your monitor should compute the next window using month-to-date figures and a projection for the rest of the current month.
The volume-gate trap
Small merchants have a specific failure mode. Consider a window with 98 sales and 6 disputes, a 6.1% rate. That's standard tier, because sales don't exceed 100.
Grow to 101 sales with the same disputes and you're instantly at 5.9% and in the high tier. Growth itself triggered the fee increase.
If you're near the gate, track two alerts:
- Projected window sales crossing 100.
- Projected rate at that point exceeding 1.5%.
Decision table for alerts
| Window sales | Rate | Headroom | Action |
|---|---|---|---|
| ≤ 100 | any | n/a | Watch projected sales vs. gate |
| > 100 | < 1.0% | large | No action |
| > 100 | 1.0–1.5% | ≤ 3 disputes | Warn: one bad week flips tier |
| > 100 | ≥ 1.5% | negative | Alert: compute months until the spike rolls out |
For the last row, find the earliest billing month where removing the oldest months brings the rate under 1.5%, assuming projected volume. That tells finance how long elevated fees will last.
Common mistakes and how to catch them
Using a rolling 90 days. It produces a rate that's close but not identical, and it disagrees with the actual fee most at month boundaries. Unit-test window_months around January to catch year wraparound.
Float comparison at the threshold. 0.015 * 2400 is not guaranteed to equal 36.0 exactly. Compare integers, as above.
Off-by-one on "more than 100." Write a test that asserts 100 sales is ungated and 101 is gated.
Mixing card chargebacks into the count. Card payments outside PayPal accounts and Guest Checkout carry their own $20 chargeback fee. Whether they count toward this dispute rate is a definitional question. Keep them in a separate column so you can include or exclude them by config.
Double-counting escalations. A single case can move from inquiry to claim, or get reopened. Key your dispute table on the dispute ID and dedupe on it. Don't count event rows, because one dispute can emit several webhooks.
Dropping late webhooks. Webhooks retry and can arrive out of order. Write disputes idempotently, then recompute affected windows when a dispute lands in an already-closed month. Run a nightly reconciliation against the Disputes API to catch anything the webhook path missed.
Ignoring sandbox and test traffic. Filter by environment before aggregation, or test sales will inflate your denominator and hide a real problem.
Testing the monitor
A few property-style tests go a long way:
- For any
sales > 100,tier == "high"exactly whendisputes >= ceil(0.015 * sales). - Adding sales without disputes never moves you from standard to high, unless it crosses the 100 gate.
- Shifting all data forward by one month shifts the tier output by exactly one month.
Run these against generated data, then backtest against your actual fee statements. If your computed tier disagrees with what PayPal charged in any month, your attribution or scope assumptions are wrong. Fix the configuration before trusting the forecasts.
Wrapping up
The tiered dispute fee is a small line item per case, but it's a stateful, lagging, threshold-based rule. Rules like that punish anyone who only looks at the current month. A few dozen lines of code give you three things a monthly statement won't: current tier, headroom in absolute disputes, and how many months a bad stretch will cost you.
Top comments (0)