Checkout UI Components

@mvs/mvs-pay-ui

@mvs/mvs-pay-ui is the published React component library that wraps Hyperswitch’s HyperLoader.js Web SDK with MVS theming. It ships the browser-side surface of MVS Pay: embedded checkout, tokenize-only forms, hosted-link anchors, a saved-cards management widget, and an MVS-specific instant-payout destination picker.

The package lives at packages/mvs-pay-ui/. Its public surface is re-exported from packages/mvs-pay-ui/src/index.ts. It pairs with @mvs/mvs-pay-sdk on the server, which mints the client_secret (and the sdk_authorization token) that each widget needs before it can render.

This library is web-only by design. See ADR-009 · Web-only UI SDK. Hyperswitch ships native iOS, Android, React Native, and Flutter SDKs, but MVS Pay does not wrap them — that scope is deferred until a real mobile consumer (HealthOS mobile, an MVS native app, or a partner) materializes, at which point a separate plan ships the wrappers.

Package at a glance

PropertyValue
Package name@mvs/mvs-pay-ui
Version0.1.0 (publishConfig.access: restricted — private npm)
Module formatESM only (tsup src/index.ts --format esm --dts)
Peer depsreact@^19, react-dom@^19
Runtime deps@mvs/mvs-pay-sdk (workspace), react-plaid-link@4.1.1
Underlying SDKHyperswitch HyperLoader.js (loaded at runtime from a CDN script tag)
Publishable key envNEXT_PUBLIC_HYPERSWITCH_PUBLISHABLE_KEY

The five exported components and their HyperLoader backing primitives:

ComponentWrapsInit credentialPurpose
<MvsPayCheckout>widgets().create("payment")clientSecretEmbedded one-time checkout (charge now)
<MvsPayPaymentMethodForm>widgets().create("paymentMethods")clientSecretTokenize-only — save a method without charging
<MvsPaySavedCardsWidget>paymentMethodsManagementElements().create("paymentMethodsManagement")sdkAuthorizationCustomer self-service: list / select / delete saved methods
<MvsPayCheckoutLink>none (plain <a>)— (a hosted URL)No-JS share-link to a Hyperswitch hosted checkout
<MvsPayInstantPayoutPicker>none (MVS-native, optional Plaid Link)Pick a payout destination; optionally link a bank via Plaid

Note that only three of the five components actually touch HyperLoader. <MvsPayCheckoutLink> is a thin styled anchor, and <MvsPayInstantPayoutPicker> is an MVS-specific presentational component over PayoutMethod rows.

How tokenization keeps the host page out of PCI scope

The whole point of mounting HyperLoader rather than building card fields yourself is PCI scope reduction. Raw PAN / CVV never enter the host page’s DOM, JS heap, or network requests.

Concretely:

  1. Server mints the secret. Your backend calls mvsPay.payments.create(...) (or payment-method-sessions for the saved-cards widget) with the secret API key. The browser only ever receives a scoped client_secret / sdk_authorization token.
  2. The publishable key is public. Hyper(publishableKey) takes the publishable key (NEXT_PUBLIC_HYPERSWITCH_PUBLISHABLE_KEY) — it is safe to ship to the browser and intentionally has no charge-authorizing power on its own.
  3. Card fields render inside HyperLoader’s iframe. The host page only renders an empty container <div>; HyperLoader injects the sensitive fields into a cross-origin iframe it controls. Sensitive data is tokenized by Hyperswitch and never crosses back into the host page.

Because PAN/CVV stay inside HyperLoader’s iframe, an integrator that uses these components exclusively can typically self-assess against SAES-A rather than the heavier SAQ-D. This is the architectural reason MVS Pay does not ship raw card-field primitives.

The publishable key

All HyperLoader-backed components take a publishableKey: string prop. In the apps/mvs-pay Next.js app this is sourced from:

$# apps/mvs-pay/.env.example
$# Per-merchant publishable key surfaced to the browser via @mvs/mvs-pay-ui.
$NEXT_PUBLIC_HYPERSWITCH_PUBLISHABLE_KEY=

The NEXT_PUBLIC_ prefix is what makes it available to client-side code. It is per-merchant, so a multi-tenant host must resolve the right key for the active merchant before rendering. See Environment Variables for the complete list.

Do not put the Hyperswitch secret (server) key behind a NEXT_PUBLIC_ prefix. Only the publishable key is browser-safe. The secret key lives server-side in @mvs/mvs-pay-sdk and in mvs-pay-extensions.

