Payment Lifecycle
The payment-intent state machine
A payment in MVS Pay is a Hyperswitch payment_intent — there is no MVS-native
transaction object. After the Hyperswitch pivot
we dropped the Finix DEBIT / REVERSAL transaction codes entirely. The
lifecycle a payment moves through is the Hyperswitch payment-intent state
machine, refunds are first-class objects created on /v1/refunds, and a
pre-capture void is just a cancel.
The mvs-pay-extensions service (apps/mvs-pay-extensions/) is a thin
orchestration layer in front of Hyperswitch. It forwards the Hyperswitch-native
fields to the router and persists MVS-specific metadata (healthcare line items,
surcharges, tips, tenant references, L2/L3 commercial-card data) to the
transfer_extension sidecar table. The route handlers that drive the lifecycle
live in apps/mvs-pay-extensions/src/routes/payments.ts and
apps/mvs-pay-extensions/src/routes/refunds.ts; the typed HTTP wrapper around
Hyperswitch is apps/mvs-pay-extensions/src/services/hyperswitch-client.ts.
This page is the conceptual model. For the feature-level “how do I do X” walkthrough (SDK code, surcharging, IIAS), see Payments & Refunds. For the API contract, see the API Reference.
The state machine
A payment intent carries a status field that moves through the states below.
This diagram is the canonical lifecycle, reproduced from
apps/mvs-pay/docs/TRANSACTION_FLOW.md.
The same flow rendered as the ASCII diagram from the source doc, for reference:
Status definitions
status is a free-form string on the wire — PaymentResponse.status is typed as
string in hyperswitch-client.ts, not a sealed enum. The extensions service does
not validate or remap the status it receives from Hyperswitch; it passes the
object through. Treat the set above as Hyperswitch’s contract (v1.123.1), and code
defensively against unrecognized values rather than exhaustively switch-ing on it.
Capture methods
capture_method is set on payments.create and decides whether the intent
auto-captures or stops at an authorization. It is part of CreatePaymentInput
in hyperswitch-client.ts ("automatic" | "manual").
manual is the “auth-then-capture” / card-hold pattern. Capture can be full or
partial:
To release a held authorization instead of capturing it, cancel the intent — do not refund it. A refund only applies after capture. See Voids vs refunds.
Driving the lifecycle
The extensions service exposes the lifecycle verbs under /v1/payments
(mounted in apps/mvs-pay-extensions/src/app.ts). Each is a thin wrapper that
forwards to Hyperswitch and maps errors via mapHyperswitchError.
Create the intent
payments.create forwards the Hyperswitch-native body and stamps
metadata.mvs_org_id / mvs_org_slug into the intent. MVS extension fields
(tenant_patient_id, surcharge_amount, healthcare line items, etc.) are split
out of the body before forwarding and written to a transfer_extension row
keyed on hyperswitchPaymentIntentId. The new intent starts at
requires_payment_method (or further along if you passed confirm: true and a
payment method).
Attach a payment method & confirm
For card-not-present checkout, the browser mounts the Hyperswitch Web SDK
(HyperLoader, via @mvs/mvs-pay-ui) using the intent’s client_secret, collects
the payment method, and confirms. Server-side you can call payments.confirm.
If the connector requires a customer step, the intent moves to
requires_customer_action and the challenge details flow back through Hyperswitch.
See Checkout UI Components.
Authorize / capture
With capture_method: "automatic" the intent goes to succeeded on a successful
connector response. With manual it settles at requires_capture and you call
payments.capture when ready (full or partial).
Reconcile off webhooks
Connector results arrive asynchronously. Reconcile your own records against the
PAYMENT_INTENT_* / WEBHOOK_REFUND_* webhook stream rather than trusting the
synchronous create/confirm response as final — see Webhooks are the source of
truth.
Surcharge enrichment on create (M27)
When an org has surcharging enabled, POST /v1/payments injects a Hyperswitch
surcharge_details block into the intent before forwarding. The handler looks
up the tenant’s default MerchantExtension, reads defaultSurchargePercent
(stored as a fraction, e.g. 0.035 → 3.5%) and defaultSurchargeCap (in
cents), and constructs a percentage surcharge — falling back to a fixed
surcharge at the cap when pct * amount would exceed it.
Two honest caveats, documented in routes/payments.ts:
- A caller-supplied
surcharge_detailsalways wins — operators can override the org default per payment. - Hyperswitch
surcharge_detailsin v1.123.1 has no dedicatedcapfield, so the cap clamp happens client-side in the handler (the percentage block is swapped for a fixed-amount block at the cap).tax_on_surcharge: 0is set as a placeholder when a cap exists. - Surcharge enrichment is best-effort: a transient DB miss is logged and the
payment proceeds without
surcharge_detailsrather than failing.
For the surcharging model end to end, see Tax & Compliance.
Refunds
Refunds are first-class objects, not reversal transfers. A refund is created
against a captured payment intent and references its parent via
payment_id. The route is POST /v1/refunds
(apps/mvs-pay-extensions/src/routes/refunds.ts), forwarding to Hyperswitch
POST /refunds.
Multiple partial refunds are allowed up to the captured amount. As refunds
apply, the parent intent’s status transitions to partially_refunded and
finally refunded. Refund resolution arrives asynchronously via the
WEBHOOK_REFUND_SUCCESS / WEBHOOK_REFUND_FAILURE webhooks — the create call
returns the in-flight refund, not its final disposition.
Refund rules
The refund route does not write a transfer_extension sidecar row — that
table is created on payment intent creation only. The refund handler logs a PHI
audit event (refund.create) and forwards to Hyperswitch. CreateRefundInput
accepts payment_id, optional amount (omit for a full refund), optional
reason, and optional metadata.
Voids vs refunds
To release an authorization before it is captured, cancel the intent rather than refunding it.
Idempotency
There are two distinct idempotency layers in the path of a write. Understand both — they protect against different failure modes.
Layer 1 — MVS per-(org, route, body) middleware
apps/mvs-pay-extensions/src/middleware/idempotency.ts is mounted on
POST /v1/payments, /v1/payment-links, /v1/refunds, /v1/payouts,
/v1/instant-payouts, /v1/customers, and /v1/plaid/exchange. When a request
carries an idempotency-key header it:
- Computes
sha256(idempotencyKey + ":" + rawBody). - Looks up an
IdempotencyRecordkeyed on(organizationId, route, bodyHash). - On a non-expired hit, replays the cached JSON response without invoking the handler (so Hyperswitch is never touched twice).
- On a miss, runs the handler and persists the response with a 24h TTL.
Behavioral details that matter:
- Only 2xx JSON responses are cached. Replaying a 5xx would defeat the client’s retry safety net, so errors are never cached and a retry re-runs the handler.
GET/HEADand keyless requests bypass the middleware entirely.- The key is scoped per organization, so two tenants can reuse the same client key string without colliding.
- A concurrent-write unique violation (
P2002) is treated as success — both writers produced the same response. - A DB lookup failure fails open: the handler runs anyway. The trade-off is explicit in the code — “rather risk a duplicate than fail a legitimate write.”
Layer 2 — Hyperswitch X-Idempotency-Key
The same idempotencyKey is forwarded to Hyperswitch by the HTTP client
(hyperswitch-client.ts) as the X-Idempotency-Key request header on
payments.create, payments-family writes, refunds.create, payouts.create,
and connector/routing/GSM writes. This is Hyperswitch’s own dedupe and is what
ultimately prevents a double charge at the connector if Layer 1 fails open.
Always pass a stable idempotency key on every write — payments.create,
payments.capture, and refunds.create are all idempotency-aware. Use a key
derived from your own domain object (e.g. checkout_${orderId},
refund_${paymentId}_full) so a network retry never double-charges.
Layer 1 keys on a hash of idempotencyKey + body. If you reuse a key but change
the body, the bodyHash differs and you get a fresh execution — not a replay.
Use a new key for a genuinely new request, and keep the body identical when retrying.
Webhooks are the source of truth
The synchronous create/confirm/capture response is a snapshot, not a verdict.
Capture confirmation, refund completion, payout settlement, and disputes all
resolve asynchronously through Hyperswitch’s outbound webhooks, ingested at
POST /webhooks/hyperswitch
(apps/mvs-pay-extensions/src/routes/webhooks-hyperswitch.ts).
- Hyperswitch fires the UPP-normalized event families
PAYMENT_INTENT_*,WEBHOOK_REFUND_*,WEBHOOK_DISPUTE_*,MANDATE_*, andPAYOUT_*. - Each payload is signature-verified with an HMAC-SHA512 hex of the raw body
using the merchant’s
payment_response_hash_key(exposed via Infisical asHYPERSWITCH_WEBHOOK_SECRET), sent in thex-webhook-signature-512header, with a SHA-256 fallback atx-webhook-signature-256. - Every verified event is persisted as a
WebhookDeliveryAttemptrow, idempotent onhyperswitchEventId. On dispatch success it is markedprocessed; on failure it stayspending,attemptCountincrements, and the route returns 500 so Hyperswitch retries on its own backoff. The TemporalwebhookDlqDrainerWorkflowre-runspendingrows every 5 minutes (see Temporal Worker).
Reconcile your records against this stream rather than assuming the create response is final.
Handling declines
A failed connector response surfaces in two ways:
- As
status: "failed"on the intent, or - As a thrown error. The extensions client raises
HyperswitchApiError, whichmapHyperswitchErrortranslates into an HTTP response of{ error: { code: "HYPERSWITCH_ERROR", details: <hyperswitch body> } }at the upstream status code. The SDK surfaces this as anMvsPayApiErrorwithcode: "HYPERSWITCH_ERROR"; the connector decline reason is indetails.
The HTTP client retries 5xx and network failures with exponential backoff (up to 3 attempts) and fails fast on 4xx, all behind a per-(method, path) circuit breaker. A 4xx decline is therefore returned immediately, not retried.
Tenant correlation & multi-tenant isolation
- Correlation. Pass
tenant_patient_id,tenant_location_id,tenant_invoice_id,tenant_user_id,tenant_purchase_id, etc. onpayments.create. They are stripped from the Hyperswitch body and persisted to thetransfer_extensionsidecar, linking the payment back to your system without polluting the Hyperswitch object. See Data Model. - Persist the
payment_id. Store the Hyperswitchpayment_idagainst your own record — it is the key for fetching status, issuing refunds, and reconciling against webhooks. - List is org-scoped in the gateway. Hyperswitch’s shared admin key lists
payments across every org, so
GET /v1/paymentspost-filters the result to the caller’s own payments using themetadata.mvs_org_idstamped at create time. Without this filter an authenticated tenant could enumerate every org’s payments. This is a gateway-side guard, not a Hyperswitch-side one.
Auth context is partial today. The userId on PHI audit events is undefined
because mvs-auth has not landed yet — this is deferred per
ADR-010 · Internal secret / deferred auth.
Tenancy is resolved from the internal-secret + org-slug contract, not an
authenticated user.
IIAS substantiation on healthcare payments (M26)
When a create call carries any healthcare line item
(healthcare_clinic_amount, healthcare_dental_amount,
healthcare_prescription_amount, healthcare_vision_amount), the handler runs
the IIAS 90%-rule (evaluateNinetyPercentRule from @mvs/mvs-pay-sdk/iias) and
persists an iias_substantiation row linked to the transfer_extension. Rows
that pass are marked substantiationMethod = "iias"; everything else is flagged
"manual" for operator review. This is orthogonal to the lifecycle status but
happens inside the same create transaction. See the
IIAS Substantiation runbook.
Card-present (terminal) sales
POS terminal sales also resolve to a Hyperswitch payment intent, routed to a
card-present connector, and typically use manual capture (authorize on
tap/insert, then capture). Terminal inventory is MVS-specific. The flow is the
same state machine driven over the /v1/devices surface — see
Devices & POS.
Where settlement and payouts fit
The lifecycle above ends at succeeded / refunded from the payment point of
view. Moving funds to the merchant bank is a separate surface modelled through
payouts (/v1/payouts) and Plaid-backed instant payouts
(/v1/instant-payouts), with their own PAYOUT_* webhook family. Settlement
timing and fees are determined by the routed connector and the merchant’s payout
configuration. See Payouts & Instant Payouts.
Related
Feature-level walkthrough: SDK usage, surcharging, IIAS, reporting.
Why payments are Hyperswitch intents and what the Finix model used to be.
How a declined failed intent can be retried against another connector.
The post-succeeded chargeback lifecycle and the WEBHOOK_DISPUTE_* family.