Stripe Connect + Multi-Card Splitting:
How the Quarvo Payment Graph Works
A walkthrough of the architecture behind splitting a single $2,400 customer transaction across 2–4 cards atomically, settling in full to one merchant, and shipping as a Stripe Connect platform — without the merchant changing processors. Real code, real failure modes, real timing.
The shape of the problem
A customer hits checkout with a $2,400 cart. Their best card has $1,800 of available credit. The straight charge fails. Their second card has $1,400 available. Their combined credit is $3,200 — enough — but no checkout in commerce is built to use both cards on one transaction.
The technical reason isn't capability. Card networks have supported partial authorizations for decades. The reason is coordination: there is no consumer-facing layer that schedules N partial authorizations against N cards, holds them simultaneously, and commits or rolls back atomically. That coordination layer is what we call the payment graph.
Quarvo is the payment graph. The rest of this post is how it's built.
Why Stripe Connect is the right substrate
The first design decision is what runs underneath the graph. Three options exist for a payment-orchestration layer:
- Become a money transmitter. Get state-by-state MT licenses in the US plus equivalents internationally, hold customer funds, settle to merchants ourselves. 18-month minimum to launch. Massive compliance overhead. Wrong tradeoff for the problem.
- Build directly against card networks. Become a card acquirer, work with banks, certify with Visa/Mastercard. 2-year minimum. Wrong tradeoff for the same reason.
- Run as a Stripe Connect platform. The merchant keeps their existing Stripe account. Quarvo orchestrates payments on the merchant's account using the platform model. Funds never touch Quarvo. Quarvo's fee comes through
application_fee_amount. Ships in months, not years.
Stripe Connect was built for marketplaces where a platform processes payments on behalf of sellers (Lyft, DoorDash, Substack). The pattern fits split-card recovery exactly: Quarvo is a "platform" whose value is the orchestration of multiple authorizations on behalf of a merchant. The merchant sees a single PaymentIntent on their dashboard and a single net deposit.
Quarvo runs as a Standard Connect platform with destination charges and an explicit application_fee_amount. Merchants keep ownership of their Stripe account, dispute responsibility, and settlement timing. Quarvo never custodies funds. Compliance-wise, this puts Quarvo in the same category as e-commerce SaaS that uses Stripe billing — not in the money-transmission category.
Architecture in one diagram
requires_captureSix conceptual nodes, three architectural layers: a thin coordinator (Quarvo's API), a vault (PCI-scoped customer card storage), and the merchant's existing Stripe Connect account where authorizations and capture happen. The coordinator orchestrates; Stripe processes; the merchant receives one consolidated settlement.
The two-phase commit, in detail
The hardest engineering problem in a multi-card transaction is partial failure. If you authorize Card A successfully and then Card B fails, the customer's available credit on Card A is still being held — they will be confused, and the merchant has nothing to capture against. The naive sequential approach is unsafe.
Quarvo uses a two-phase commit pattern, the same primitive that distributed databases use to ensure atomicity across multiple resource managers. Phase 1 prepares all authorizations. Phase 2 commits all of them, or none.
Phase 1 — Prepare
All N authorizations are launched in parallel as PaymentIntents in requires_capture state with capture_method: "manual". This holds the funds without charging the customer. The system waits for all N to either succeed or fail.
// Phase 1: launch N parallel authorizations on the merchant's Stripe account const authorizations = await Promise.allSettled( splits.map((split) => stripe.paymentIntents.create( { amount: split.amount, // in cents currency: 'usd', payment_method: split.cardToken, confirm: true, capture_method: 'manual', // HOLD, don't capture application_fee_amount: computeFee(split.amount), metadata: { quarvo_session_id: session.id, split_index: split.index, split_total: splits.length, }, }, { stripeAccount: merchant.stripeAccountId } ) ) ); const failures = authorizations.filter(a => a.status === 'rejected' || a.value.status !== 'requires_capture'); if (failures.length > 0) { await rollbackAll(authorizations); throw new SplitFailedError(failures); }
Two key properties: Promise.allSettled rather than Promise.all (we want partial success info, not early termination), and capture_method: 'manual' on every intent (so we can void cleanly if anything fails). The window during which authorizations are held is bounded by Stripe's auth-hold window (typically 7 days for most issuers, but Quarvo enforces a much shorter internal window — see below).
Phase 2 — Commit
If all N authorizations are in requires_capture state, the commit phase fires N captures in parallel. Each capture finalizes its respective authorization. The merchant's Stripe account ends up with N successful charges, all linked to the same Quarvo session via metadata.
// Phase 2: capture all authorizations in parallel const captures = await Promise.allSettled( authorizations.map(a => stripe.paymentIntents.capture(a.value.id, {}, { stripeAccount: merchant.stripeAccountId }) ) ); const capFailures = captures.filter(c => c.status === 'rejected'); if (capFailures.length > 0) { // rare — capture after successful auth almost never fails // when it does, refund any successful captures and surface the error await refundCapturedSplits(captures); throw new CaptureFailedError(capFailures); } return { sessionId: session.id, status: 'succeeded', captures };
The rollback path
If Phase 1 returns any failure, we cancel every successful authorization immediately. This is the most important code path in the system — partial holds are the worst possible state from a customer-trust perspective.
async function rollbackAll(authorizations) { // cancel all successful holds; failed ones don't need cancellation const toCancel = authorizations .filter(a => a.status === 'fulfilled' && a.value.status === 'requires_capture'); await Promise.allSettled( toCancel.map(a => stripe.paymentIntents.cancel(a.value.id, {}, { stripeAccount: a.value.transfer_data.destination }) ) ); // available credit on each card is restored within minutes // customer is shown which card failed and offered to swap }
The customer must never see a partial charge. If Phase 1 fails on any card, every successful authorization on every other card must be voided before the customer is shown the error. Quarvo's coordinator runs a strict 5-second timeout on Phase 1; if any card hasn't responded by then, it counts as a failure and triggers the rollback path.
Timing characteristics
/api/checkout/session with split intent.paymentIntents.create calls.paymentIntents.capture calls. Captures are fast — typically ~100ms each.checkout.session.completed webhook to merchant. UI shows success.Parallel execution is what makes this feel like a single-card checkout. The temptation in early designs is to authorize cards sequentially — "if Card 1 succeeds, then try Card 2" — but that adds a full round-trip per card and degrades the customer experience. Parallel + atomic commit is the only design that holds latency flat as N grows.
The reconciliation layer
Each split shows up on the merchant's Stripe dashboard as a separate charge — same customer name, same order ID via metadata, but distinct PaymentIntents. This is correct behavior (each card is a separate authorization), but it creates a reconciliation problem: the merchant's order management system expects one charge per order.
Quarvo emits a single checkout.session.completed webhook with all N charges grouped under one session. The merchant's webhook handler treats the session as one logical order and writes one row to its OMS. The N underlying Stripe charges are linked via the quarvo_session_id metadata field.
Refunds are also atomic. If a customer requests a refund post-fulfillment, the merchant calls POST /api/checkout/session/:id/refund with the amount. Quarvo distributes the refund proportionally across the N original cards — Card 1 receives 75% of the refund, Card 2 receives 25%, matching the original split — and emits a single checkout.session.refunded webhook. The merchant never has to think about which card to refund.
Read the full integration docs.
End-to-end API reference, drop-in JS snippets, server SDK examples, webhook signatures, and brand kit. Ships as a Stripe Connect platform — no replatforming required.
Open the integration guide →// PILOT · Q2 2026 · STRIPE · ADYEN AND BRAINTREE TO FOLLOW
What "graph" means in payments
The word graph is doing real work in the name "payment graph." It's not branding — it describes the data structure underneath the orchestration.
A traditional payment is a tuple: (customer_card, merchant_account, amount). A linear, single-edge relationship. There's nothing to coordinate.
A Quarvo payment is a graph. Nodes: a customer, N customer cards, one merchant. Edges: N partial payments connecting customer cards to the merchant, plus one logical "session" node tying them together. Every operation — authorize, capture, refund, void, dispute — has to traverse the graph atomically.
The coordinator's job is to maintain this graph as a consistent unit. The cards don't know about each other. The merchant doesn't see N transactions; it sees one session. The customer doesn't manage allocation themselves; the system does. The graph is what abstracts away the multi-party complexity.
Most payment infrastructure assumes one customer, one card, one merchant. Quarvo assumes one customer, N cards, one merchant — and treats the relationship as a graph the platform owns. That's the only architectural change. Everything else is conventional Stripe.
Edge cases the system has to handle
Three failure modes are interesting enough to call out:
Card-on-file expiration mid-session
If a customer's vaulted card expires between when the session is created and when Phase 1 fires, that card's PaymentIntent will fail. Quarvo's coordinator catches the specific Stripe error code (expired_card), prompts the customer to update the card in-flow, and retries that single split without affecting the others.
Issuer step-up authentication (3DS)
Some issuers will require 3DS challenge mid-authorization. When that happens on one of N cards in a split, Quarvo's coordinator pauses Phase 1 — the other N-1 authorizations have already completed and are held. The customer is shown the 3DS challenge; once they pass, Phase 1 resolves and Phase 2 fires normally. If they cancel or fail, we roll back.
Network partition between Phase 1 and Phase 2
The least likely but most operationally important failure: all N Phase 1 auths succeed, then the coordinator loses connectivity before Phase 2 fires. Quarvo's coordinator persists Phase 1 state to a transactional queue; on recovery, it resumes from the persisted state and either commits or rolls back. The Stripe-side authorizations remain valid for ~7 days (the issuer-side hold window), giving operations far more headroom than they need.
How does multi-card splitting work technically?
Why use Stripe Connect for split-card payments?
application_fee_amount.How does atomic commitment work across multiple authorizations?
requires_capture state. Phase 2 (commit): once all authorizations succeed, all are captured in parallel. If any authorization fails or times out during Phase 1, all successful authorizations are immediately voided via the Stripe cancel API. The merchant only ever sees a successful capture or no charge at all.