Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 033 / 100

Stripe fees and reporting: practical tips and trade-offs

Understand what you pay.

Expona Admin8 min read

Introduction

By day 33 of payment engineering, you've built checkout flows, handled webhooks, and stored customer payment methods. But if you haven't instrumented proper fee tracking and reporting, you're flying blind on one of your most important metrics: what you actually pay Stripe. Understanding Stripe fees and reporting: practical tips and trade-offs isn't just accounting work—it's how you optimise pricing strategy, negotiate volume discounts, and catch billing anomalies before they compound.

We've seen teams lose hundreds of thousands in margin because they didn't track per-transaction fees at scale, or they couldn't explain to finance why conversion fees spiked last quarter. In this guide, we'll walk through the mechanics of Stripe's fee structure, show you how to instrument reporting in your application, and share the trade-offs between realtime dashboards and batch-exported records.

How Stripe fees work: understanding balance transactions

Stripe doesn't charge you in a single monthly invoice. Instead, fees are applied per transaction and deducted from your payouts. Every time a customer completes a payment, Stripe creates a balance transaction—a line item that nets the fee, dispute chargeback, refund, or payout. This granularity is powerful but requires you to understand the model.

A typical payment flow generates multiple balance transactions:

  • Charge creation: Customer pays $100. Stripe takes 2.9% + $0.30 = $3.20. Your balance increases by $96.80.
  • Refund: Customer requests $50 refund. Stripe takes 2.9% + $0.30 again (the fee is not waived). Your balance decreases by $51.60 ($50 + $1.60 fee).
  • Dispute chargeback: Customer disputes the original $100 charge. Stripe charges $15 dispute fee and claws back the $96.80 credit. Your balance decreases by $111.80.

The key insight: you can't assume a fixed fee rate. Refunds, disputes, chargebacks, and transfer fees all have their own line items. If your accounting team only looks at payout totals, they'll miss the granular picture.

To audit fees programmatically, iterate the Balance Transactions API:

typescript
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

async function auditFeesByDay(date) {
  const startOfDay = Math.floor(new Date(date).getTime() / 1000);
  const endOfDay = startOfDay + 86400;

  const transactions = await stripe.balanceTransactions.list({
    created: { gte: startOfDay, lt: endOfDay },
    limit: 100,
  });

  let totalFees = 0;
  let feesByType = {};

  for (const txn of transactions.data) {
    const fee = txn.fee / 100; // Convert from cents
    totalFees += fee;
    feesByType[txn.type] = (feesByType[txn.type] || 0) + fee;
    
    console.log(`${txn.type}: $${fee.toFixed(2)}`);
  }

  console.log(`Total fees: $${totalFees.toFixed(2)}`);
  console.log(feesByType);
}

auditFeesByDay('2025-01-10');

Run this daily or weekly to catch spikes. We recommend storing results in your own database so finance can query historical trends without hitting the API repeatedly.

Currency conversion and international fees

Once you expand globally, conversion fees become your second-biggest fee category after transaction fees. Stripe applies two costs when a customer in one currency settles into your account currency:

  1. Conversion markup: Typically 2.5% on the FX rate (varies by currency pair and volume).
  2. Cross-border processing fee: Additional 1–2% for international card networks (for some regions).

A real scenario: your US account is in USD. A UK customer pays £100. Stripe's rate is £1 = $1.25. Stripe applies 2.5% markup, so the effective rate is £1 = $1.22. The £100 charge settles as $122 minus the markup difference, plus the card network fee. Your payout is less than you'd expect if you trusted a live exchange rate.

To capture FX details, check the balance transaction's description and conversion_rate fields:

typescript
async function analyzeFXFees(startDate, endDate) {
  const transactions = await stripe.balanceTransactions.list({
    type: 'adjustment', // FX adjustments appear here sometimes
    created: {
      gte: Math.floor(new Date(startDate).getTime() / 1000),
      lt: Math.floor(new Date(endDate).getTime() / 1000),
    },
    limit: 100,
  });

  for (const txn of transactions.data) {
    if (txn.description && txn.description.includes('Currency conversion')) {
      console.log({
        amount: txn.amount / 100,
        fee: txn.fee / 100,
        currency: txn.currency,
        sourceCharge: txn.source,
      });
    }
  }
}

Better approach: if you process significant volume in multiple currencies, open accounts in those currencies. A GBP account in the UK avoids conversion markup entirely when UK customers pay you directly. Stripe's multi-currency payouts are cheaper than constant conversion.

Stripe Reports API for finance teams

The Balance Transactions API works for real-time queries, but for finance teams running month-end reconciliation, you need scheduled reports. Stripe's Reports API generates pre-built CSV exports of fees, payouts, and reconciliation data—exactly what your accountant needs.

Key report types:

  • Payout reconciliation report: Every payout, itemised by transaction.
  • Balance transaction summary: Fees, refunds, and disputes aggregated by day or month.
  • Scheduled payouts report: Upcoming payouts for cash forecasting.
  • Tax transaction report: Itemised sales by jurisdiction (if using Stripe Tax).

To request a report programmatically:

