PathSessionTriggerCVV / 3DS?
On-session createOn-sessionUser purchases in browserYes — customer present, SCA required
On-session updateOn-sessionUser upgrades/downgrades in browserYes — customer present, SCA required
Off-session createOff-sessionAPI / catalog / queue-initiatedNo — references prior on-session auth
Off-session updateOff-sessionRenewal, scheduled change, admin actionNo — references prior on-session auth

The on/off-session distinction is a card-network concept, not just a UX one. Off-session charges look indistinguishable from fraud unless a prior on-session authorization exists for the same merchant and card. Stripe references that first successful on-session transaction to prove legitimacy. A naive off-session attempt without prior setup will be declined by the issuer.

Every payment — regardless of path — flows through six steps. The first four steps write nothing to our database. Only step five crosses the commitment boundary.

1. Intent — subscription change requested 2. Validate — plans, quantities, prices checked 3. PaymentIntent — created in Stripe (cancellable) 4. Confirm — payment confirmed by customer or off-session 5. Commit — our DB written, Stripe subscription created 6. Reconcile — invoice.paid webhook clears dunning state
The Green Zone

Steps 1–4 are the Green Zone: no state has been committed to our database. If payment fails at any point before step 5, nothing needs to be rolled back. The PaymentIntent is simply cancelled or abandoned. This is the key safety property that PDE establishes on all five payment paths.

PM TypeProcessorCollection MethodNotes
Credit CardStripecharge_automaticallypm_XXXXXX token. CVV optional on-session. Vaultable.
Apple PayStripecharge_automaticallyWallet token pm_XXXXXX. On-session only for initial auth.
Google PayStripecharge_automaticallyWallet token pm_XXXXXX. On-session only for initial auth.
ACH / SEPAStripecharge_automaticallyBank debit. Mandate required. Supports on-session.
PayPalBraintreesend_invoiceAlways off-session. Braintree Billing Agreement. The outlier.

PayPal is the outlier in every dimension. Stripe manages the subscription lifecycle, but Braintree executes the actual charge via a GraphQL API call to ChargePayPalAccount. The result is reconciled back to the Stripe invoice via MarkInvoicePaidOutOfBand(). This dual-processor burden — Stripe for subscription state, Braintree for money movement — is the root of PayPal's unique risks: double-charge window (30s Braintree dedup), tax timing gaps (5s/10s Avalara wait), and no automatic retry on failure. All other payment methods use a single processor.

PDE (Payment Determinism Engine) is the migration from commit-then-rollback to confirm-before-commit. The old model committed entitlements and state to our database first, then attempted payment, then rolled back on failure. Rollbacks were incomplete — ~200/day — leaving ~35 uncollected invoices daily. The new model holds all state in Stripe (as a pending PaymentIntent) until payment is confirmed, then commits atomically.

ParameterUsed ForEffect
default_incompleteOn-session createsSubscription created in incomplete state; activates on payment
pending_if_incompleteOn-session + off-session updatesUpdate held pending; applied on payment (not on failure)
pay_immediatelyAll paths (must flip to true)Eliminates separate collection step; payment inline with change
setup_future_usage: off_sessionAll PaymentIntentsStripe auto-vaults PM on customer after payment succeeds

When Stripe returns requires_action on a PaymentIntent, the customer must complete a 3DS challenge. On-session, this is handled by the frontend: the API response includes a client_secret, which the Stripe.js unified checkout component uses to render the 3DS UI inline. No page navigation required.

Off-session 3DS (rare, but possible for SCA-regulated regions) is handled server-side via a Temporal workflow. The workflow polls the PaymentIntent status with a 24-hour timeout. Status mapping: succeeded → commit and provision; requires_action → return client secret to caller; requires_payment_method → mark failed, trigger dunning. The Temporal workflow resolves the concern about incomplete webhook handling for off-session paths.

Three out-of-band paths exist for payments outside Stripe automatic collection: wire transfers (enterprise, manually reconciled via NinjaPanel), PayPal renewals (Braintree charge followed by MarkInvoicePaidOutOfBand), and check deposits (support-only, manual). All converge on the PaidOutOfBand API.