phpguzzle.org
enrudees
Stripe – Docs

Stripe Fees for Developers: Fee Logic, Tax, and Net Calculation in PHP

Hundreds of blog posts list Stripe’s percentages. This one won’t. The exact rates change by country, card brand, and product tier; hardcoding them into your codebase guarantees a bug the next time Stripe updates its pricing page. What doesn’t change is where those numbers live in the API and how your PHP code should read them.

The practical question is: a customer paid $100, how much do you actually keep? The answer involves a BalanceTransaction object most tutorials skip entirely, a fee_details array that separates processing from currency conversion, and a net field that already has the math done. For Connect platforms the picture adds application_fee_amount. For anyone collecting taxes, tax_behavior on the Price object controls whether the displayed price is gross or net.

This article is the code-first reference for all of it. If you need SDK setup basics, start with the payment integration guide. For exception handling around failed charges, see the errors article.

Where Stripe fees live in the API

A PaymentIntent or Charge object does not carry fee information. The fee lives on the BalanceTransaction – a separate object that represents any movement of funds in your Stripe balance.

$stripe = new \Stripe\StripeClient('sk_test_...');

// Retrieve a charge with its balance transaction expanded
$charge = $stripe->charges->retrieve('ch_1abc...', [
    'expand' => ['balance_transaction'],
]);

$bt = $charge->balance_transaction;

echo $bt->amount;   // 10000 (gross, in cents)
echo $bt->fee;      // 320   (total fee in cents)
echo $bt->net;      // 9680  (what hits your balance)
echo $bt->currency; // "usd"

Three fields, three numbers. amount is what the customer paid, fee is what Stripe kept, net is the difference. No percentages in the code, no formulas to maintain.

Without the expand parameter you get a string like "txn_1abc..." instead of the object. You can also retrieve it separately:

$bt = $stripe->balanceTransactions->retrieve('txn_1abc...');

Why not read fee from the Charge directly?

Because Charge has no fee field. The Stripe object hierarchy separates the payment event (Charge/PaymentIntent) from the balance impact (BalanceTransaction). This is deliberate: one charge can produce multiple balance transactions when partial refunds, disputes, or currency conversions get involved.

fee_details: what the fee is made of

The fee integer is a sum. The breakdown lives in fee_details:

foreach ($bt->fee_details as $detail) {
    printf(
        "%s: %d %s (%s)\n",
        $detail->type,
        $detail->amount,
        $detail->currency,
        $detail->description
    );
}

A domestic card charge produces one line:

stripe_fee: 59 usd (Stripe processing fees)

A cross-border charge with currency conversion adds more:

stripe_fee: 59 usd (Stripe processing fees)
stripe_fee: 30 usd (International card fee)
stripe_fee: 20 usd (Currency conversion fee)

The possible type values: stripe_fee, application_fee, payment_method_passthrough_fee, tax, withheld_tax. Each line has its own amount and description. If you need a breakdown for accounting, parse this array rather than guessing percentages.

Cross-border fees are separate line items

Reddit threads complaining about “22% Stripe fee” usually involve a stack of three lines: base processing, international card surcharge, and currency conversion. Each is a distinct charge in fee_details. If you log only $bt->fee you’ll see the total but won’t understand why it’s higher than expected. Always log the full breakdown in production:

$feeLog = array_map(
    fn($d) => "{$d->description}: {$d->amount}",
    $bt->fee_details
);
error_log('Fee breakdown: ' . implode(', ', $feeLog));

When balance_transaction.fee is not available yet

The BalanceTransaction is created when funds actually move. On a payment_intent.succeeded webhook event, the balance_transaction field on the charge may still be null if Stripe hasn’t created the transaction yet.

// Inside a webhook handler
$intent = $event->data->object;

$charge = $stripe->charges->retrieve($intent->latest_charge, [
    'expand' => ['balance_transaction'],
]);

if (null === $charge->balance_transaction) {
    // Not settled yet; schedule a retry or listen for charge.updated
    return;
}

$fee = $charge->balance_transaction->fee;

For reliable fee data, listen to charge.updated or invoice.finalized. By the time payout.paid fires, every balance transaction in that payout window has final numbers.

Building a fee lookup in PHP

Instead of a calculator that hardcodes “2.9% + $0.30”, query the actual fee from the API:

