Payouts & Instant Payouts

Settlements and Plaid ACH

This page covers how money leaves the platform: Hyperswitch-native payouts, their MVS settlement sidecar and approval workflow, payout methods (push-to-card debit cards and Plaid-linked ACH bank accounts), the Plaid Link → exchange flow, instant payouts against a linked method, and the async settlement reconciliation that the Temporal pay-crons worker performs.

The two architectural rules to internalize before reading further:

  1. Hyperswitch is the source of truth for the money movement. Every payout is a Hyperswitch payout object. MVS only stores sidecar rows (SettlementExtension, InstantPayoutRequest, PayoutMethod, PlaidItem) for things Hyperswitch does not model — an approval gate, funding-attempt history, ACH bank linking, and operator visibility. See Hyperswitch pivot.
  2. There are two services. The Next.js dashboard app (apps/mvs-pay) is the customer/operator-facing API; it proxies to the extensions service (apps/mvs-pay-extensions, a Hono app) which owns the sidecar tables, the Plaid integration, and the tenant-scoped Prisma client. See Extensions service.

Plaid bank linking, instant payouts against Plaid methods, and the encrypted-token storage are Phase 5 / M24 work. They are wired end-to-end in code, but Plaid production credentials are not yet provisioned — only sandbox keys are usable today (production access carries a 5–10 business-day Plaid approval SLA). See the Plaid Link runbook.

Vocabulary

TermWhat it isWhere it lives
PayoutA Hyperswitch payout object — the actual transfer of funds out.Hyperswitch (source of truth)
SettlementExtensionMVS sidecar for a payout: approval gate, platform notes, links to funding attempts. Keyed by hyperswitchPayoutId.settlement_extension table
PayoutMethodA destination: a push-to-card debit card (tokenized by Hyperswitch) or a Plaid-verified ACH bank account.payout_method table
InstantPayoutRequestOne attempt to push funds to a PayoutMethod. Tracks status + Hyperswitch payout id + failure detail. Doubles as the per-settlement “funding attempt.”instant_payout_request table
PlaidItemPlaid linked-bank metadata (institution, account mask, verification status, webhook state). One row per Plaid Item, unique per org.plaid_item table

These models are also documented in Data model.

Plain payouts (Hyperswitch-native)

The dashboard’s payout surface is a thin pass-through to Hyperswitch with a settlement sidecar attached on create.

Extensions service routes

apps/mvs-pay-extensions/src/routes/payouts.ts is a near-verbatim proxy to the Hyperswitch payouts API — no sidecar logic, no tenant table writes:

Method & pathHyperswitch call
POST /v1/payoutshyperswitch.payouts.create(body, idempotencyKey)
GET /v1/payouts/:idhyperswitch.payouts.get(id)
POST /v1/payouts/:id/confirmhyperswitch.payouts.confirm(id)
POST /v1/payouts/:id/cancelhyperswitch.payouts.cancel(id)
GET /v1/payoutshyperswitch.payouts.list()

Errors are mapped through mapErr: a HyperswitchApiError is re-emitted as { error: <responseBody> } with the upstream status (via asContentfulStatus); anything else re-throws. The idempotency-key header is forwarded to Hyperswitch on create.

See the API Reference tab for request/response shapes.

Dashboard app: payout + sidecar

apps/mvs-pay/app/api/payouts/route.ts is where the settlement sidecar is created. On POST /api/payouts:

  1. Authenticate with the settlements:write scope.
  2. Create the Hyperswitch payout via the SDK (mvsPayClient(...).payouts.create). An idempotency-key header is honored; otherwise one is synthesized as mvspay_<orgId>_<uuid>.
  3. If the response has no payout_id, fail 502 — we refuse to persist a sidecar we cannot key.
  4. Upsert a SettlementExtension keyed on hyperswitchPayoutId with approvalStatus: "PENDING".

GET /api/payouts lists Hyperswitch payouts (scope settlements:read) and joins each with its org-scoped settlementExtension. Note the explicit tenant guard: even after a findUnique on the globally-unique hyperswitchPayoutId, the row is discarded unless ext.organizationId === organizationId.

Settlements & the approval gate

SettlementExtension (schema.prisma, model SettlementExtension) is the MVS-only workflow layer over a Hyperswitch payout. Selected fields:

1model SettlementExtension {
2 id String @id @default(cuid())
3 organizationId String
4 merchantExtensionId String?
5 hyperswitchPayoutId String @unique
6 hyperswitchMerchantId String?
7
8 // Platform approval workflow — MVS-specific gate before payout funds.
9 approvalStatus String @default("PENDING") // PENDING, APPROVED, REJECTED
10 approvedAt DateTime?
11 approvedBy String?
12 rejectedAt DateTime?
13 rejectedBy String?
14 rejectionReason String?
15 reviewDeadline DateTime?
16 platformNotes String?
17
18 instantPayoutRequests InstantPayoutRequest[]
19}