The HyperLoader runtime loader

All HyperLoader-backed components share a single loader, loadHyperLoader(), defined in packages/mvs-pay-ui/src/hyper-loader.ts. It injects one <script> tag (id mvs-pay-hyperloader) so that multiple widgets on the same page reuse the same SDK instance.

Key behaviors to know when debugging:

  • Idempotent. Concurrent callers share one in-flight promise (pending). If window.Hyper is already present, it resolves immediately.
  • Browser-only. Called server-side (no window), it rejects with HyperLoader can only run in a browser environment. This is the failure you’ll see if a component is rendered during SSR without a client boundary.
  • Default script source: https://beta.hyperswitch.io/v1/HyperLoader.js. Override per component via the scriptSrc prop (e.g. a self-hosted bundle).
  • Rejection is not cached. A transient script-load failure nulls out the cached promise so the next render retries, rather than permanently breaking every widget until a full reload.
  • Late-listener guard. If the script tag already exists (HMR re-eval, StrictMode remount, a duplicate bundled copy), the loader settles from current state instead of attaching a listener that would never fire — it resolves if window.Hyper is present, otherwise rejects.

Every HyperLoader-backed component accepts customBackendUrl?: string, forwarded as Hyper(publishableKey, { customBackendUrl }). Use this when pointing the browser SDK at a self-hosted Hyperswitch deployment rather than the default backend. See ADR-001 · Self-host Hyperswitch.

<MvsPayCheckout>

Embedded one-time checkout. Mounts HyperLoader’s payment widget into a unique container (derived from useId(), so multiple checkouts can coexist on one page) and cleans up on unmount. Source: packages/mvs-pay-ui/src/components/MvsPayCheckout.tsx.

Obtain clientSecret server-side via mvsPay.payments.create(...) before rendering.

Props

PropTypeDefaultNotes
publishableKeystringRequired. Browser-safe Hyperswitch key.
clientSecretstringRequired. Minted server-side per payment.
appearanceHyperAppearancedefaultAppearance (MVS theme)Theme / variables / rules forwarded to widgets().
layout"tabs" | "accordion""tabs"Payment element layout.
returnUrlstringWhere Hyperswitch redirects after auth; also becomes confirmParams.return_url and wallets.walletReturnUrl.
customBackendUrlstringSelf-hosted Hyperswitch backend.
scriptSrcstringdefault CDNOverride HyperLoader.js URL.
googlePayMerchantIdstringForwarded to wallets.googlePay.merchantId. From NEXT_PUBLIC_GOOGLE_PAY_MERCHANT_ID.
renderSubmit(args: { submit, isReady }) => ReactNodeRender-prop for your own submit button.
className / styleOn the outer wrapper <div>.
onReady(widgets: HyperWidgets) => voidFired once the element mounts.
onSuccess(result: HyperConfirmResult) => voidFired on a non-error confirm.
onError(error: Error) => voidLoad, confirm, or not-ready errors.
paymentElementOptionsRecord<string, unknown>Escape hatch merged into create("payment", ...).

Submit & wallet composition

The component does not render its own submit button — you wire one through renderSubmit, which receives submit() and an isReady flag. submit() calls hyper.confirmPayment({ widgets, confirmParams, redirect: "if_required" }):

  • If the result has error, onError fires with result.error.message ?? "Payment failed".
  • Otherwise onSuccess fires. The raw HyperConfirmResult is always returned to the caller.
  • Calling submit() before the widget mounts throws "[mvs-pay-ui] checkout not ready" and fires onError.

The wallets block is composed in this order (last wins): walletReturnUrl from returnUrl, then googlePay.merchantId from googlePayMerchantId, then any wallets on your paymentElementOptions. So paymentElementOptions.wallets is the ultimate override. See Wallets & Click to Pay.

Without googlePayMerchantId, Google Pay still works in test mode, but production processing requires the merchant ID issued by the Google Pay & Wallet Console. Apple Pay additionally needs domain association provisioning — see the Wallet Validation runbook.

<MvsPayPaymentMethodForm>

Tokenize-only flow: save a payment method without immediately charging. Wraps HyperLoader’s paymentMethods widget. Use it to save a card on a customer profile, capture a payout method, or pre-authorize a card-on-file agreement. Source: packages/mvs-pay-ui/src/components/MvsPayPaymentMethodForm.tsx.

Props

