PaymentsHosted checkout-as-a-service

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:

  1. The entity exists and can collect (require + canCollect).
  2. 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.
  3. The intent is well-formed (a planKey, a positive amountCents, 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

HostedEmbedded
ReturnsA redirect url on pay.loop.healthAn embedToken a Loop component exchanges
Card field rendered byLoop’s hosted pageA Loop-owned embeddable component
App responsibilityRedirect the userMount the Loop component
PCI scope of the appNoneNone

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

Source

  • services/payments/src/orchestration/payment-session.ts
  • services/payments/tests/unit/orchestration/payment-session.test.ts
  • ADR-0093 (cross-system #3 — clean entrypoint + handoff, LOO-2222 / LOO-2227)