function getPaymentFee(\Stripe\StripeClient $stripe, string $chargeId): array
{
    $charge = $stripe->charges->retrieve($chargeId, [
        'expand' => ['balance_transaction'],
    ]);

    $bt = $charge->balance_transaction;

    if (null === $bt || !is_object($bt)) {
        throw new \RuntimeException('Balance transaction not available yet');
    }

    $details = [];
    foreach ($bt->fee_details as $d) {
        $details[] = [
            'type'        => $d->type,
            'amount'      => $d->amount,
            'currency'    => $d->currency,
            'description' => $d->description,
        ];
    }

    return [
        'gross'   => $bt->amount,
        'fee'     => $bt->fee,
        'net'     => $bt->net,
        'details' => $details,
    ];
}

This function works regardless of what Stripe’s current rates are. When rates change, your code doesn’t.

Application fees for Connect platforms

If you run a marketplace or SaaS platform on Stripe Connect, you collect revenue through application_fee_amount. This is your platform’s cut – separate from Stripe’s processing fee.

$intent = $stripe->paymentIntents->create([
    'amount' => 5000,
    'currency' => 'usd',
    'payment_method' => $pmId,
    'confirm' => true,
    'application_fee_amount' => 500, // $5.00 platform fee
], [
    'stripe_account' => 'acct_connected_123',
]);

In fee_details of the connected account’s balance transaction, two lines appear:

stripe_fee: 175 usd (Stripe processing fees)
application_fee: 500 usd (Application fee)

The connected account pays both. Your platform receives the 500 cents as a separate BalanceTransaction of type application_fee on your own account.

Retrieving application fees

$fees = $stripe->applicationFees->all([
    'charge' => 'ch_1abc...',
    'limit' => 10,
]);

foreach ($fees->data as $appFee) {
    echo $appFee->amount;            // 500
    echo $appFee->account;           // "acct_connected_123"
    echo $appFee->balance_transaction; // your platform's balance txn
}

A common mistake: confusing application_fee_amount with the Stripe processing fee. The application fee goes to you, the platform. The processing fee goes to Stripe. They stack – a $50.00 payment with a $5.00 application fee might net the connected account $43.25 after both fees.

Tax calculation with Stripe Tax API

The Stripe Tax API (available since 2022) automates sales tax, VAT, and GST. It replaced the older pattern of manually adding tax as custom line items with hardcoded percentages. For a custom payment flow (not Checkout), you compute tax server-side before creating the PaymentIntent:

$calculation = $stripe->tax->calculations->create([
    'currency' => 'usd',
    'line_items' => [
        [
            'amount' => 10000,     // $100 item price
            'reference' => 'L1',   // your internal ID
        ],
    ],
    'customer_details' => [
        'address' => [
            'line1' => '920 5th Ave',
            'city' => 'Seattle',
            'state' => 'WA',
            'postal_code' => '98104',
            'country' => 'US',
        ],
        'address_source' => 'shipping',
    ],
]);

echo $calculation->amount_total;          // 11030 (item + tax)
echo $calculation->tax_amount_exclusive;  // 1030

// Use amount_total as PaymentIntent amount
$intent = $stripe->paymentIntents->create([
    'amount' => $calculation->amount_total,
    'currency' => 'usd',
    'payment_method' => $pmId,
    'confirm' => true,
]);

After the payment succeeds, record the transaction for reporting:

$stripe->tax->transactions->createFromCalculation([
    'calculation' => $calculation->id,
    'reference' => 'order_' . $orderId,
]);

tax_behavior: inclusive vs exclusive

When you create a Price object, tax_behavior controls whether the amount already includes tax:

$price = $stripe->prices->create([
    'unit_amount' => 10000,
    'currency' => 'eur',
    'product' => 'prod_abc...',
    'tax_behavior' => 'inclusive', // or 'exclusive'
]);

Exclusive (common in the US): tax is calculated on top. A $100.00 item becomes $110.30 at checkout if the tax rate is 10.3%.

Inclusive (common in the EU): the sticker price already contains tax. A 100.00 EUR item stays 100.00 EUR; Stripe back-calculates the tax portion (e.g., 16.67 EUR if VAT is 20%).

Mixing behaviors in one Checkout Session works but requires attention. Each line item can have its own tax_behavior, and Stripe sums them correctly. The total your customer sees combines exclusive line items (amount + tax) and inclusive ones (amount as-is).

The Price object and adaptive pricing

The Price object replaced the deprecated Plan for defining how much to charge. Every product has at least one Price, each with a currency, billing interval (for subscriptions), and amount.

// One-time price
$price = $stripe->prices->create([
    'unit_amount' => 2500,
    'currency' => 'usd',
    'product' => 'prod_abc...',
]);