typescript
async function generateMonthlyFeeReport(year, month) {
  const startOfMonth = new Date(year, month - 1, 1);
  const endOfMonth = new Date(year, month, 0, 23, 59, 59);

  const report = await stripe.reporting.reportRuns.create({
    report_type: 'balance_transaction_summary_reconciliation',
    parameters: {
      interval_start: Math.floor(startOfMonth.getTime() / 1000),
      interval_end: Math.floor(endOfMonth.getTime() / 1000),
      payout: 'all', // All payouts in the interval
    },
  });

  console.log(`Report created: ${report.id}`);
  console.log(`Status: ${report.status}`);

  // Poll until ready (usually completes in seconds)
  let ready = false;
  while (!ready) {
    const check = await stripe.reporting.reportRuns.retrieve(report.id);
    if (check.status === 'succeeded') {
      console.log(`Download: ${check.result.url}`);
      ready = true;
    } else if (check.status === 'failed') {
      throw new Error(`Report failed: ${check.result.error}`);
    }
    await new Promise(r => setTimeout(r, 1000));
  }
}

generateMonthlyFeeReport(2025, 1);

Once the report is ready, the result.url is a pre-signed download link valid for 24 hours. Pipe that into your accounting software (see next section) or email it to finance.

Exporting to accounting tools and SQL databases

Most teams don't store Stripe fee data manually. Instead, they either use Stripe's native connectors or build a lightweight ETL pipeline. Here's what works in production:

Option 1: Native integrations

If you use QuickBooks Online, Xero, or FreshBooks, Stripe has built-in connectors through apps like Zapier or direct API bridges. A payout webhook triggers an entry in your accounting software automatically. Overhead is minimal, but customisation is limited and you depend on the third-party app's maintenance.

Option 2: Daily ETL to PostgreSQL

For teams needing custom reporting, we recommend a scheduled job that pulls balance transactions and stores them in your database. This lets you join with your own orders table for margin analysis:

typescript
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

async function syncStripeFeesDaily() {
  const yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);
  yesterday.setHours(0, 0, 0, 0);

  const startOfDay = Math.floor(yesterday.getTime() / 1000);
  const endOfDay = startOfDay + 86400;

  let hasMore = true;
  let startingAfter = null;

  while (hasMore) {
    const transactions = await stripe.balanceTransactions.list({
      created: { gte: startOfDay, lt: endOfDay },
      limit: 100,
      starting_after: startingAfter,
    });

    for (const txn of transactions.data) {
      await prisma.stripeFeeRecord.upsert({
        where: { stripeId: txn.id },
        update: {},
        create: {
          stripeId: txn.id,
          type: txn.type,
          amount: txn.amount / 100,
          fee: txn.fee / 100,
          currency: txn.currency,
          createdAt: new Date(txn.created * 1000),
          sourceChargeId: txn.source,
          description: txn.description,
        },
      });
    }

    hasMore = transactions.has_more;
    startingAfter = transactions.data[transactions.data.length - 1]?.id;
  }

  console.log('Daily Stripe fee sync complete');
}

// Run via cron or Lambda every morning at 2 AM UTC
syncStripeFeesDaily();

With data in your own database, you can write SQL to answer questions finance asks:

typescript
// Average fee rate by month and payment method
SELECT 
  DATE_TRUNC('month', created_at) AS month,
  SUM(fee) / SUM(amount) * 100 AS fee_rate_pct,
  COUNT(*) AS transaction_count
FROM stripe_fee_records
WHERE type = 'charge'
GROUP BY 1
ORDER BY 1 DESC;

Common mistakes

  • Forgetting refund fees. Many teams assume refunds are free. Stripe charges fees on refunds too. If you have a 10% refund rate and don't account for it, your effective fee rate is 10–15% higher than quoted.
  • Not tracking disputes separately. A $15 dispute fee plus clawback is brutal. If you don't isolate chargeback risk, you'll under-price high-fraud categories. Use the Balance Transactions API's type: 'adjustment' to find dispute fees.
  • Ignoring conversion markup in pricing. If you price in USD but serve 30% international traffic in GBP/EUR, you're absorbing 2.5% FX markup. Either raise prices in those markets, open local Stripe accounts, or accept lower margins.
  • Syncing too frequently. Querying balance transactions every minute is wasteful. Once daily (overnight, off-peak) is standard. Use webhooks only for real-time alerts on refunds or disputes.
  • Trusting Stripe dashboard totals for reconciliation. The dashboard rounds numbers and can lag. Always reconcile against the Reports API or balance transactions query.

Key takeaways

  • Every transaction—charge, refund, dispute, payout—is a separate balance transaction. Iterate them systematically to understand true fees.
  • Currency conversion markup is 2.5%+ on international payments. Open local-currency Stripe accounts to eliminate it at scale.
  • Use the Reports API monthly for finance team reconciliation; sync balance transactions daily to your own database for custom reporting.
  • Dispute fees ($15 + clawback) and refund fees add significant cost. Factor them into margin models by risk category.
  • Never trust dashboard totals for accounting. Export data, store it, and query it yourself.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles