Healthcare platforms collect money from two places. Insurers pay claims, and patients pay copays, coinsurance, deductibles, and self-pay fees. The claims side is a well-documented problem with an established rail. The patient side looks deceptively like ordinary e-commerce — right up until you realise the money is not yours.
You are not selling a product. You are a platform routing payments to independent practices, each with its own bank account, its own tax identity and its own relationship with the patient. That changes the architecture completely.
This guide covers building patient payments and provider payouts on Stripe Connect for a multi-tenant healthcare platform: the money flow, the onboarding gate, webhook-driven ledger posting, and the failure modes that quietly cost money.
Why standard Stripe is the wrong shape
A single Stripe account works when you are the merchant of record. On a healthcare platform, you usually are not. The practice delivered the care, the practice has the payer contracts, and the practice must receive the money.
Taking patient payments into your own account and paying practices later creates three problems: you are holding funds you do not own, you have taken on money-transmission questions you do not want, and reconciliation becomes a manual exercise in matching your bank statement to fifty practices' expectations.
Connect solves this by giving each practice its own account that you operate on behalf of. Money flows to them; you retain a platform fee. You never hold their funds.
Onboarding is a gate, not a form
The most common design mistake is treating practice onboarding as a setup step users can skip. It is a hard gate: until a practice's account can accept charges, every patient payment attempt will fail, and it will fail at the worst moment — with the patient's card in hand.
Model onboarding as explicit states, and check readiness before you ever render a payment button:
GET /payments/readiness?organizationId=123
{
"connected": true,
"chargesEnabled": false,
"payoutsEnabled": false,
"requirementsDue": ["individual.verification.document"],
"canAcceptPayments": false,
"reason": "Identity verification outstanding with Stripe"
}Three rules that save a lot of support load:
- Cache the account status, but refresh it on a webhook. Calling Stripe on every page load is slow and rate-limited. Listen to
account.updatedand update your cached view when their status genuinely changes. - Surface the outstanding requirements verbatim. "Payments unavailable" generates a support ticket. "Stripe needs a photo ID for the account owner" gets resolved by the practice.
- Handle disconnection. A practice can revoke your access from their own Stripe dashboard at any time. The
account.application.deauthorizedevent is how you find out. Without it, your platform believes it can charge for an account that no longer exists.
The webhook is the only writer
This is the single most important architectural decision in the whole integration.
When a patient completes a payment, your application receives a browser redirect back from Stripe. It is tempting to post the payment to your ledger there — the user is right in front of you, and it feels immediate.
Do not. The redirect is unreliable in ways that matter for financial data. The patient closes the tab. Their connection drops. Mobile Safari backgrounds the page. The payment succeeds, and your system never hears about it — money taken, balance unchanged, patient invoiced again next month.
Treat the redirect as a UI event only — show a confirmation, poll for state. The webhook is the authoritative writer.
The events worth handling on a healthcare platform:
| Event | Why it matters |
|---|---|
checkout.session.completed | The payment succeeded. Post it. |
checkout.session.expired | Release any hold you placed on the balance. |
payment_intent.succeeded | Confirmation for non-checkout flows. |
payment_intent.payment_failed | Drives retry and dunning. |
charge.refunded | Reverse the posting. Refunds must flow through the same ledger. |
charge.dispute.created | A chargeback. Flag the account before more care is delivered. |
charge.dispute.funds_withdrawn | The money has actually left. Reverse the earnings. |
charge.dispute.closed | Resolution — reinstate or confirm the loss. |
account.updated | Onboarding progressed or requirements changed. |
account.application.deauthorized | The practice disconnected you. |
Disputes are the ones teams skip, and they are the ones that create silent losses. A chargeback where you never reversed the provider earnings means you have paid a clinician for money you no longer hold.
Idempotency is not optional
Stripe retries webhooks. Your endpoint will receive the same event more than once — that is normal operation, not an error. Without a guard, a retry posts the payment twice.
# Store the Stripe event id on the ledger row and check it first.
if ledger.exists(stripe_event_id=event["id"]):
return 200 # already applied - acknowledge and stop
with transaction():
post_payment(event)
ledger.record(stripe_event_id=event["id"])Two more essentials: verify the signature on every request before parsing the body, and return 200 quickly. Do the heavy work asynchronously — a slow endpoint causes Stripe to retry, which causes more load, which makes it slower.
Posting patient money to the same ledger as insurance money
Here is where healthcare diverges sharply from ordinary payments, and where most platforms end up with two sets of books.
A single visit can be paid by an insurer and a patient, weeks apart, in any order. Provider compensation, patient balance, and practice revenue all need to reflect both. If patient payments live in a payments table and insurance payments live in a remittance table, and each screen sums a different one, your numbers will disagree, and nobody will be able to say which is right.
Both must post to one financial ledger, with the source recorded rather than the destination differing:
ledger.post(
claim_id = 4471,
amount = 30.00,
source = "patient_card", # vs "insurance_era"
stripe_event_id= "evt_1P...",
occurred_at = event_timestamp, # NOT wall clock
)Two details that matter more than they look:
- Use the event's timestamp, not your server's. Webhooks arrive late and out of order. If you stamp arrival time, your ledger ordering and your financial reality diverge.
- Keep assigned and collected separate. A remittance assigning $30 of patient responsibility is not the same as the patient paying $30. Provider earnings should follow collected money. Conflating them pays clinicians on revenue that has not arrived.
If you are building the insurance side of this too, our guide to clearinghouse integration covers how remittance data reaches the same ledger.
Cards on file, holds and auto-pay
Most healthcare platforms eventually need to charge a patient after the visit — the exact amount is unknown until the insurer adjudicates, which is weeks later.
That means storing a payment method and charging it later, which brings its own requirements:
- Capture explicit authorisation to charge later, and store when and how it was given. This is a consent record, not a checkbox.
- Store the customer against the practice's connected account, not globally. A patient seen by two practices on your platform has two relationships, and one practice must never be able to charge using another's stored method.
- Cap and bound automatic charges. A per-charge ceiling and a reason code. An uncapped auto-charge against an incorrectly adjudicated claim is a serious patient-trust incident.
- Hold rather than charge when the amount is disputed. A hold that can be released is recoverable; a charge that must be refunded is not, in reputational terms.
Failed payments are a revenue problem, not an error case
Cards expire and fail routinely. Care has already been delivered, so a failed charge is not a lost sale — it is an unpaid receivable for work already done.
Treat it as a recoverable state with a retry schedule, a notification to the patient, a cap on attempts, and an exit into human follow-up. Handling a failed charge once and giving up writes off money you have already earned.
Refunds and reversals
Refunds are not negative payments. They are events in their own right and must flow through every downstream calculation:
- The patient balance increases again
- Provider earnings reduce — the clinician was credited for money now returned
- The ledger records a reversal, never an edit of the original entry
That last point matters. Financial history is append-only. Editing the original payment to "fix" a refund destroys the audit trail, and the audit trail is what you need when a payer, a practice, or an auditor asks what happened eighteen months from now.
What to build first
- The onboarding gate — states, cached status, readiness endpoint. Nothing else works without it.
- The webhook endpoint — signature verification, idempotency, fast acknowledgement.
- The ledger — one place both patient and insurance money post to.
- One payment path end to end — a single copay, from card to ledger to provider earnings.
- Refunds and disputes, before volume rather than after the first chargeback.
- Cards on file and retries, once the basics are provably correct.
Steps 1 to 3 are most of the work, and none of them are glamorous. They are also the ones that determine whether your finance team can trust the system a year from now.
The short version
Patient payments on a healthcare platform are not e-commerce. The money belongs to the practice, arrives alongside insurance money for the same service, and must land in one ledger that both sides agree on. Gate on onboarding, let the webhook be the only writer, keep history append-only, and treat failed payments as revenue to recover rather than errors to log.
Building patient payments or provider payouts into a healthcare platform? Our healthcare product engineering team builds these rails, and our revenue cycle practice connects them to the claims side. Talk to our team about your payment architecture.



