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:

┌─────────────────────────┐ confirm ┌─────────────────────────┐
│ requires_payment_method │───────────────────▶│ requires_confirmation │
└─────────────────────────┘ └────────────┬────────────┘
│ confirm
┌──────────────────────────────────┐
│ requires_customer_action (3DS) │
└────────────────┬─────────────────┘
│ authenticated
(capture_method = manual) ▼
┌──────────────────┐ ◀────────────── ┌──────────────────────────┐
│ requires_capture │ │ processing / authorized │
└────────┬─────────┘ capture └────────────┬─────────────┘
│ capture │ (capture_method = automatic)
└───────────────────────┬────────────────────┘
┌──────────┐ refund ┌──────────────────┐
│ succeeded│────────────────────▶ │ partially_refunded│
└──────────┘ │ / refunded │
│ └──────────────────┘
│ cancel (pre-capture) ─▶ cancelled
│ decline ─────────────▶ failed

Status definitions

StatusDescription
requires_payment_methodIntent created; no payment method attached yet.
requires_confirmationPayment method attached; awaiting confirmation.
requires_customer_actionA customer step is needed (e.g. a 3DS challenge or a redirect-based authentication).
processingSubmitted to the connector; awaiting the authorize/capture result.
requires_captureAuthorized, awaiting a manual capture (capture_method = manual).
succeededCaptured / settled successfully. This is the terminal happy-path state before any refund.
partially_capturedPart of an authorization was captured.
cancelledVoided before capture.
failedDeclined or errored at the connector.
partially_refunded / refundedOne or more refunds have been applied (see Refunds).

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").

MethodBehaviorResulting status on success
automatic (default)Authorize and capture in one step.Goes straight to succeeded.
manualAuthorize only. Funds are held but not pulled.Settles at requires_capture; you call payments.capture later.

manual is the “auth-then-capture” / card-hold pattern. Capture can be full or partial:

1// Capture a manual-capture intent — full or partial.
2// `amount_to_capture` is forwarded only when not null, so an explicit 0
3// is honored as a partial capture rather than silently becoming a full one.
4await mvsPay.payments.capture(paymentId, { amount_to_capture: 4250 });

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.

VerbExtensions routeHyperswitch callSDK method
CreatePOST /v1/paymentsPOST /paymentspayments.create
GetGET /v1/payments/:idGET /payments/:idpayments.get
ConfirmPOST /v1/payments/:id/confirmPOST /payments/:id/confirmpayments.confirm
CapturePOST /v1/payments/:id/capturePOST /payments/:id/capturepayments.capture
Cancel (void)POST /v1/payments/:id/cancelPOST /payments/:id/cancelpayments.cancel
ListGET /v1/paymentsGET /payments/listpayments.list
1

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).

2

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.

3

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).

4

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_details always wins — operators can override the org default per payment.
  • Hyperswitch surcharge_details in v1.123.1 has no dedicated cap field, 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: 0 is 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_details rather 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.

1// Full refund
2const refund = await mvsPay.refunds.create(
3 { payment_id: paymentId, amount: 5000, reason: "customer_request" },
4 { idempotencyKey: `refund_${paymentId}_full` },
5);
6
7// Partial refund — $20.00 of a $50.00 capture
8await mvsPay.refunds.create(
9 { payment_id: paymentId, amount: 2000 },
10 { idempotencyKey: `refund_${paymentId}_partial_1` },
11);

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

RuleDetails
Max amountTotal refunds cannot exceed the captured amount.
Multiple refundsAllowed, up to the captured amount.
TimingSubject to the routed connector’s refund window.
Parent statusDrives the parent intent to partially_refunded / refunded.

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.

1await mvsPay.payments.cancel(paymentId, { cancellation_reason: "voided" });
AspectCancel / voidRefund
WhenBefore capture (requires_capture / authorized)After capture (succeeded)
Result statuscancelledpartially_refunded / refunded
Customer viewAuthorization drops offCredit appears
Best forMistakes, abandoned authsReturns, post-settlement adjustments

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:

  1. Computes sha256(idempotencyKey + ":" + rawBody).
  2. Looks up an IdempotencyRecord keyed on (organizationId, route, bodyHash).
  3. On a non-expired hit, replays the cached JSON response without invoking the handler (so Hyperswitch is never touched twice).
  4. 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/HEAD and 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_*, and PAYOUT_*.
  • 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 as HYPERSWITCH_WEBHOOK_SECRET), sent in the x-webhook-signature-512 header, with a SHA-256 fallback at x-webhook-signature-256.
  • Every verified event is persisted as a WebhookDeliveryAttempt row, idempotent on hyperswitchEventId. On dispatch success it is marked processed; on failure it stays pending, attemptCount increments, and the route returns 500 so Hyperswitch retries on its own backoff. The Temporal webhookDlqDrainerWorkflow re-runs pending rows 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, which mapHyperswitchError translates into an HTTP response of { error: { code: "HYPERSWITCH_ERROR", details: <hyperswitch body> } } at the upstream status code. The SDK surfaces this as an MvsPayApiError with code: "HYPERSWITCH_ERROR"; the connector decline reason is in details.

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. on payments.create. They are stripped from the Hyperswitch body and persisted to the transfer_extension sidecar, linking the payment back to your system without polluting the Hyperswitch object. See Data Model.
  • Persist the payment_id. Store the Hyperswitch payment_id against 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/payments post-filters the result to the caller’s own payments using the metadata.mvs_org_id stamped 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.

Dashboard / POS mvs-pay-extensions Hyperswitch Connector / Terminal
│ │ │ │
│ create payment ───────▶│ POST /payments ─────▶│ route + authorize ─▶│
│ │ │ │ tap / insert
│ │ │◀──── authorized ─────│
│ (manual capture) │ POST /capture ──────▶│ capture ───────────▶│
│ │ │◀──── captured ───────│
│◀── PAYMENT_INTENT_SUCCESS (webhook) ──────────│ │

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.