Idempotent split-cart payments with Stripe
Author: Adam Hood
Published: May 27, 2026

Summary
How we built idempotent split-cart checkouts on Stripe so retries and network failures never result in a duplicate or partial charge.
A split-cart checkout means a single customer order can be paid for in more than one way: part of it charged to an HSA/FSA card for the eligible portion, and the rest charged to a standard card for everything else. That sounds straightforward until you consider what happens when a network call times out halfway through, a customer double-taps "place order," or a retry from our own client fires seconds after the first request already succeeded. Getting this wrong means either a duplicate charge or a customer who paid for one segment of the order but not the other.
The problem with split-cart checkouts
A normal, single-payment checkout is idempotent almost by accident: one charge, one payment intent, one outcome. Split-cart breaks that assumption because a single logical order now maps to two or more Stripe payment intents that both have to succeed, or the whole order has to roll back cleanly. If the HSA/FSA charge succeeds but the standard card charge fails, we can't just leave the order half-paid — the customer needs a consistent, predictable outcome regardless of which network call happened to fail.
Designing for idempotency
Stripe supports idempotency keys natively, but the interesting engineering problem isn't calling the API correctly — it's deciding what the key represents and how our own retry logic composes with Stripe's guarantees.
Idempotency keys per cart segment
We generate one idempotency key per cart segment, derived deterministically from the order ID and the segment type, rather than a random UUID per request:
function segmentIdempotencyKey(orderId: string, segment: "hsa_fsa" | "standard"): string {
return `order_${orderId}_segment_${segment}`;
}
async function chargeSegment(orderId: string, segment: Segment) {
const key = segmentIdempotencyKey(orderId, segment.type);
return stripe.paymentIntents.create(
{
amount: segment.amountCents,
currency: "usd",
payment_method: segment.paymentMethodId,
confirm: true,
},
{ idempotencyKey: key },
);
}
Because the key is deterministic, any retry — whether triggered by our job queue, a client-side resubmission, or a manual replay during an incident — collapses onto the same Stripe payment intent instead of creating a second one. This is the difference between "safe to retry" and "safe to retry without thinking about it."
Handling partial failures
Even with idempotent charges, a split-cart order can still end up in a partial state: one segment charged, the other not. We treat the two-segment charge as a saga rather than a single transaction. Each order carries a state machine — pending, hsa_fsa_charged, fully_charged, failed_reversing — and a reconciliation worker checks orders stuck in an intermediate state against Stripe's actual payment intent status rather than trusting our own database as the source of truth. If the standard-card segment fails after the HSA/FSA segment succeeds, we automatically void the successful charge rather than leaving the customer in a state where they paid for part of an order they never received.
What we learned in production
A few failure modes only became obvious after real traffic hit the system:
- Retries from the client and retries from our background job queue would sometimes fire within milliseconds of each other, both using the same deterministic key — Stripe correctly de-duplicated these, but our own logging made it look like two separate attempts until we added request-level tracing.
- Idempotency keys need a bounded lifetime tied to the order, not the API request, or you risk silently reusing a key across a customer's next unrelated order.
- Voiding a successful segment charge is not instant, so the order state machine needs a distinct
reversingstate rather than jumping straight fromhsa_fsa_chargedtofailed— otherwise support tooling shows an order as failed when a refund is still in flight.
None of this required exotic infrastructure. It required treating "one order, two charges" as a distributed transaction problem from the start, instead of bolting idempotency on after the first duplicate-charge incident.
Related articles

How we classify HSA/FSA eligibility at scale
How our classification pipeline scores products for HSA/FSA eligibility, from rule-based pre-filters to a confidence-weighted model.
By Adam Hood

An event-driven provider queue built on Celery
Why we rebuilt our practitioner matching queue as an event-driven system on Celery, and how it handles retries and backpressure.
By Adam Hood
Get engineering updates
New engineering writing from the Truemed team, delivered when we publish.