Stripe's own tools for fee visibility, and their limits
Dashboard views, exports, Sigma, and reporting reports show what happened. None compute what should have happened — that gap is where fee leaks live.
Stripe's own tools for fee visibility, and their limits
Before you build or buy anything, inventory what Stripe already shows you. This piece walks platform founders, payments engineers, and finance teams through the Dashboard, CSV exports, Sigma, and reporting reports — then names the question none of them answers.
The inventory
Four native surfaces cover fee visibility on a Stripe account. Each is described here only as far as Stripe's own documentation describes it.
Dashboard views. The payments list shows charges and their refunds; the Collected fees view lists ApplicationFee objects with fields including amount, amount_refunded, currency, and account (application fees); the Balance section reconciles like a bank statement, and Stripe publishes prebuilt balance reports to "reconcile your Stripe balance like a bank account" (Stripe reporting).
CSV exports. Every one of these views exports to CSV. Sigma's own pitch includes the same escape hatch: export query results in CSV format to import into your tools (Sigma).
Stripe Sigma. An interactive SQL environment inside the Dashboard over your transactional data — payments, refunds, disputes, payouts, and more. The data is read-only; queries cannot modify or create transactions (how Sigma works). Beyond the editor, you can export results as CSV, fetch data on a schedule of your choosing, and run the same queries programmatically through the Query Run API, which lives on the Reports API v2 under /v2/data/reporting/query_runs (Query Run API). Query runs return CSV result files, can be requested compressed, notify completion by webhook instead of polling, and their result files are retained for 90 days. In sandboxes, Sigma is free to use with no usage limits.
Reporting report types. Prebuilt reports exist for itemized balance changes and payout reconciliation, viewable in the Dashboard or downloadable as CSV, on a schedule if you want one (Stripe reporting). The same report types are addressable from Sigma templates and the API, with stable identifiers: balance_change_from_activity.itemized.3, payouts.itemized.3, payout_reconciliation.itemized.5, and ending_balance_reconciliation.itemized.4 (how Sigma works). Alongside these sits Data Pipeline, documented as a retrieval path for the same tables outside the Sigma editor (querying fees data); warehouse-oriented teams pair it with destinations like BigQuery.
The table below condenses the inventory into the question each tool answers, its interface, and its output form.
| Tool | Question it answers | Interface | Output form |
|---|---|---|---|
| Dashboard (payments, Collected fees, Balance) | What arrived, what was refunded, what fees were collected, what sits in balance | Browser views | On-screen lists, summaries, drill-downs |
| CSV exports | The same questions, taken offline | Download buttons in Dashboard views | CSV files |
| Sigma | Any custom question expressible in SQL over your Stripe tables | Dashboard SQL editor; scheduled runs; Query Run API | Tables and charts in-app; CSV downloads; QueryRun result files |
| Reporting report types | Standardized itemized ledgers: balance changes, payout reconciliation | Dashboard reports; Reports API | CSV downloads, scheduled delivery |
| Data Pipeline | The same schema, retrieved outside the Dashboard | Documented pipeline configuration | The same tables in your environment |
What each genuinely answers
These tools are good at their jobs. Three worked fits show where to reach for each.
Total fees paid last month. Sigma, one query over balance_transactions — every column here is verified against Stripe's documented query examples (query transactional data):
select
sum(fee) as stripe_fees_last_30d,
currency
from balance_transactions
where created >= date_add('day', -30, current_date)
group by currency
All refunds in a window, with their charges. Again Sigma, joining the two tables the way Stripe's own refund template does:
select
date_format(date_trunc('day', balance_transactions.created), '%Y-%m-%d') as day,
balance_transactions.source_id as refund_id,
refunds.charge_id,
balance_transactions.amount as refunded_amount,
balance_transactions.currency
from balance_transactions
inner join refunds
on refunds.balance_transaction_id = balance_transactions.id
where balance_transactions.type = 'refund'
and balance_transactions.created >= date_add('day', -30, current_date)
order by day desc
Payout-to-bank reconciliation. No SQL needed. The payout reconciliation report breaks down the individual transactions included in each payout (Stripe reporting), itemized via payout_reconciliation.itemized.5, downloadable or scheduled. For teams still doing this from spreadsheets, the migration path is unglamorous but real: from Excel export to recovery, with controllers' concerns covered separately (the controller's view).
Fees by transaction category. The same balance_transactions table groups fees by the type of activity that incurred them, which is often the fastest way to see where processing cost concentrates:
select
type,
sum(fee) as stripe_fees,
currency
from balance_transactions
where created >= date_add('day', -30, current_date)
group by type, currency
order by stripe_fees desc
Itemized balance changes without writing SQL. When the question is "every entry that moved my balance in March," the prebuilt itemized balance change report — balance_change_from_activity.itemized.3 — answers it without a query editor, on screen or as a scheduled CSV (how Sigma works, Stripe reporting). Reach for report types when the question is standard; reach for Sigma when it is not.
Notice the shape all four share. A question goes in, a description of recorded history comes out. Totals, listings, reconciliations — all past-tense.
The gap: expected versus actual
Fee leakage is not a past-tense question. "How much did we refund last month?" is answerable everywhere. "Which refunds should have reversed a transfer that did not reverse?" requires holding an expectation next to the record, and no native object stores expectations. There is no field named expected_reversal on any Transfer, and none named expected_fee_refund on any ApplicationFee. Every column in the documented schema records something that happened (schema). Nothing records what should have happened.
Producing the expectation means joining the object graph client-side — refunds to charges, destination charges to transfers, transfers to reversals, charges to application fees — then applying formulas yourself. Here is how close Sigma gets you. This query returns the raw components for the reversal check, using only columns verified in Stripe's published examples:
select
date_format(bt.created, '%Y-%m-%d') as day,
r.id as refund_id,
r.charge_id,
c.amount as charge_amount,
t.amount as transferred_amount,
bt.amount as refund_amount,
bt.fee as stripe_fee_on_refund,
bt.currency
from balance_transactions bt
inner join refunds r
on r.balance_transaction_id = bt.id
inner join charges c
on c.id = r.charge_id
left join transfers t
on t.id = c.transfer_id
where bt.type = 'refund'
order by day desc
limit 100
Run it and you get rows like: charge 10000¢, transfer 9000¢, refund 10000¢, currency usd. Now the two lines of arithmetic that live outside every native tool:
- Expected reversal = round((refund_amount ÷ charge_amount) × transfer_amount) = round((10000 ÷ 10000) × 9000) = 9000¢ ($90.00).
- Missing = expected − reversals actually made = 9000 − 0 = 9000¢ ($90.00).
The fee analog runs identically against the application fee: expected proportional fee refund minus amount_refunded. On the canonical $100.00 charge with a $10.00 application fee, a default full refund shows expected fee refund 1000¢, refunded 0, missing 1000¢ ($10.00).
The fee analog runs identically against the application fee: expected proportional fee refund minus amount_refunded. On the canonical $100.00 charge with a $10.00 application fee, a default full refund shows expected fee refund 1000¢, refunded 0, missing 1000¢ ($10.00).
Run the same diff on the well-behaved twin and the method proves it stays quiet. Full refund with both flags set on the canonical charge: expected reversal 9000¢, reversals on file 9000¢, missing 0; expected fee refund 1000¢, amount_refunded 1000¢, missing 0. No finding. That silence is the design goal — an expectation engine earns trust by raising exactly the rows where recorded reality diverges from computed intent, never the rows where someone did everything right.
Why the joins span four object types is a consequence of how Connect splits money. A single destination refund touches a Refund (the debit), a Charge (the ratio's denominator), a Transfer (the funds at risk), TransferReversal objects (what came back), and an ApplicationFee (the platform margin component). Direct charges push some of those objects onto connected accounts entirely, separate charges and transfers decouple the transfer from the refund with no fee object at all, and no single table or report assembles the graph for you. The assembly step — joining refunds to charges to transfers to reversals to fees — is precisely the client-side work that makes detection possible, and it is also why the next limit matters.
Those two subtraction lines are the entire detection method, and they are exactly the part Sigma cannot execute, because they reference quantities no table contains. You can run the query, export the CSV, and do the arithmetic in a script afterward — that pairing is legitimate and some teams should. What you cannot do is ask any Stripe-native surface "which refunds are missing money," because the surface has no concept of missing. That boundary between recording and expecting is precisely where Sigma-and-exports workflows stop, and closing it programmatically is standard work for a payments engineer.
Where direct charges go blind at the platform level
One more limit matters specifically for Connect platforms. Direct charges are created on connected accounts, and Stripe states plainly what that does to visibility: "If your platform creates direct charges on a connected account, they appear on the connected account, not on your platform" (querying connected account data). Your own account's tables and most platform-side Dashboard views simply do not contain those objects.
Stripe does provide doors. Platforms can use Connect-specific tables such as connected_account_charges and connected_account_balance_transactions — structured like your own tables, plus an account column identifying the connected account. Organization-level Sigma exists too, letting you query across multiple accounts belonging to your organization, grouped per account by merchant_id, provided Sigma is enabled on each account and you hold an organization role such as Analyst (Sigma across an organization). Note the scope: that feature spans the direct accounts of your organization — your own house — while connected-account data flows through the Connect-specific tables.
The table below maps each charge pattern to where its objects live and which native door reaches them from the platform side.
| Charge pattern | Where charge objects live | Platform-side default visibility | Door required |
|---|---|---|---|
| Destination charges | Platform account | Full — charges, refunds, transfers in your own tables | None; join charges.transfer_id to transfers |
| Separate charges and transfers | Platform account (transfers too) | Full — linked by transfer_group | None |
| Direct charges | Each connected account | Absent from platform tables by default | connected_account_* tables, or per-account queries |
The consequence for detection is structural rather than fatal: fleet-wide queries are possible, but they go through a different door than your platform ledger, and any expectation engine must be explicit about which side of it each object lives on. A refund on a direct charge, the fee it skipped, and the reversal it never triggered are invisible unless someone deliberately crosses into the connected_account_* tables — a scheduled platform-account CSV will simply not contain those rows. The same split applies outside SQL: report types and prebuilt reports describe the account they run against, so fleet coverage means running them per account or stepping up to organization-level tooling with its enablement requirements. For a deeper cut on the same theme, see Sigma and exports versus detection.
Freshness and retention
A few facts are documented precisely. Sigma exposes a data_load_time parameter giving the timestamp data is available through (how Sigma works). Query Run results persist for 90 days, and each download URL expires after roughly five minutes (Query Run API). Completion can arrive by webhook so batch jobs need not poll.
Beyond those points, freshness and retention vary by tool and are documented by Stripe; design accordingly. Practically, that means a detection loop should not assume real-time inputs anywhere in the chain: refunds sit pending when a balance is short, settlement lags authorization, and a row may appear in a table days after the money event it describes. Charge amounts land in pending first and become available on a rolling schedule that varies by country and account (payouts), so the window between "refund issued" and "all its ledger traces visible" is measured in days by design. Build the loop to tolerate lag — cursor-based pulls, re-check windows, idempotent processing — and let the tools be as fresh as they are rather than as fresh as you wish.
The retention facts carry their own lesson for anyone wiring the Query Run API into a pipeline: result files vanish after 90 days and download URLs after minutes, so the system holding your results must fetch promptly and persist durably. Treat every Stripe-native output as perishable inventory; whatever you want to keep, copy the moment it completes.
Where exports fit a detection loop
None of the above makes exports obsolete; it assigns them a position. Detection has two cadences. Batch reconciliation — pull, join, expect, diff — runs daily or weekly and tolerates the freshness limits above. Event-driven checks react to individual refunds near-real-time. Exports serve both poorly as detectors and both well as evidence: a signed-in-time CSV of refunds, transfers, and reversals is the artifact you attach to a finding when someone asks "prove it."
So treat exports as the audit trail layer even when computation happens elsewhere: schedule them, store them immutably, and make every alert reference the export batch that corroborates it. Teams running warehouse copies get this almost for free — the BigQuery sync pattern keeps history queryable while exports remain the point-in-time proof. The failure mode to avoid is using exports as the detector: a monthly spreadsheet diff finds leaks six weeks late and misses rounding-scale losses entirely.
The two cadences divide the work cleanly, and it helps to see the division explicitly:
- Batch reconciliation pulls on a cursor or calendar, joins and diffs offline, tolerates the freshness limits above, and produces the periodic completeness check — nothing escaped.
- Event-driven checks react per refund near-real-time, need idempotent handlers because delivery retries, and produce fast single-case answers at the cost of running infrastructure continuously.
- Exports serve both as evidence rather than computation: immutable, timestamped files that make any finding independently verifiable months later, long after the live objects have moved on.
A detection practice that runs both loops against the same expectation formulas gets the best property available in this domain: the two paths disagree only when something is actually wrong.
Frequently asked questions
Is Sigma included in normal Stripe pricing?
Using Sigma in sandboxes is free, with no usage limits on test data. Production use of the Query Run API requires an active Sigma subscription, and creating query runs needs API key permissions Stripe labels reporting_write and sigma_api_write. Current pricing tiers live on Stripe's pricing page; this piece intentionally quotes none.
Can Sigma compute what should have been reversed?
No. Sigma runs read-only SQL over recorded objects, and expectations are not recorded anywhere in the schema. You can pull the raw components — refund amounts, charge amounts, transfer amounts — but the proportionality math and the shortfall diff happen in your code after the data lands.
Do the reporting reports replace a reconciliation script?
They complement it. Payout reconciliation and itemized balance reports describe actuals with excellent fidelity — that is their job. Expectation diffing asks a counterfactual question those reports cannot pose. Run the reports for close-of-book truth; run an expectation engine for leakage.
Does Radar flag any of this?
No. Radar scores and blocks payments for fraud risk before and at payment time; it has no bearing on refund-time money movement, flag defaults, or fee recovery. The closest touchpoint is indirect: marking a refund with reason=fraudulent feeds Radar block lists.
Run the free 90-day audit
Everything above describes recording instruments. FeeGuard builds the missing half: it reads your last 90 days of Connect activity through a restricted, read-only API key, computes what should have happened alongside what did, and reports every unreversed transfer, unreclaimed application fee, uncovered dispute loss, and FX slippage instance with the amounts attached. You get the answer first; monitoring is optional afterward.
FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.