I’ve spent a fair amount of time in the space between Stripe and Xero over the last few years, across several Laravel builds that take payments and keep their books in order. What strikes me about that work, looking back over it, is how differently fee handling fails compared with everything else in a payments integration.
Most things that go wrong announce themselves fairly quickly. A card gets declined, a webhook returns a 500, an invoice doesn’t get marked as paid, or somebody emails to say they’ve been charged twice. You find out because something has visibly stopped working, and you go and fix it.
Fees don’t behave like that. If an integration records payments but mishandles the fees Stripe deducts, nothing appears to break at all. Customers pay, invoices close, and the site carries on exactly as it should. The only symptom turns up much later, when somebody sits down to reconcile a bank payout and finds the numbers won’t meet.
That asymmetry is why I’ve come to treat fees as one of the first things to work through rather than a detail to tidy up afterwards. Here’s what I’d want checked before any of these systems sees its first live reconciliation.
There is probably more than one way in
If your client emails invoices out of Xero, those invoices can carry a “Pay now” link, and when somebody pays that way it’s Xero that originates and handles the payment rather than your application. On one of these builds those payments never touched the site’s webhook at all. Xero wrote the payment and a fee straight into the ledger, and the first the application knew about it was the invoice changing status.
Meanwhile the website was taking its own payments, creating PaymentIntents and pushing payments into Xero through the Accounting API. Two routes into one Stripe account, only one of which was handled by code anybody on the project controlled. In a setup like this, any assumption that every payment arrives through your application’s webhook stops being true the moment somebody uses Pay Now on an emailed invoice.
You can tell the two apart at the Stripe end using metadata, so it’s worth setting something distinctive of your own such as the Xero invoice ID. In the account I looked at, Xero attached its own keys as well (OrgCode, OrgName, Invoice number), which made the split easy to see, though I’d treat third-party metadata as evidence to test rather than a contract you can rely on. For duplicate prevention specifically, the stronger check is whether the Stripe charge ID is already represented by an authorised transaction in Xero.
The fee Xero records may not be the fee you think
This was the gotchya.
Xero’s Pay Now service is built on Stripe Connect, with Xero acting as the platform. If you expand fee_details on one of those charges, what came back in the account I inspected was a single entry:
type: "application_fee"
description: "Xero application fee"
There was no stripe_fee component at all. For those payments the Xero application fee was the entire amount deducted from the organisation’s Stripe balance, and any underlying processing cost wasn’t charged to them separately.
The consequence is that the fee entries Xero created for those transactions weren’t Stripe processing fees, even though Xero posted them into an account named something like “Stripe Fees”. The account name gave no indication of which type of fee any given transaction represented. Connect economics vary depending on how a platform has configured things, so I wouldn’t take that as universal behaviour, but it’s worth verifying rather than assuming, because it changes what any comparison between the two systems actually means.
Which is why headline totals mislead
Stripe’s fee reporting can combine its own processing fees with fees charged by Connect platforms or external partners into a single figure. If you take that number, compare it against a Xero account holding only some of those categories, and treat the difference as missing fees, you’ll get an answer that looks perfectly reasonable and means something other than what you think.
Depending on what’s in each, the gap can come out smaller than the real shortfall, larger than it, or pointing at entirely the wrong set of payments. I’ve watched an apparently sensible discrepancy dissolve once it became clear that two different things were being measured against each other.
If you’re reconciling, work from the itemised attribution rather than the headline: balance_transaction.fee_details per charge, or Stripe’s fee reports with the breakdown visible. It’s worth knowing too that not every Stripe cost can be found by walking through charges at all, since dispute fees and certain standalone account charges appear as separate balance transactions, so a genuinely complete picture means reconciling against balance transactions rather than assuming every cost attaches to a payment.
Recording your own fees takes more work than it looks
For payments your own application handles, if no other integration is creating the fee transaction then you’ll need to, because Xero can’t infer an accounting entry from a payment your code has posted.
The awkward part is that the fee isn’t in the webhook payload. A payment_intent.succeeded event gives you the intent but not the amount Stripe took, which lives on the associated balance transaction and has to be fetched separately:
$charge = $stripe->charges->retrieve($charge_id, [
'expand' => ['balance_transaction'],
]);
One warning worth passing on. Stripe removed PaymentIntent.charges in API version 2022-11-15 in favour of latest_charge, and in my testing, requesting both the legacy and the current expansion paths in a single call caused the whole request to fail rather than quietly ignoring the one that didn’t apply. The approach of expanding both and seeing which comes back therefore doesn’t work. It’s more reliable to take the charge ID from whichever payload shape you’ve received and then fetch the charge on its own.
The balance transaction is worth fetching for a second reason, which is the date. It carries its own timestamp for when Stripe created the balance activity, and that is the date the fee actually bears on the payout you’ll eventually be reconciling against. For card payments it sits within seconds of everything else and the distinction is invisible. For delayed methods like Bacs Direct Debit, where a PaymentIntent can be created most of a week before the payment is confirmed, choosing the wrong timestamp is the difference between a reconciliation that works and one that doesn’t.
Stripe is not a bank account
In a typical automatic-payout setup, Stripe doesn’t pay out per transaction. It pays in batches, net of fees, some time after the original payments. If you post each payment straight into the client’s current account dated the day of the charge, Xero accumulates a series of individual gross deposits while the bank statement shows a single lump sum arriving later.
Those records no longer form a simple one-to-one match. They can still be reconciled, since Xero will let you match several transactions against one statement line, but it becomes a manual exercise, and it’s exactly the sort of process where a missing fee goes unnoticed because whoever is doing it assumes the difference is something they’ve misunderstood.
The conventional answer is a clearing account: a bank-type account in Xero representing the Stripe balance, with payments in at gross, fees out, and the payout appearing as a single transfer into the real bank account. The clearing account can then be reconciled against a defined Stripe balance at the same cut-off, allowing for pending funds and timing differences, which means a discrepancy surfaces as it happens rather than at year end.
Xero also offers a direct Stripe feed that imports Stripe activity as statement lines. It doesn’t necessarily replace the accounting transactions your application creates, so be explicit about which system is creating entries and which is supplying statement data. Get that wrong and you risk duplicated entries, unmatched statement lines, or a reconciliation process nobody fully understands.
The thing worth taking away
Billing logic on a system like this is intricate, and it’s the part that looks hardest at the outset: working out who gets invoiced, which account code applies to which product, how a guest checkout maps onto a contact record. It’s also testable, and it’s your own logic, so when you get it wrong something visibly doesn’t work and you fix it.
Fees are the inverse. A handful of lines of code, a few small numbers, and no failure mode anybody will notice. The system carries on working while the accounts quietly stop describing reality, and by the time that surfaces it’s usually somebody in finance who finds it rather than anybody in the development team.
Which is the whole argument for working reconciliation out at the design stage, while there’s still time to choose how the money is modelled, rather than once the payments are already flowing and the answers have to fit around what’s there.
We build and maintain Laravel applications with accounting integrations behind them. If you’re planning something similar, we’re happy to talk it through.