// Recurring price (monthly subscription)
$monthlyPrice = $stripe->prices->create([
    'unit_amount' => 1999,
    'currency' => 'usd',
    'product' => 'prod_abc...',
    'recurring' => ['interval' => 'month'],
]);

Multi-currency with Price objects

For international pricing you have two options. The traditional approach: create one Price per currency.

$priceEur = $stripe->prices->create([
    'unit_amount' => 2300,
    'currency' => 'eur',
    'product' => 'prod_abc...',
]);

The newer approach: Adaptive Pricing. When enabled on a Checkout Session, Stripe converts your base price into the customer’s local currency using real-time exchange rates. You define one price, Stripe handles the rest.

Adaptive Pricing is a Checkout-level feature. In a custom PaymentIntent flow, multi-currency still requires separate Price objects or manual conversion.

// Checkout Session with adaptive pricing enabled
$session = $stripe->checkout->sessions->create([
    'mode' => 'payment',
    'line_items' => [[
        'price' => 'price_abc...',
        'quantity' => 1,
    ]],
    'adaptive_pricing' => ['enabled' => true],
    'success_url' => 'https://example.com/thanks',
    'cancel_url' => 'https://example.com/cart',
]);

Adaptive Pricing shows the converted price on the Checkout page before the customer confirms. The settlement still happens in your account’s default currency. Currency conversion fees apply and show up as a separate line in fee_details.

Passing the processing fee to customers

Some businesses add a surcharge to cover Stripe’s fee. The math looks simple but gets the formula wrong more often than not:

// Wrong: the surcharge itself gets charged a fee
$amount = 10000;
$surcharge = (int) round($amount * 0.029 + 30);
$total = $amount + $surcharge;
// Stripe takes 2.9% + $0.30 of $total, which is more than $surcharge

// Correct: solve for total where total - fee(total) = amount
// total = (amount + fixed) / (1 - rate)
$rate = 0.029;
$fixed = 30; // cents
$total = (int) ceil(($amount + $fixed) / (1 - $rate));
// $total = 10330, fee on 10330 = 330, net = 10000 = $100

Legal note: surcharging credit card payments is regulated or banned in some jurisdictions (EU, Australia, parts of the US). Check local rules before implementing this.

Micro-payments: when the fixed fee dominates

On a $2.00 charge, Stripe’s fixed per-transaction fee represents a much larger percentage than on $100.00. This is why the effective rate on small amounts can look shocking:

function effectiveRate(int $amountCents, float $rate, int $fixedCents): float
{
    $fee = (int) round($amountCents * $rate) + $fixedCents;
    return round($fee / $amountCents * 100, 2);
}

echo effectiveRate(200, 0.029, 30);   // 18.0% on $2.00
echo effectiveRate(500, 0.029, 30);   // 9.0%  on $5.00
echo effectiveRate(10000, 0.029, 30); // 3.2%  on $100.00
echo effectiveRate(50000, 0.029, 30); // 2.96% on $500.00

For products under $5.00, consider bundling multiple items into a single charge, or using a minimum order amount. Stripe previously offered a micro-payments plan with lower fixed fees; that’s been discontinued for new accounts. The current fee schedule is at stripe.com/pricing.

Refund behavior: Stripe keeps the fee

Since September 2017 for new accounts (and September 2020 for legacy ones), Stripe no longer refunds the processing fee when you refund a payment. A $100.00 charge that you fully refund costs you the original processing fee with no recovery.

$refund = $stripe->refunds->create([
    'charge' => 'ch_1abc...',
]);

// After refund, check the refund's balance transaction
$refundBt = $stripe->balanceTransactions->retrieve(
    $refund->balance_transaction
);

echo $refundBt->amount; // -10000 (refunded to customer)
echo $refundBt->fee;    // 0 (no fee refunded back to you)
echo $refundBt->net;    // -10000 (your balance decreases by the full amount)

The original charge’s fee (e.g., 320 cents) stays with Stripe. Your actual loss on a full refund is $amount + $originalFee. Factor this into your refund policy and any financial reconciliation code.

Older blog posts and calculators that assume fee refunds will give you wrong numbers. If your code estimates refund costs, it should not subtract the original fee from the refund amount.

Stripe vs PayPal: a developer comparison of fees

Both charge a base percentage plus a fixed fee for domestic card payments. The exact numbers differ by country and change periodically, so check stripe.com/pricing and paypal.com/webapps/mpp/merchant-fees for current rates.

Where they diverge for developers:

