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.
MvsPayClientConfig
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:
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.
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):
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:
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.
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).
How the Next.js app maps errors
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.
MvsPayClient, MvsPayClientConfig, MvsPayApiError, HttpClient and its
types, plus the full set of public input/response types. This is what new code
imports.
Deterministic, dependency-free fake of MvsPayClient. No HTTP, no Hyperswitch,
no Redis/Postgres/Temporal.
Back-compat surface for the deleted @mvs/payfac-client. Every export is
@deprecated. Removed in @mvs/mvs-pay-sdk@2.0.0.
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.
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. orgIddefaults toorg_mock; timestamps default toMOCK_TIMESTAMP(2026-01-01T00:00:00.000Z) and can be overridden with{ now: () => "..." }.- The mock does not
implements MvsPayClient(the real namespaces carry a privatehttpfield). Instead a compile-time guard (_AssertSameNames) keeps the mock’s namespace key names in lockstep with the real client — add a namespace toMvsPayClientandsrc/mock.tsfails to compile until it catches up. mock.rawthrows — there is no realHttpClientto 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.finixTransferId→payment_id,state→status); 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 looseRecord<string, unknown>. PayfacClientErroris re-exported as an alias forMvsPayApiErrorso oldinstanceofchecks still type-resolve; the runtime instance isMvsPayApiError.- Wrapper functions (
createTransfer,getTransfer,refundTransfer,createAuthorization,captureAuthorization,listDisputes,createIdentity, …) now take a pre-builtMvsPayClientas their first argument and route onto the new namespace methods. This lets callers migrate incrementally without re-introducing the legacy singleton + InfisicalgetSecret()lookup. Functions that no longer map 1:1 (for example anything that read the legacy discreteSettlemententity) are stubbed onto the nearest new method or throw a deprecationErrorpointing 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:
System routes — memoized, no org
mvsPaySystemClient() returns a single cached MvsPayClient built without org
headers. Use only for system-level routes.
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:
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:
- Add a changeset (
pnpm changeset) alongside your code change. - On
main, the Buildkite pipeline runspnpm changeset publish, which reads thelinkedarray and publishes both packages together. - Publish targets the Buildkite Package Registry (
access: "restricted"). The step needsBUILDKITE_PACKAGES_TOKEN, pulled from AWS Secrets Manager via theseek-oss/aws-smBuildkite plugin (.buildkite/pipeline.yml, the:rocket: Publishstep on themvs-pay-publishagent 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 tomvs-authper ADR-010. ./legacyis 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 in2.0.0.rawexists 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.
MockMvsPayClientmirrors 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; thedistpublish path is wired but the local/workspace entry points still resolve tosrc.