PropTypeDefaultNotes
publishableKeystringRequired.
clientSecretstringRequired. Minted server-side.
appearanceHyperAppearanceminimal (borderRadius: "8px")Note: a lighter default than <MvsPayCheckout>.
layout"tabs" | "accordion""tabs"
customBackendUrlstring
scriptSrcstringdefault CDN
className / style
onReady(widgets: HyperWidgets) => void
onError(error: Error) => void
paymentMethodsOptionsRecord<string, unknown>Spread into create("paymentMethods", ...).

This component has no submit / confirm path and no onSuccess callback — it only mounts the tokenization widget and exposes onReady/onError. The host is responsible for driving the save/attach flow against the server (the widget tokenizes; persisting the result is your backend’s job).

<MvsPaySavedCardsWidget>

Customer-facing payment-method management. Mounts HyperLoader’s paymentMethodsManagement widget so a logged-in customer can list, select, and delete their saved methods (cards, wallets, bank-account on file). Source: packages/mvs-pay-ui/src/components/MvsPaySavedCardsWidget.tsx.

Unlike checkout, this widget is initialised with an sdkAuthorization token (not a per-payment clientSecret). The token is minted server-side via the extensions-service proxy POST /v1/payment-method-sessions (apps/mvs-pay-extensions/src/routes/payment-method-sessions.ts), which forwards to Hyperswitch’s endpoint of the same name and scopes the session to a business profile via x-profile-id. See Extensions Service and the Connector Onboarding runbook (M27).

Props

PropTypeDefaultNotes
publishableKeystringRequired.
sdkAuthorizationstringRequired. Minted via /v1/payment-method-sessions.
appearanceHyperAppearancedefaultAppearance (MVS theme)
customBackendUrlstring
scriptSrcstringdefault CDN
localestringe.g. "en", "es"; forwarded to HyperLoader.
onCardSelected(paymentMethodId: string) => voidFrom the paymentMethodSelected event.
onCardDeleted(paymentMethodId: string) => voidFrom the paymentMethodDeleted event.
onError(error: Error) => void
paymentMethodsManagementOptionsRecord<string, unknown>Passed to create("paymentMethodsManagement", ...).
className / style

This widget requires a newer HyperLoader. If hyper.paymentMethodsManagementElements is not a function, the component throws and fires onError: "HyperLoader does not expose paymentMethodsManagementElements; upgrade to v1.123.1+". The per-element .on(...) event binding is likewise guarded — older HyperLoader builds without it silently skip the onCardSelected / onCardDeleted callbacks. See the Hyperswitch Upgrade runbook.

The event payloads from HyperLoader are loosely typed (unknown[]); the component defensively extracts the id from any of paymentMethodId, payment_method_id, or id, or a bare string argument.

A thin styled anchor that opens a Hyperswitch hosted-checkout URL. Use it for a no-JS share-link flow rather than embedding a widget. It does not load HyperLoader. Source: packages/mvs-pay-ui/src/components/MvsPayCheckoutLink.tsx.

Props

PropTypeDefaultNotes
hrefstringRequired. The hosted-checkout URL.
targetAnchorHTMLAttributes["target"]"_blank"Override e.g. for iframe embeds.
relstring"noopener noreferrer"
childrenReactNode"Continue to checkout"Link text.
...restanchor attrsAny other <a> attribute except href / target.
1import { MvsPayCheckoutLink } from "@mvs/mvs-pay-ui";
2
3<MvsPayCheckoutLink href={paymentLink.url}>
4 Pay your invoice
5</MvsPayCheckoutLink>

Generating the hosted URL is a server concern — see Payment Links.

<MvsPayInstantPayoutPicker>

MVS-specific, not a HyperLoader widget. Renders a radiogroup of saved payout destinations (PayoutMethod rows — bank accounts / debit cards) and emits the selected id via onSelect. It is purely presentational: fetching the methods is the caller’s job (use mvsPay.payoutMethods.list() or a server action that proxies it). Source: packages/mvs-pay-ui/src/components/MvsPayInstantPayoutPicker.tsx. See Payouts & Instant Payouts.

Props

PropTypeDefaultNotes
payoutMethodsMvsPayInstantPayoutPickerOption[]Required. Rows to render.
selectedIdstring | nullCurrently selected id.
onSelect(id, method) => voidRequired. Fired on row click.
onAddNewAccount() => voidLegacy callback; ignored if plaid is supplied.
plaidMvsPayInstantPayoutPickerPlaidConfigOpt-in Plaid Link flow.
emptyStateReactNode”No payout methods on file yet.”Shown when the list is empty.
renderItem(method, { selected, disabled }) => ReactNodeOverride row layout.
className / style

