Trueness

Stripe refunds in HubSpot: why a refunded invoice still reads "paid"

A customer gets refunded in Stripe™. Days or weeks later, HubSpot — the deal, the invoice, whatever property your team looks at — still says "Paid." Nobody notices until a customer asks why they were billed for something they returned, or until someone doing month-end close adds up "paid" invoices and the total doesn't match the bank.

This isn't a sync outage. It's a precise, explainable mechanism, and once you see it, the fix is obvious — but it isn't the fix most teams reach for first.

Why it happens

Stripe's own Invoice object has no "refunded" status. The status field on a Stripe invoice is one of exactly five values: draft, open, paid, uncollectible, void. There is no sixth value for "paid, then refunded." When you refund a paid invoice, Stripe does not change the invoice's own status field — the invoice stays paid, because from Stripe's bookkeeping perspective, the invoice was paid; the refund is a separate, later event recorded on a different object. A connector that maps HubSpot's payment status directly from invoice.status will therefore keep reporting "paid" indefinitely, and it will be reading Stripe correctly while doing it.

Refunds live on their own object with their own event stream. A Refund is a distinct Stripe object, and the authoritative events are refund.created, refund.updated, and refund.failed. A common and easy mistake is reading refund information off charge.refunded instead — but that event's payload is the Charge, not the Refund, and Stripe's own documentation is explicit that refund.created is where refund details belong. Code (or a no-code automation) built around charge.refunded alone can miscount partial refunds or miss the refund reason entirely.

Refunds can fail after they're created. refund.created firing doesn't guarantee money actually moved — a refund can subsequently fail (a closed card, for instance), which fires refund.failed. Anything that decrements revenue on refund.created and never listens for refund.failed will under-report revenue the moment a refund bounces, in the opposite direction from the original problem.

Credit notes make it worse, because one credit note can do four different things at once. Stripe can record a revenue reversal as a Credit Note, and a single credit note can simultaneously reduce what's still owed on an open invoice, generate an actual cash refund, credit the customer's account balance for future use, and record a credit that happened outside Stripe entirely. A system that stores "credit note total" as one number can't tell you whether money left the bank or was simply credited toward next month's invoice — and those are very different facts for a finance lead.

Disputes are a fourth, separate case. When a customer disputes a charge, funds are withdrawn from your Stripe balance at the point the dispute opens — not when it's decided. They're only returned if you win. A system that reduces revenue when a dispute opens and restores it when the dispute closes will incorrectly restore revenue on a dispute you lost.

There are two different paths to a refund in Stripe, and one of them can surprise you. You can call the refunds API directly, or you can issue a credit note with a refund amount set, which makes Stripe generate the Refund automatically as a side effect. That second path means a Refund object can appear in your event stream with no preceding, standalone "refund" action anywhere in your own tooling — if your integration only reacts to refunds it believes it initiated itself, a credit-note-generated refund can slip past it entirely.

The event map, for reference

Stripe event What it actually tells you Common mistake
refund.created A refund has been initiated Treated as final, when the money isn't confirmed back yet
refund.failed A previously created refund did not go through Ignored entirely, so revenue stays under-reported
charge.refunded A Charge was refunded, in full or in part Read as if data.object were the Refund itself — it's the Charge
charge.dispute.funds_withdrawn Cash actually left your balance Confused with charge.dispute.created, which moves no money
charge.dispute.funds_reinstated Cash actually came back — only on a won dispute Confused with charge.dispute.closed, which fires on wins and losses alike

What you can do about it today

1. Give refund state its own property — don't overload "status"

Create a dedicated field (on the Deal, the invoice record, or a Contact/Company property, depending on where your team tracks revenue) such as refund_status or net_amount_after_refunds. Keep it separate from whatever field mirrors Stripe's paid/open/void status, so the two can disagree without one silently overwriting the other.

2. Subscribe to the full, correct event set

Wire a Stripe webhook endpoint (Dashboard → Developers → Webhooks) to refund.created, refund.updated, and refund.failed — not charge.refunded alone — plus charge.dispute.funds_withdrawn and charge.dispute.funds_reinstated for disputes, rather than only charge.dispute.created and .closed. Route these into whatever updates HubSpot: a script calling the HubSpot API, or a Zapier/Make automation using their native Stripe refund trigger paired with a HubSpot "Update record" action.

3. Reverse the actual amount, not a flag

Stripe supports partial refunds. Write the refunded amount, not a binary "refunded: yes/no" — a customer refunded $40 of a $400 invoice is a very different fact than a full reversal, and a flag loses that distinction immediately.

4. Reconcile on a schedule even without live wiring

If you're not ready to build the webhook path, export Stripe's Refunds list (Dashboard → Payments → Refunds, filterable by date range) monthly and cross-check it against whatever HubSpot shows as paid for the same period. This won't catch it same-day, but it catches it before it compounds across multiple close cycles.

5. Don't forget tax when refunding manually

If anyone on the team issues refunds by hand from the Stripe Dashboard, refunding only the pre-tax line amount under-refunds the customer — Stripe Tax automatically splits a refund between tax and net, and the safer pattern is to preview the credit note total first and refund that full figure, not the line item alone. This matters for what you update in HubSpot too: the refunded amount you record should match what Stripe actually returned, tax included, not the pre-tax number that was easiest to read off an invoice line.

Where this breaks down

No-code automations rarely handle failed refunds or dispute cash timing correctly, because the "obvious" triggers — charge.refunded, dispute created/closed — look like the right lifecycle and aren't. Getting this exactly right needs the specific events above, not the first one that appears in a trigger picker.

A paused, disabled, or quietly erroring automation stops updating HubSpot with no alert, and because the underlying Stripe invoice never displays a "refunded" state either, there is no visual cue in either system that something has drifted. It looks correct in both places while being wrong in one.

Credit notes' four-way split has no natural home in a simple property scheme. Most DIY setups collapse "reduced the invoice," "refunded in cash," "credited to balance" and "credited off-Stripe" back into a single number, which erases exactly the distinction a finance lead needs when asked whether cash actually left the account.

None of this scales past a handful of refunds a month without dedicated tracking, because there's no record of which HubSpot update came from which Stripe event — so a missed one is invisible until someone manually re-checks every invoice against Stripe directly.

Multi-currency refunds add a second layer of rounding risk. When a refund is issued in a currency different from your reporting currency, the amount you update in HubSpot depends on which exchange rate you use and when — re-converting at today's rate rather than the rate tied to the original transaction produces a refund figure that doesn't quite match the original charge, even though Stripe itself stayed internally consistent throughout.

What Trueness does about it

Trueness's free Drift Report already flags the most common version of this: an invoice that's refunded in Stripe but still reads "paid" in HubSpot, priced in dollars, the same day the report runs. Decomposing every credit note into its cash, balance and out-of-band components, and writing the corrected amount back onto a HubSpot record, are on our roadmap — not built yet, so today the report tells you what's wrong and you fix it in Stripe or HubSpot yourself.

Install free — get the report.