Fee transparency in the API. Stripe exposes every fee component through balance_transaction.fee_details. PayPal’s transaction_fee field on a capture gives you one number – no breakdown of cross-border, currency conversion, or payment method surcharges.

Refund policies. Stripe keeps the processing fee on refunds. PayPal also keeps fees on refunds (changed in 2019). Both cost you money per refund, but the exact amounts differ.

Fee collection. Stripe deducts fees from each payment before it hits your balance. PayPal debits fees from your PayPal balance. In code, Stripe’s net field gives you the post-fee amount; with PayPal you compute it from gross_amount - fee_amount.

Connect/marketplace fees. Stripe Connect provides application_fee_amount with explicit routing to the platform. PayPal’s equivalent (Partner Referrals / Marketplace) uses a different fee negotiation model. The Stripe approach is more transparent in the API response.

Square’s processing fees are in the same ballpark. The difference for PHP developers: Square’s API returns a processing_fee array on the Payment object, which is closer to Stripe’s transparency than PayPal’s single number. Current rates at squareup.com/pricing.

The real question isn’t which is cheaper by 0.1%, it’s which API gives your code reliable fee data without spreadsheet gymnastics. On that metric, Stripe’s fee_details is the most granular of the three.

Reconciliation: verifying Stripe’s math

For financial reporting, iterate over balance transactions in a date range and verify the totals:

$transactions = $stripe->balanceTransactions->all([
    'created' => [
        'gte' => strtotime('2026-04-01'),
        'lte' => strtotime('2026-04-30'),
    ],
    'type' => 'charge',
    'limit' => 100,
]);

$totalGross = 0;
$totalFee = 0;
$totalNet = 0;

foreach ($transactions->autoPagingIterator() as $bt) {
    $totalGross += $bt->amount;
    $totalFee += $bt->fee;
    $totalNet += $bt->net;
}

// Sanity check: net should equal gross minus fees
assert($totalNet === $totalGross - $totalFee);

printf(
    "Gross: %s, Fees: %s, Net: %s\n",
    number_format($totalGross / 100, 2),
    number_format($totalFee / 100, 2),
    number_format($totalNet / 100, 2)
);

Use autoPagingIterator() for pagination – it handles starting_after automatically. The API reference covers pagination patterns in detail.

Filter by type to separate charges from payouts, refunds, and adjustments. For a full fee report across all transaction types, group results by the reporting_category field on each returned transaction – it maps to Stripe’s financial report categories and makes reconciliation with Dashboard exports easier.

FAQ

Does Stripe take 3%?

The base processing rate depends on your country and card type. For US accounts it’s typically 2.9% + $0.30 per successful charge, but this varies for international cards, AMEX, and specific payment methods. Check stripe.com/pricing for your region. In code, never hardcode this number; read balance_transaction.fee after the charge settles.

How much is the Stripe fee for $100?

For a domestic US card, roughly $3.20 (2.9% of $100 + $0.30 fixed). But if the card is international, add the cross-border surcharge. If currency conversion happens, add that too. The only source of truth is balance_transaction.fee on the actual charge – a standard domestic payment and an international one with the same $100 amount will have different fees.

Is the Stripe payment gateway free to set up?

There are no setup fees, monthly fees, or minimum commitments. You pay per transaction. The API, Dashboard, test mode, and developer tools are all free to use.

How do I add a 3% fee on Stripe?

Use the formula total = (amount + fixed_fee) / (1 - rate) to compute the surcharge correctly. A naive amount * 0.03 underpays because the surcharge itself gets charged a fee. See the surcharging section above. Also check whether surcharging is legal in your jurisdiction before shipping this to production.

What’s cheaper, PayPal or Stripe?

Base rates are within 0.1-0.5% of each other for most regions. The real cost difference comes from refund policies, cross-border fees, and volume discounts. Both keep processing fees on refunds. For high-volume businesses, Stripe offers custom rates through sales. Compare based on your actual transaction mix (domestic vs international, average ticket size, refund rate), not headline percentages.

Does Stripe charge a fee for debit cards?

Yes. Debit cards are charged the same base rate as credit cards in most regions. Some countries have lower debit rates – check your Dashboard under Settings > Payment methods for the exact rates applied to your account.

What is adaptive pricing?

A Checkout feature that automatically converts your product price into the customer’s local currency using real-time exchange rates. You define one price in your base currency; Stripe handles the presentation. The customer sees the price in their currency, you receive settlement in yours. Currency conversion fees from fee_details apply. Requires Checkout Sessions – not available in custom PaymentIntent flows.

Spotted something inaccurate on this page?

Report an error