Each MvsPayInstantPayoutPickerOption has id, optional type, brand, lastFour, name, verified, disabled (plus arbitrary extras). The default row renders brand/type ····lastFour, an optional name, and a Verified/Unverified badge. Rows use the ARIA role="radio" pattern inside a role="radiogroup" (a native <input type="radio"> can’t host the badge children).

When the plaid prop is supplied, the “Link a bank account” / “Add another account” buttons drive react-plaid-link’s usePlaidLink hook instead of calling onAddNewAccount:

MvsPayInstantPayoutPickerPlaidConfig:

FieldTypeDefaultNotes
linkTokenEndpointstring/api/plaid/link-tokenPOSTed to mint a link_token.
exchangeEndpointstring/api/plaid/exchangePOSTed { public_token, account_id }, expects { payoutMethodId }.
onLinked(result: { payoutMethodId }) => void | Promise<void>Host refreshes its list here; awaited before re-enabling.
onExit() => voidUser closed Plaid Link without finishing.
onError(err: Error) => voidLink-token fetch or exchange failed.

Both fetches use credentials: "include". The button label reflects state (“Preparing Plaid…”, “Linking…”). The link token is fetched on demand (not on mount); the usePlaidLink hook only opens once a token loads. End-to-end provisioning is documented in the Plaid Link runbook.

Usage example: embedded checkout

A minimal Next.js (App Router) integration. The server mints the secret; the client renders the widget.

1

Server: create the payment, return the client secret

1// app/checkout/page.tsx (Server Component)
2import { mvsPay } from "../../../../lib/mvs-pay"; // your @mvs/mvs-pay-sdk instance
3import { CheckoutClient } from "./checkout-client";
4
5export default async function CheckoutPage() {
6 const payment = await mvsPay.payments.create({
7 amount: 4200,
8 currency: "USD",
9 // ...customer, metadata, etc.
10 });
11
12 return (
13 <CheckoutClient
14 publishableKey={process.env.NEXT_PUBLIC_HYPERSWITCH_PUBLISHABLE_KEY!}
15 clientSecret={payment.clientSecret}
16 />
17 );
18}
2

Client: mount the widget and wire a submit button

1"use client";
2import { useState } from "react";
3import { MvsPayCheckout } from "@mvs/mvs-pay-ui";
4
5export function CheckoutClient({
6 publishableKey,
7 clientSecret,
8}: {
9 publishableKey: string;
10 clientSecret: string;
11}) {
12 const [submitting, setSubmitting] = useState(false);
13
14 return (
15 <MvsPayCheckout
16 publishableKey={publishableKey}
17 clientSecret={clientSecret}
18 layout="tabs"
19 returnUrl="https://pay.mvspay.io/checkout/complete"
20 googlePayMerchantId={process.env.NEXT_PUBLIC_GOOGLE_PAY_MERCHANT_ID}
21 onSuccess={(result) => console.log("paid", result.status)}
22 onError={(err) => console.error(err.message)}
23 renderSubmit={({ submit, isReady }) => (
24 <button
25 type="button"
26 disabled={!isReady || submitting}
27 onClick={async () => {
28 setSubmitting(true);
29 try {
30 await submit();
31 } finally {
32 setSubmitting(false);
33 }
34 }}
35 >
36 {submitting ? "Processing…" : "Pay $42.00"}
37 </button>
38 )}
39 />
40 );
41}

All HyperLoader-backed components must render in a client boundary ("use client" in Next.js). loadHyperLoader() rejects with “HyperLoader can only run in a browser environment” if invoked during SSR.

Type exports

packages/mvs-pay-ui/src/index.ts also re-exports the HyperLoader type bindings and the loader itself, so you can type custom escape-hatch options or call HyperLoader directly:

1import {
2 loadHyperLoader,
3 type HyperAppearance,
4 type HyperConfirmResult,
5 type HyperConfirmPaymentInput,
6 type HyperInstance,
7 type HyperWidgets,
8 type HyperPaymentElementOptions,
9 type HyperPaymentMethodsManagementOptions,
10 type LoadHyperLoaderOptions,
11} from "@mvs/mvs-pay-ui";

These are hand-written bindings over HyperLoader (packages/mvs-pay-ui/src/hyper-loader.ts), not generated from an upstream package — most option bags carry an open [k: string]: unknown index so you can pass through fields the bindings don’t enumerate.

Where to go next

For request/response shapes of the endpoints that back these widgets, see the API Reference tab.