The relationship that matters operationally: one SettlementExtension has many InstantPayoutRequest rows — each one a funding attempt. Hyperswitch has no per-settlement funding-attempt API, so this history lives entirely in MVS tables.

The approvalStatus column models an approve/reject gate, but the enforcement of that gate (refusing to fund a settlement that is still PENDING/REJECTED) is not implemented in the instant-payout create path described below — it checks the payout method’s enabled/verified flags, not the settlement’s approvalStatus. If you are adding hard approval enforcement, that check does not exist yet; treat the gate as advisory/operator-facing today.

Funding-attempt history

apps/mvs-pay-extensions/src/routes/settlements.ts exposes one read endpoint:

GET /v1/settlements/:id/funding-attempts

:id may be the SettlementExtension cuid or its globally-unique hyperswitchPayoutId — it resolves with an OR filter that is always tenant-scoped by organizationId (the isolation boundary). It then returns the InstantPayoutRequest rows for that settlement, newest-first.

The dashboard surfaces this at GET /api/settlements/[id]/funding-attempts (apps/mvs-pay/app/api/settlements/[id]/funding-attempts/route.ts, scope settlements:read), proxying through mvsPayClient.settlements.fundingAttempts.

Payout methods

A PayoutMethod is a payout destination. Two flavors share the table:

typeCreated byhyperswitchPaymentMethodIdVerification signal
PAYMENT_CARD (default)POST /v1/payout-methods with a Hyperswitch payment-method idReal Hyperswitch tokenToken must still resolve in Hyperswitch
BANK_ACCOUNTPlaid /v1/plaid/exchangeSynthetic handle plaid:<plaidItemId>accessTokenEnc + plaidAccountId present

The synthetic id for bank accounts is deliberate: there is no Hyperswitch payment-method id for an ACH account, so the Plaid Item id is used as a stable, unique handle to satisfy the @unique constraint on hyperswitchPaymentMethodId.

Routes

apps/mvs-pay-extensions/src/routes/payout-methods.ts. All routes require tenant.organizationId (else 400 tenant_required) and are Prisma-scoped to that org.

Method & pathBehavior
GET /v1/payout-methodsList org’s methods, newest-first.
POST /v1/payout-methodsCreate a push-to-card method from a hyperswitch_payment_method_id + hyperswitch_merchant_id (+ optional card display fields). Defaults type to PAYMENT_CARD.
GET /v1/payout-methods/:id/verificationReturn { verified, verificationId, lastVerifiedAt, plaidVerifiedAt, pushToCardEligible }. 404 if not owned.
POST /v1/payout-methods/:id/verifyFlip verified=true (see logic below). 404 if not owned.
DELETE /v1/payout-methods/:idTenant-scoped deleteMany; returns { ok: true }.

Verify logic

POST /v1/payout-methods/:id/verify branches on how the method was created:

1

Ownership check first

findFirst scoped by id + organizationId. A no-match returns 404 not_found — never a blind success — so you cannot verify a method another org owns.

2

Plaid-backed bank account

If accessTokenEnc and plaidAccountId are present, the account was already verified at Link/exchange time. Trust that signal: set verified=true, stamp lastVerifiedAt, and set plaidVerifiedAt if not already set.

3

Push-to-card debit card

Otherwise the Hyperswitch token must still resolve — call hyperswitch.paymentMethods.get(method.hyperswitchPaymentMethodId). A missing/invalid token returns payment_method_unverifiable with the upstream status and must not pass. Only on success do we flip verified=true.

The PayoutMethod model carries several push-to-card eligibility columns (pushToCardEligible, domesticPushToCard, crossBorderPushToCard) that are present in the schema but not populated by any route in this directory today — treat them as reserved for a future eligibility-probe step.

Plaid ACH linking

The end-to-end goal: let an org link a real bank account via Plaid Auth and use it as an instant-payout destination, without ever storing a raw Plaid access token in the database in plaintext.

Configuration & gating

Plaid config lives in apps/mvs-pay-extensions/src/config/index.ts under config.plaid:

FieldEnv varNotes
clientIdPLAID_CLIENT_IDnon-secret but environment-scoped
secretPLAID_SECRETsandbox vs prod secret
environmentPLAID_ENVsandbox (default) / development / production
webhookUrlPLAID_WEBHOOK_URLbaked into link tokens; must terminate at /webhooks/plaid
webhookSecretPLAID_WEBHOOK_SECREToptional HMAC verification key

