Payouts & Instant Payouts
Payouts & Instant Payouts
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:
- 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. - 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
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:
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:
- Authenticate with the
settlements:writescope. - Create the Hyperswitch payout via the SDK (
mvsPayClient(...).payouts.create). Anidempotency-keyheader is honored; otherwise one is synthesized asmvspay_<orgId>_<uuid>. - If the response has no
payout_id, fail502— we refuse to persist a sidecar we cannot key. - Upsert a
SettlementExtensionkeyed onhyperswitchPayoutIdwithapprovalStatus: "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:
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:
: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:
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.
Verify logic
POST /v1/payout-methods/:id/verify branches on how the method was created:
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.
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:
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.accessTokenEncandPlaidItem.accessTokenEnchold the AES-256-GCM ciphertext of the Plaid access token, produced byencryptForOrg(organizationId, accessToken)from@mvs/secrets/encryption. Wire format isv1:<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 fromMVS_PAY_KMS_KEY(production refuses to start without it; non-prod falls back to a deterministic scrypt-derived dev key).PlaidItem.accessTokenSecretNamestores 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 toaccessTokenEnc;accessTokenSecretNameis set but the Infisical-stored-token path is the legacy/older-row story. New rows resolve viaaccessTokenEnc.
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.
Link flow
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:
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:
400 missing_fieldsif either field is absent.itemPublicTokenExchange({ public_token })→access_token+item_id.accountsGet({ access_token })→ accounts + institution id.- Find the chosen
account_idin the item;404 account_not_found_in_itemif missing. encryptForOrg(orgId, accessToken)→ ciphertext.- Upsert a
PlaidItemon the(organizationId, itemId)compound unique. Re-link resetslastErrorCode/lastErrorMessage/loginRequiredAtto null and setsverificationStatus: "verified". - Create a
PayoutMethod(type: "BANK_ACCOUNT", syntheticplaid:<plaidItemId>handle,verified: true,plaidVerifiedAt: now,hyperswitchMerchantId: ""). - 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:
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:
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_SECRETfails closed (503 not_configured) — this state-mutating, internet-facing endpoint never silently skips verification in prod. If a secret is configured, thePlaid-Verificationheader must match an HMAC-SHA256 of the raw body (timingSafeEqual), else401 signature_invalid. Outside production an unset secret is tolerated for sandbox/pre-rotation testing. - Lookup:
findPlaidItemByItemIdfinds the row byitem_id(globally unique via the(organizationId, itemId)index), then re-acquires a tenant-scoped client with the resolvedorganizationIdfor the update. Unknown items are ack’d as{ ok: true, ignored: true }.
Handled events:
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
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? }.
PHI audit
Emit phiAudit.logAccess (resourceType: "instant_payout", subAction: "instant_payout.create"), carrying tenant_patient_id if supplied.
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.
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.
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/..., scopesettlements:write) — request an instant payout for a settlement. It acceptspayoutMethodId(or legacypaymentInstrumentId), a positiveamount, optionalcurrency, and forwards anidempotency-key. It callsclient.instantPayouts.request({ ..., settlement_extension_id: id }), which is howInstantPayoutRequest.settlementExtensionIdgets 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):
- Load
InstantPayoutRequestrows wherestatus === "REQUESTED", oldest-first. - Skip any without a
hyperswitchPayoutId(nothing to reconcile yet). hyperswitch.payouts.get(payoutId)and readstatus:succeeded/success/completed→COMPLETED(+completedAt).failed/cancelled/expired/reversed→FAILED(+failedAt,failureCode,failureMessage).- anything else → leave as
REQUESTED, count as processed.
- 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.
Related pay-crons (context)
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:
(webhookDlqDrainerWorkflow and successRateReconcileWorkflow also run on this queue.)
Known gaps & honesty checklist
Plaid ACH payout rail is not actually wired through Hyperswitch
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.
accessTokenEnc is written but never decrypted in this code
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.
Settlement approval gate is advisory, not enforced
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.
PENDING_EXPIRATION alerting is log-only
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.
Webhook signature is HMAC fallback, not Plaid JWT
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.
Plaid production not provisioned
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.
push-to-card eligibility columns unpopulated
pushToCardEligible, domesticPushToCard, crossBorderPushToCard exist on PayoutMethod but are not set by any route here.
Where to go next
Step-by-step credential provisioning, sandbox verification, webhook simulation, and production cutover.
Rotating PLAID_SECRET, PLAID_WEBHOOK_SECRET, and the MVS_PAY_KMS_KEY data-encryption key.
The Hono app that owns these routes and the tenant-scoped Prisma client.
Full schema for SettlementExtension, PayoutMethod, InstantPayoutRequest, PlaidItem.
Why Hyperswitch is the source of truth and MVS only stores sidecars.
payouts, instantPayouts, and settlements client methods used by the dashboard.