Hosted checkout-as-a-service
What this is: the front door of the payments service — apps declare what they want charged and the service builds the payment step. Apps never build checkout UI or touch a card.
Who it’s for: any team integrating checkout, a reorder, a subscription start, or a one-time charge from any Loop brand.
What to read next: PCI posture · Routing & failover · Architecture.
Status. The contract + a fulfillment-gate skeleton are built
(PaymentSessionService, LOO-2222): it validates the config against the entity
registry, normalizes the intent, and mints a pending session with placeholder
URLs. The hosted surface that actually renders the session and performs the
charge is planned (LOO-2227); the charge it will perform is the
PaymentOrchestrator. So integrators can
round-trip the contract today, but no money moves.
The idea: declare, don’t build
Every legacy caller hand-rolled a Stripe-shaped checkout session, threaded brand/entity cosmetically (one hardcoded Stripe account), and left the settlement boundary implicit. Hosted checkout-as-a-service inverts that: the caller POSTs a declarative config and the service owns everything from the card field to settlement.
The config-push contract
POST /v1/payment-sessions takes a declarative config: who pays, which brand + entity, what intent, hosted vs embedded, plus optional theme and redirects.
From services/payments/src/orchestration/payment-session.ts:
export type CreatePaymentConfig = {
/** Canonical customer identity (loopUserId). */
customer: string;
/** Presentation brand. */
brand: string;
/** Collecting legal entity (LLC) — threaded into MID routing. */
entity: string;
intent: PaymentIntentInput;
mode: PaymentMode; // "hosted" | "embedded"
/** Product category for eligibility, when the caller knows it. */
product?: ProductCategory;
theme?: Record<string, string>;
redirects?: { successUrl: string; cancelUrl: string };
metadata?: Record<string, string>;
};A worked example:
{
"customer": "loop_user_01HZX…",
"brand": "loop-health",
"entity": "loop_bio_labs",
"intent": { "type": "line_items", "lineItems": [{ "providerPriceRef": "price_bpc157", "quantity": 1 }] },
"mode": "hosted",
"product": "ruo_compounds",
"theme": { "accent": "#0aa" },
"redirects": { "successUrl": "https://loop.health/thanks", "cancelUrl": "https://loop.health/cart" }
}The intent unifies the three legacy checkout paths
export type PaymentIntentInput =
| { type: "subscription_plan"; planKey: string }
| { type: "line_items"; lineItems: CheckoutLineItemInput[] }
| { type: "amount"; amountCents: number; currency?: string };One contract covers a subscription start, a catalog cart, and an ad-hoc amount (e.g. a one-time reorder without a catalog entry). Line items accept either an existing providerPriceRef or ad-hoc priceData.
What the service does on create
PaymentSessionService.create validates before it mints:
- The entity exists and can collect (
require+canCollect). - If the caller names a
product, it must be eligible for the entity — the laundering guard runs here too, up front, in addition to charge time. - The intent is well-formed (a
planKey, a positiveamountCents, or ≥1 valid line item).
Then it mints a pending session, hiding provider shape and threading brand + entity for downstream MID routing.
const sessionId = `ps_${this.newId()}`;
// hosted → a redirect url on pay.loop.health; embedded → an embed token.
// The skeleton returns placeholders so the contract round-trips for integrators
// today; the hosted surface (LOO-2227) fills the real url / embed_token.Hosted vs embedded
| Hosted | Embedded | |
|---|---|---|
| Returns | A redirect url on pay.loop.health | An embedToken a Loop component exchanges |
| Card field rendered by | Loop’s hosted page | A Loop-owned embeddable component |
| App responsibility | Redirect the user | Mount the Loop component |
| PCI scope of the app | None | None |
Both keep the card inside a Loop-controlled surface. The difference is presentation, not scope.
Why PCI collapses to ONE CDE
This is the structural payoff. The card only ever meets the Loop-hosted surface plus the vault — it never enters the consuming app’s process, and it never enters the payments service process (it is tokenized client-side against the vault).
- The consuming app is out of scope — it sends a config, never a PAN.
- The payments service logic is out of scope — the PAN is tokenized client-side; the service handles a token.
- Only the hosted surface + vault are in scope — one CDE for the whole ecosystem.
See PCI posture for how this maps to SAQ A.
The settled-vs-pending fulfillment gate
The settlement boundary is explicit, not implicit. A session is pending until money lands, then settled exactly once. The gate is idempotent — a duplicated settlement signal (webhook redelivery, retry) fulfills only on the first call, so a customer is never provisioned twice.
export interface FulfillmentGate {
/** "first" ⇒ run fulfillment now; "duplicate" ⇒ already fulfilled, skip. */
settle(sessionId: string): Promise<"first" | "duplicate">;
state(sessionId: string): Promise<PaymentSessionStatus>;
}This is the handoff contract every consumer uses: POST a config, then fulfill exactly once on the "first" settlement. The foundation ships an in-memory implementation; production swaps a DB / event-sourced impl behind the same interface. (Money lands asynchronously via payment.charged.v1 — see Events and EventBridge.)
The boundary: payment step, NOT cart/catalog/shipping
Hosted checkout-as-a-service owns the payment step — and only the payment step. It is not a cart, a product catalog, a shipping calculator, or a tax engine. The consuming app still owns the cart and catalog; it hands the resolved intent to the payment session. Pricing/discount resolution is a separate seam payments calls — payments stays about money, not pricing.
This keeps the responsibility line clean: apps own commerce; payments owns moving the money for it. The discount/price-resolution seam that composes Stripe coupons, membership pricing, Loop Cash, BigCommerce group discounts, and affiliate commission into one final price for (customer, cart, brand, entity) is planned (ADR-0093 cross-system #2).
See also
- PCI posture — one CDE = SAQ A
- Entity model — the
entitythe config threads - Routing & failover — what the hosted surface invokes to charge
- Subscriptions — the
subscription_planintent
Source
services/payments/src/orchestration/payment-session.tsservices/payments/tests/unit/orchestration/payment-session.test.ts- ADR-0093 (cross-system #3 — clean entrypoint + handoff, LOO-2222 / LOO-2227)