isPlaidConfigured() returns true only when both clientId and secret are set. The Plaid client (apps/mvs-pay-extensions/src/services/plaid-client.ts) is a lazily-constructed PlaidApi singleton; it throws if instantiated while unconfigured. Both Plaid routes short-circuit to 503 plaid_not_configured before the client is ever built, so that throw only fires on local-dev misconfiguration.

Token & secret storage

There are two stores in play, and the comments in the schema and code do not fully agree — worth knowing:

  • PayoutMethod.accessTokenEnc and PlaidItem.accessTokenEnc hold the AES-256-GCM ciphertext of the Plaid access token, produced by encryptForOrg(organizationId, accessToken) from @mvs/secrets/encryption. Wire format is v1:<iv-base64>:<tag-base64>:<ciphertext-base64>, and the org id is mixed into the GCM AAD (org:<id>) so a ciphertext leaked from one org cannot be decrypted under another. The key comes from MVS_PAY_KMS_KEY (production refuses to start without it; non-prod falls back to a deterministic scrypt-derived dev key).
  • PlaidItem.accessTokenSecretName stores a reference string (PLAID_ACCESS_TOKEN_<itemId>). The model comment describes access tokens living in Infisical under /tenants/{slug}/PLAID_ACCESS_TOKEN_{itemId}, with only the name reference in the DB. In current code (/v1/plaid/exchange) the encrypted token is written directly to accessTokenEnc; accessTokenSecretName is set but the Infisical-stored-token path is the legacy/older-row story. New rows resolve via accessTokenEnc.

Nothing in this directory decrypts accessTokenEnc yet. The instant-payout create path (below) drives Hyperswitch off hyperswitchPaymentMethodId, which for a Plaid bank account is the synthetic plaid:<id> handle — not a real Hyperswitch payment method. So while a Plaid bank account can be linked, verified, and have an InstantPayoutRequest row created against it, the actual ACH transfer rail is not wired through Hyperswitch in this code path. See “Known gaps” below before promising customers ACH payouts.

apps/mvs-pay-extensions/src/routes/plaid.ts exposes two endpoints. Both emit a phiAudit.logAccess entry attributing the action to the org.

POST /v1/plaid/link-token mints a short-lived link_token for the browser-side usePlaidLink hook / <MvsPayInstantPayoutPicker> component:

1const request: LinkTokenCreateRequest = {
2 user: { client_user_id: tenant.organizationId },
3 client_name: "MVS Pay",
4 products: [Products.Auth],
5 country_codes: [CountryCode.Us],
6 language: "en",
7 ...(config.plaid.webhookUrl ? { webhook: config.plaid.webhookUrl } : {}),
8};

POST /v1/plaid/exchange trades the single-use public_token for a long-lived access token and persists the linked account. Body: { public_token, account_id }. Steps:

  1. 400 missing_fields if either field is absent.
  2. itemPublicTokenExchange({ public_token })access_token + item_id.
  3. accountsGet({ access_token }) → accounts + institution id.
  4. Find the chosen account_id in the item; 404 account_not_found_in_item if missing.
  5. encryptForOrg(orgId, accessToken) → ciphertext.
  6. Upsert a PlaidItem on the (organizationId, itemId) compound unique. Re-link resets lastErrorCode/lastErrorMessage/loginRequiredAt to null and sets verificationStatus: "verified".
  7. Create a PayoutMethod (type: "BANK_ACCOUNT", synthetic plaid:<plaidItemId> handle, verified: true, plaidVerifiedAt: now, hyperswitchMerchantId: "").
  8. Return { payoutMethodId } for the caller to hand to the instant-payout picker.

Tenant scoping & error mapping

Every Prisma query in these routes filters by tenant.organizationId, and the org id is bound into the ciphertext AAD so a leaked column value cannot be replayed cross-org. Plaid errors are funneled through describePlaidError:

ConditionHTTP statusBody error
INVALID_PUBLIC_TOKEN / INVALID_ACCESS_TOKEN400invalid_token
Plaid responded 401401plaid_unauthorized
anything else502plaid_upstream_error

Sanitized Plaid logging

Never log a raw Plaid/axios error. A raw axios error serializes err.config.data, err.config.headers, and err.response.config, which would leak the Plaid access_token, public_token, and our Plaid API secret / client_id into the logs.

plaid.ts enforces this with a dedicated plaidErrorLogFields(err) helper that extracts only three safe fields — the Plaid error_code, error_message, and HTTP status — and that is the only shape ever passed to logger.warn:

