The SDK

@mvs/mvs-pay-sdk

@mvs/mvs-pay-sdk is the typed Node HTTP client for the payments domain. It is the only sanctioned way an MVS app talks to payments — consumers never touch the Hyperswitch admin API or the mvs-pay-extensions database directly. The package wraps the HTTP surface exposed by apps/mvs-pay-extensions (which itself wraps Hyperswitch and persists the MVS extension rows) behind a small, namespaced client.

Source of truth for everything on this page lives in packages/mvs-pay-sdk/src. The package header (src/index.ts) names its consumers explicitly: the apps/mvs-pay UI itself, healthos, and mvs-c2. See docs/architecture/hyperswitch-pivot.md for the full pivot plan.

Where it sits

The SDK is a thin transport-and-types layer. It does not own business logic, talk to a database, or hold a connection pool — every method is a typed fetch against a route group on the extensions service.

Internally the layering is: MvsPayClient → one HttpClient (src/http.ts) → namespace classes (src/namespaces/*) that call http.request<T>(path, opts). All transport concerns (header injection, retries, error mapping) live in the single HttpClient; the namespaces are pure path/type wrappers.

Constructing MvsPayClient

Construct one client per consumer process — or, for org-scoped request handlers, per request — and reuse it. Construction is cheap (field assignments only, no I/O), so per-request instantiation is fine; the underlying fetch and secret resolution are shared.

1import { MvsPayClient } from "@mvs/mvs-pay-sdk";
2
3const mvsPay = new MvsPayClient({
4 baseUrl: "https://mvs-pay-extensions.payments.internal",
5 internalSecret: process.env.MVS_PAY_EXTENSIONS_INTERNAL_SECRET!,
6 orgId: "org_abc123",
7});

MvsPayClientConfig

FieldTypeRequiredDefaultNotes
baseUrlstringyesBase URL of the extensions service. Trailing slashes are stripped in HttpClient.
internalSecretstringyesService-to-service secret, sent as the x-internal-secret header.
orgIdstringone of orgId/orgSlugSent as x-org-id.
orgSlugstringone of orgId/orgSlugSent as x-org-slug.
fetchFetchLikenoglobalThis.fetchOverride for tests or non-global-fetch runtimes.
retriesnumberno3Retries on 5xx / network errors.
retryBaseDelayMsnumberno200Base delay for exponential backoff.

The constructor validates eagerly in HttpClient (src/http.ts): it throws a plain Error if baseUrl or internalSecret is missing, or if neither orgId nor orgSlug is provided.

There is one exception to the “org is required” rule, and it is enforced by the consumer, not the SDK: apps/mvs-pay/lib/mvs-pay-client.ts keeps a single memoized system client built with no org headers for system-level routes (mvsPaySystemClient()). Org-scoped routes call mvsPayClient({ organizationId, organizationSlug }) or mvsPayClientFromAuth(ctx), which do require an org. If you build a client with no org and call an org-scoped route, the failure surfaces from the extensions service, not from the SDK.

Authentication model

The SDK carries a single shared internalSecret and an org identifier — that is the whole auth story today. Per-user identity is not propagated; this is a deliberate, documented gap pending the mvs-auth plan. See ADR-010 · Internal secret / deferred auth and Authentication & Tenancy. When mvs-auth lands, JWT validation is expected to ride alongside x-internal-secret, which is then decommissioned over a 90-day overlap.

Headers the SDK injects

Every request from HttpClient.buildHeaders carries:

HeaderSourceWhen
Accept: application/jsonconstantalways
Content-Type: application/jsonconstantalways
x-internal-secretconfig.internalSecretalways
x-org-idconfig.orgIdwhen orgId set
x-org-slugconfig.orgSlugwhen orgSlug set
idempotency-keyper-call meta.idempotencyKeywhen provided
x-request-idper-call meta.requestIdwhen provided
arbitraryper-call opts.headersmerged last, can override the above

Bodies are JSON.stringify’d; query params are appended via URL.searchParams (arrays expand to repeated keys, null/undefined values are dropped).

Namespaces

MvsPayClient exposes 18 namespaces, each mirroring a route group on the extensions service. They split into Hyperswitch-modeled resources and MVS-specific sidecars.

NamespaceClassBacked by
paymentsPaymentsNamespaceHyperswitch payment_intent + transfer-extension sidecar
paymentLinksPaymentLinksNamespaceHyperswitch-native payment links
refundsRefundsNamespaceHyperswitch refunds
customersCustomersNamespaceHyperswitch customers (+ payment methods)
disputesDisputesNamespaceHyperswitch disputes + dispute_extension
mandatesMandatesNamespaceHyperswitch mandates
payoutsPayoutsNamespaceHyperswitch payouts
instantPayoutsInstantPayoutsNamespaceMVS PayoutMethod + InstantPayoutRequest
settlementsSettlementsNamespaceMVS SettlementExtension funding attempts
payoutMethodsPayoutMethodsNamespaceMVS PayoutMethod
devicesDevicesNamespaceMVS Device (POS terminals)
merchantsMerchantsNamespaceHyperswitch merchant_account + merchant_extension, connectors
routingRoutingNamespaceHyperswitch routing (static + routing.dynamic)
smartRetriesSmartRetriesNamespaceGSM rules + retry config
billableItemsBillableItemsNamespaceMVS BillableItem
notificationsNotificationsNamespaceMVS Notification
costObservabilityCostObservabilityNamespaceFee breakdown / downgrades / anomalies / forecast
revenueRecoveryRevenueRecoveryNamespaceRecovery plans + stats (see ADR-011)

A few namespaces nest. routing.dynamic (RoutingDynamicNamespace) carries the MAB / elimination / contract controls separately from the static rule engine on routing itself.

The READMEs and ADRs are the canonical feature docs; this page documents the transport shape. For what each feature does, see the matching Features pages: Payments & Refunds, Payouts & Instant Payouts, Disputes & Mandates, Payment Links, Routing & Smart Retries, Revenue Recovery, Devices & POS, and Cost Observability.

Method shape

Every namespace method follows the same convention: a typed input, an optional RequestMetadata trailing arg ({ idempotencyKey?, requestId?, signal? }), and a typed Promise<T> return. Mutating verbs accept idempotencyKey; reads accept requestId + signal. For example, PaymentsNamespace (src/namespaces/payments.ts):

1// POST /v1/payments
2const payment = await mvsPay.payments.create(
3 { amount: 1234, currency: "USD" },
4 { idempotencyKey: "checkout_42" },
5);
6
7// POST /v1/payments/:id/capture, /confirm, /cancel — all idempotent
8await mvsPay.payments.capture(payment.payment_id, { amount_to_capture: 1234 });
9
10// GET /v1/payments/:id and GET /v1/payments
11const fetched = await mvsPay.payments.get(payment.payment_id);
12const page = await mvsPay.payments.list({ limit: 50 });

Idempotency

Pass idempotencyKey in the per-call metadata arg on any mutating method; the SDK forwards it as the idempotency-key header. It is not auto-generated — the caller owns the key so retries (the SDK’s own, or an upstream re-invocation) collapse onto one operation.

The raw escape hatch

MvsPayClient.raw returns the underlying HttpClient for endpoints not yet wrapped in a namespace:

1const result = await mvsPay.raw.request<MyShape>("/v1/some/new-route", {
2 method: "POST",
3 body: { ... },
4});

Reach for this only when a typed namespace method does not exist yet; the right follow-up is to add the method to the namespace.

Error handling — MvsPayApiError

Every non-2xx response and every transport failure is surfaced as a single typed error class, MvsPayApiError (src/errors.ts). It mirrors the extensions service’s HyperswitchApiError shape so consumers can pattern-match.

FieldTypeMeaning
messagestring"mvs-pay {status}: {summary}", or a network/retry message
statusCodenumberupstream HTTP status; 0 for network/transport failures
responseBodyunknownparsed JSON body (or raw text if unparseable), null on transport errors
codestringmachine code — lifted from the body or derived from status (see below)
requestIdstring | nullfrom the response x-request-id header

code is best-effort: deriveErrorCode first tries responseBody.error.code, then responseBody.code, then falls back by status — UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), RATE_LIMITED (429), MVS_PAY_SERVER_ERROR (5xx), MVS_PAY_CLIENT_ERROR (other 4xx). Transport paths use MVS_PAY_NETWORK_ERROR (first-class network failure) and MVS_PAY_RETRY_EXHAUSTED (5xx/network retries exhausted).

1import { MvsPayApiError } from "@mvs/mvs-pay-sdk";
2
3try {
4 await mvsPay.payments.create({ amount: 100, currency: "USD" });
5} catch (err) {
6 if (err instanceof MvsPayApiError && err.code === "RATE_LIMITED") {
7 // back off and retry later
8 } else {
9 throw err;
10 }
11}

apps/mvs-pay/lib/mvs-pay-client.ts collapses every route’s catch block to one line with mvsPayApiError(error, fallback): an MvsPayApiError maps to the app’s apiError envelope using error.statusCode || 502 (a 0 status code from a transport failure becomes a 502); anything else becomes a 500 with the fallback message.

Retry behaviour

HttpClient.request retries on 5xx responses and transport (network) errors with exponential backoff — delay is retryBaseDelayMs * 2 ** attempt (default 200ms base, 3 retries). 4xx responses fail fast — they are caller mistakes, not transient. Override per client via the config, or per call via opts.retries on the raw request.

Subpath exports

The package ships three export subpaths beyond the root, declared in the exports map in packages/mvs-pay-sdk/package.json.

Root — @mvs/mvs-pay-sdk

MvsPayClient, MvsPayClientConfig, MvsPayApiError, HttpClient and its types, plus the full set of public input/response types. This is what new code imports.

./mock — MockMvsPayClient

Deterministic, dependency-free fake of MvsPayClient. No HTTP, no Hyperswitch, no Redis/Postgres/Temporal.

./legacy — legacy-shim

Back-compat surface for the deleted @mvs/payfac-client. Every export is @deprecated. Removed in @mvs/mvs-pay-sdk@2.0.0.

./iias — 90% rule helper

Pure helper (evaluateNinetyPercentRule) for IIAS substantiation. No HTTP. Also re-exported from the root. See the IIAS Substantiation runbook.

./mock

MockMvsPayClient (src/mock.ts) is a drop-in fake with the same namespace surface as the real client. Each namespace method returns a canned shape derived from its input rather than making an HTTP call. It is what healthos / mvs-c2 CI tests and local development use.

1import { MockMvsPayClient } from "@mvs/mvs-pay-sdk/mock";
2
3const mvsPay = new MockMvsPayClient({ orgId: "org_test" });
4const payment = await mvsPay.payments.create({ amount: 100, currency: "USD" });
5// payment.payment_id === "pay_mock_000001", status "succeeded"

Conventions worth knowing:

  • IDs use the real prefix plus _mock_ and a zero-padded counter (pay_mock_000001, ref_mock_000002, …), so the same call sequence is stable across a test run.
  • orgId defaults to org_mock; timestamps default to MOCK_TIMESTAMP (2026-01-01T00:00:00.000Z) and can be overridden with { now: () => "..." }.
  • The mock does not implements MvsPayClient (the real namespaces carry a private http field). Instead a compile-time guard (_AssertSameNames) keeps the mock’s namespace key names in lockstep with the real client — add a namespace to MvsPayClient and src/mock.ts fails to compile until it catches up.
  • mock.raw throws — there is no real HttpClient to hand back.

./legacy

src/legacy-shim.ts exists so healthos and mvs-c2’s apps/platform-admin keep compiling against their old @mvs/payfac-client (Finix-era) imports while they migrate to the namespace API. See the HealthOS Cutover and mvs-c2 Cutover runbooks for the migration steps.

Everything in ./legacy is @deprecated and slated for removal in @mvs/mvs-pay-sdk@2.0.0 (180-day deprecation, per the phase 3 plan §M19). New code must not import it.

What it provides, and the caveats:

  • Finix-named entity types (Transfer, Authorization, Merchant, Device, Dispute, Settlement, PaymentInstrument, Identity, etc.) are aliases to the closest Hyperswitch-modeled SDK type. They exist so imports keep compiling — not so behaviour is identical. Field names differ between the surfaces (e.g. finixTransferIdpayment_id, statestatus); TypeScript flags the diff at call sites that read individual fields. A few that have no clean mapping (PaymentInstrument, Receipt, PaymentLink, Subscription) are exported as a loose Record<string, unknown>.
  • PayfacClientError is re-exported as an alias for MvsPayApiError so old instanceof checks still type-resolve; the runtime instance is MvsPayApiError.
  • Wrapper functions (createTransfer, getTransfer, refundTransfer, createAuthorization, captureAuthorization, listDisputes, createIdentity, …) now take a pre-built MvsPayClient as their first argument and route onto the new namespace methods. This lets callers migrate incrementally without re-introducing the legacy singleton + Infisical getSecret() lookup. Functions that no longer map 1:1 (for example anything that read the legacy discrete Settlement entity) are stubbed onto the nearest new method or throw a deprecation Error pointing at the replacement.

The SDK is web/Node-only by design — there are no native or mobile wrappers. See ADR-009 · Web-only UI SDK (which governs the sibling @mvs/mvs-pay-ui).

Real usage in apps/mvs-pay

apps/mvs-pay/lib/mvs-pay-client.ts is the canonical consumer and a good template for new ones. It resolves config from @mvs/config (mvsPayExtensions.baseUrl, requireMvsPayExtensionsSecret()) and exposes three factories:

1

System routes — memoized, no org

mvsPaySystemClient() returns a single cached MvsPayClient built without org headers. Use only for system-level routes.

2

Org-scoped routes — per-request client

mvsPayClient({ organizationId, organizationSlug }) builds a fresh org-scoped client (cheap; shares fetch + secret resolution). Throws if neither id nor slug is given.

3

From an auth context

mvsPayClientFromAuth(ctx) is sugar over the above, pulling organizationId / orgSlug straight off the authenticated ApiContext.

The same file’s mvsPayApiError(error, fallback) is the standard catch-block helper described in the error section above.

Publishing

@mvs/mvs-pay-sdk is published as part of a linked release group together with @mvs/mvs-pay-ui. The two are bumped to the same version so downstream consumers (healthos, mvs-c2) can pin both together. This is configured in .changeset/config.json:

1"linked": [["@mvs/mvs-pay-sdk", "@mvs/mvs-pay-ui"]]

Everything else in the workspace (both apps, the vendored packages/*, the payment-owned Temporal workflows, tooling) is in the changeset ignore list and does not publish.

Release flow:

  1. Add a changeset (pnpm changeset) alongside your code change.
  2. On main, the Buildkite pipeline runs pnpm changeset publish, which reads the linked array and publishes both packages together.
  3. Publish targets the Buildkite Package Registry (access: "restricted"). The step needs BUILDKITE_PACKAGES_TOKEN, pulled from AWS Secrets Manager via the seek-oss/aws-sm Buildkite plugin (.buildkite/pipeline.yml, the :rocket: Publish step on the mvs-pay-publish agent queue).

Package state today: version is 0.1.0. The package’s main/types currently point at ./src/*.ts (workspace-internal consumption via the monorepo); the published artifact uses the publishConfig block, which maps to the tsup-built ./dist (ESM + CJS + .d.ts). The .changeset/initial-mvs-pay-extraction.md note also mentions a future @mvs/payments-sdk joining the linked group — it does not exist yet.

See CI/CD and Secrets Management for the surrounding pipeline and token provisioning.

Gaps & honesty notes

  • No per-user auth. Only x-internal-secret + org headers; user identity is not propagated. Deferred to mvs-auth per ADR-010.
  • ./legacy is type-only back-compat, not behavioural parity. Aliased Finix entity types do not have identical field names to the new shapes; treat compile errors at the call site as the migration to-do list. Removed in 2.0.0.
  • raw exists because not every extensions route is wrapped. New endpoints land on the raw escape hatch first; wrapping them in a typed namespace is the follow-up.
  • Mock divergence is possible. MockMvsPayClient mirrors the method surface and canned return shapes, not real validation or upstream behaviour — a method may “succeed” in the mock and fail against the real service. The compile-time name guard only catches namespace drift, not per-method signature drift within a namespace.
  • Versions are pre-1.0. Both linked packages sit at 0.1.0; the dist publish path is wired but the local/workspace entry points still resolve to src.