1logger.warn(
2 {
3 plaidError: plaidErrorLogFields(err), // { code, message, status } only
4 organizationId: tenant.organizationId,
5 },
6 "[plaid/exchange] Plaid call failed",
7);

If you add new Plaid call sites, reuse plaidErrorLogFields — do not spread the error object into a log line.

Plaid webhooks

apps/mvs-pay-extensions/src/routes/webhooks-plaid.ts ingests Plaid’s single configured webhook URL (/webhooks/plaid). Behavior:

  • Signature: in production an unset PLAID_WEBHOOK_SECRET fails closed (503 not_configured) — this state-mutating, internet-facing endpoint never silently skips verification in prod. If a secret is configured, the Plaid-Verification header must match an HMAC-SHA256 of the raw body (timingSafeEqual), else 401 signature_invalid. Outside production an unset secret is tolerated for sandbox/pre-rotation testing.
  • Lookup: findPlaidItemByItemId finds the row by item_id (globally unique via the (organizationId, itemId) index), then re-acquires a tenant-scoped client with the resolved organizationId for the update. Unknown items are ack’d as { ok: true, ignored: true }.

Handled events:

EventEffect
ITEM:ERRORverificationStatus = "broken", stamp lastErrorCode/lastErrorMessage; set loginRequiredAt if ITEM_LOGIN_REQUIRED.
ITEM:PENDING_EXPIRATIONverificationStatus = "pending_expiration"; operator notification is currently just a logger.warn — real alerting via @mvs/mail is a follow-on milestone (not yet wired).
AUTH:AUTOMATICALLY_VERIFIEDAlready covered at exchange time; ack { alreadyVerified: true } and stamp lastWebhookAt.
(default)Ack { unhandled: true }, stamp lastWebhookAt/webhookStatus. Plaid retries non-2xx only.

The webhook signature scheme here is the simple HMAC fallback; the runbook flags that production should rotate to Plaid’s JWT-based verification.

Instant payouts

An instant payout pushes funds to a previously-linked, verified PayoutMethod and tracks the attempt in InstantPayoutRequest.

Model

1model InstantPayoutRequest {
2 id String @id @default(cuid())
3 organizationId String
4 hyperswitchMerchantId String
5 hyperswitchPayoutId String? // set once the Hyperswitch payout is created
6 settlementExtensionId String? // links this attempt to a settlement
7 payoutMethodId String
8 amount Int
9 currency String @default("USD")
10 status String @default("PENDING") // PENDING, REQUESTED, COMPLETED, FAILED
11 fundingTransferId String?
12 failureCode String?
13 failureMessage String?
14 rail String @default("INSTANT")
15 requestedAt DateTime @default(now())
16 requestedBy String?
17 completedAt DateTime?
18 failedAt DateTime?
19}

payoutMethod is onDelete: Restrict — you cannot delete a PayoutMethod that has attempts pointing at it.

Status state machine

Create flow

apps/mvs-pay-extensions/src/routes/instant-payouts.ts, POST /v1/instant-payouts. Requires tenant.organizationId. Body: { payout_method_id, amount, currency?, settlement_extension_id?, requested_by?, tenant_patient_id?, metadata? }.

1

PHI audit

Emit phiAudit.logAccess (resourceType: "instant_payout", subAction: "instant_payout.create"), carrying tenant_patient_id if supplied.

2

Resolve & gate the method

findFirst the PayoutMethod scoped by id + organizationId. 404 payout_method_not_found if absent; 400 payout_method_not_verified if !enabled || !verified.

3

Persist the attempt (PENDING)

Create the InstantPayoutRequest row up front (status: "PENDING"), copying hyperswitchMerchantId from the method and the optional settlement_extension_id. This guarantees operator visibility even if the Hyperswitch call fails.

4

Create the Hyperswitch payout

hyperswitch.payouts.create({ amount, currency, payout_method_data: { payment_method_id: method.hyperswitchPaymentMethodId }, confirm: true, auto_fulfill: true, metadata: { mvs_org_id, mvs_instant_payout_request_id } }, idempotencyKey).

5

Record outcome

On success: store hyperswitchPayoutId, flip to REQUESTED, return the row plus hyperswitch_payout_id. On HyperswitchApiError (or any throw): flip to FAILED, stamp failureMessage + failedAt, and surface the upstream status.

Forward an idempotency-key header. It is passed straight to hyperswitch.payouts.create, so a retried request will not double-fund.

GET /v1/instant-payouts (paginated by ?limit, default 50) and GET /v1/instant-payouts/:id are tenant-scoped reads.

Dashboard entry points

  • POST /api/settlements/[id]/instant-payout (apps/mvs-pay/..., scope settlements:write) — request an instant payout for a settlement. It accepts payoutMethodId (or legacy paymentInstrumentId), a positive amount, optional currency, and forwards an idempotency-key. It calls client.instantPayouts.request({ ..., settlement_extension_id: id }), which is how InstantPayoutRequest.settlementExtensionId gets populated and the attempt shows up under that settlement’s funding-attempt history.

Async settlement via the Temporal worker

The create path leaves a successful attempt in REQUESTED — it does not wait for the payout to actually settle. Reconciliation is owned by the pay-crons Temporal worker.

The reconciliation workflow

packages/temporal-workflows/src/workflows/mvs-pay.ts defines instantPayoutSettlementWorkflow, scheduled every 15 minutes (packages/temporal-workflows/src/schedules/pay-crons.ts, schedule id pay-crons.instant-payout.settlement, cron */15 * * * *). Workflow code is deterministic; the side-effecting work is in the activity runInstantPayoutSettlement (packages/temporal-workflows/src/activities/mvs-pay.ts), proxied with a 10-minute start-to-close timeout and the Hyperswitch-API retry policy.

What the activity does (default limit: 100 per run):

  1. Load InstantPayoutRequest rows where status === "REQUESTED", oldest-first.
  2. Skip any without a hyperswitchPayoutId (nothing to reconcile yet).
  3. hyperswitch.payouts.get(payoutId) and read status:
    • succeeded / success / completedCOMPLETED (+ completedAt).
    • failed / cancelled / expired / reversedFAILED (+ failedAt, failureCode, failureMessage).
    • anything else → leave as REQUESTED, count as processed.
  4. Return { processed, errors, details: { completed, failed, scanned } }.

This activity uses the global getMvsPayDb() (no org filter) because it scans all orgs’ REQUESTED rows. That is intentional for a platform-wide reconciler, but it means it is the one settlement code path that is not tenant-scoped — keep that in mind when modifying it.

The same pay-crons queue runs five other scheduled workflows. They are not payout-settlement but are listed so you know what else touches these tables / Hyperswitch:

ScheduleCadencePurpose
hyperswitchEventReplayWorkflowevery 30mRetry non-delivered Hyperswitch webhook events.
residualEstimationWorkflowmonthly 1st 06:00Snapshot per-merchant gross/fees/net residual into MerchantExtension.metadata.
residualChecksWorkflowdaily 10:00Alert on connector-fee % drift vs. baseline.
disputeSlaMonitorWorkflowdaily 09:00DISPUTE_DEADLINE notifications for overdue disputes.
deviceHealthMonitorWorkflowevery 30mDEVICE_OFFLINE notifications for stale devices.

(webhookDlqDrainerWorkflow and successRateReconcileWorkflow also run on this queue.)

Known gaps & honesty checklist

A Plaid BANK_ACCOUNT PayoutMethod carries a synthetic hyperswitchPaymentMethodId of plaid:<plaidItemId> and an empty hyperswitchMerchantId. The instant-payout create path passes that handle to hyperswitch.payouts.create as payment_method_id. Hyperswitch will not recognize a plaid: handle, so an instant payout against a Plaid-linked account is expected to fail at the Hyperswitch call (landing the row in FAILED). The link/verify/store half is real; the ACH disbursement half is not. Push-to-card debit methods (real Hyperswitch tokens) are the working rail today.

No route or activity in the payouts/instant-payouts/plaid surface calls decryptForOrg. The encrypted Plaid access token is stored correctly but is not yet consumed by any disbursement flow.

SettlementExtension.approvalStatus exists and is set to PENDING on payout creation, but the instant-payout path does not check it before funding. There is no code that blocks a payout because its settlement is unapproved.

The Plaid ITEM:PENDING_EXPIRATION webhook only emits a logger.warn. Operator notification via @mvs/mail is a stated follow-on milestone and is not implemented.

webhooks-plaid.ts verifies an HMAC-SHA256 of the raw body against PLAID_WEBHOOK_SECRET. The runbook flags that production should move to Plaid’s JWT verification; that rotation is not done.

Only sandbox keys are usable today. Production cutover (separate client_id/secret, PLAID_ENV=production, prod MVS_PAY_KMS_KEY, prod webhook URL behind the WAF) is documented but pending Plaid’s use-case approval. The KMS-key re-encryption migration referenced in rotation is a TODO tracked separately.

pushToCardEligible, domesticPushToCard, crossBorderPushToCard exist on PayoutMethod but are not set by any route here.

Where to go next