Disputes & Mandates
Disputes & Mandates
This page covers two distinct but adjacent surfaces in mvs-pay-extensions:
- Disputes — the chargeback lifecycle: reading disputes Hyperswitch surfaces from connectors, attaching and finalizing evidence, and the MVS-only sidecar metadata (alerts, assignment, deadlines) layered on top.
- Mandates — the authorization underneath recurring payments: listing, reading, revoking, and the amend orchestration MVS synthesizes because Hyperswitch has no native amend endpoint.
Both are thin Hono routers in apps/mvs-pay-extensions/src/routes/ that delegate
to the typed Hyperswitch client (apps/mvs-pay-extensions/src/services/hyperswitch-client.ts)
and, where MVS adds value, to per-org extension tables in
packages/mvs-pay/prisma/schema.prisma. Hyperswitch is the system of record for
the money-moving state; MVS owns only the workflow metadata around it. This is
the native-Hyperswitch bias at
work — we wrap, we do not reimplement.
Every route on this page is mounted under /v1 behind the gateway middleware
(app.use("*", authMiddleware())), so a resolved tenant.organizationId is
present on the request context. The order is auth → rate-limit → idempotency →
audit-log → handler (see apps/mvs-pay-extensions/src/app.ts). See
Authentication for how the internal-secret +
x-mvs-org-id contract resolves the tenant.
Disputes (chargebacks)
Routes
All dispute routes live in apps/mvs-pay-extensions/src/routes/disputes.ts,
mounted at /v1/disputes (api.route("/disputes", disputeRoutes)).
The SDK mirrors these as client.disputes.list / get / submitEvidence / submit / updateExtension (packages/mvs-pay-sdk/src/namespaces/disputes.ts). Full
request/response shapes are in the API Reference under the disputes
resource.
There are two distinct submit steps, and they are not interchangeable.
POST /:id/evidence (/disputes/evidence) attaches a piece of evidence.
POST /:id/submit (/disputes/evidence/submit) finalizes the attached bundle
and hands it to the connector for review — this is the irreversible one. Attach
all evidence first, then submit exactly once.
Evidence submission flow
The attach step (submitEvidence) merges the caller’s JSON body into the
Hyperswitch request as { dispute_id, ...body } — the client method injects
dispute_id, so callers do not pass it. Hyperswitch’s evidence schema (document
types, field names) is owned upstream; mvs-pay-extensions passes the body through
untouched.
The dispute extension sidecar
GET /v1/disputes/:id augments Hyperswitch’s payload with the MVS-only row, when
one exists, under a mvs_extension key:
The sidecar is DisputeExtension in packages/mvs-pay/prisma/schema.prisma,
keyed by a globally unique hyperswitchDisputeId and owned by exactly one
organizationId:
Because hyperswitchDisputeId is globally unique across all tenants, both
POST /:id/submit and PATCH /:id/extension run an ownership pre-check: if a
sidecar row already exists for a different org, the route returns 404 not_found
rather than 403. This is deliberate — a 403 would leak the existence of
another tenant’s dispute. Do not “fix” the 404 into a 403.
PATCH /:id/extension also strips organizationId and hyperswitchDisputeId
from the request body before writing, so the caller can never reassign tenancy or
identity columns via mass assignment:
autoAcceptAt and platformPriority exist as columns for an
operator-driven auto-accept workflow, but there is no scheduler today that acts
on autoAcceptAt — it is metadata only. If you build the auto-accept job,
that’s a net-new worker, not an existing one.
PHI audit logging
GET /:id, POST /:id/evidence, and POST /:id/submit all emit a phiAudit.logAccess
record (@mvs/logs) with resourceType: "dispute" and a subAction of
dispute.read, dispute.submit_evidence, or dispute.submit_evidence_final.
This is the HIPAA access trail — dispute evidence can reference patient billing,
so reads and writes are logged even though the patientId field is passed empty
here. Do not strip these calls when refactoring.
Webhook-driven dispute events
Hyperswitch fires WEBHOOK_DISPUTE_* lifecycle events to
apps/mvs-pay-extensions/src/routes/webhooks-hyperswitch.ts. Per the handler’s
own docstring the intended fan-out is to “create a Notification on
WEBHOOK_DISPUTE_OPENED, etc.” — routing of dispute events into the notification
/ alert tables is owned by the webhook dispatcher
(apps/mvs-pay-extensions/src/services/webhook-dispatcher.ts), not by the dispute
routes themselves. Verified webhooks are persisted as WebhookDeliveryAttempt
rows (idempotent on hyperswitchEventId) and re-driven by the Temporal
webhookDlqDrainerWorkflow on failure. See
Authentication for webhook signature verification
(HMAC-SHA512 of the raw body using the merchant’s payment_response_hash_key,
exposed as HYPERSWITCH_WEBHOOK_SECRET).
Rate limiting: dispute routes use the disputes bucket
(MVS_PAY_RATE_LIMIT_DISPUTES_*, default 600 requests / 60s), configured in
apps/mvs-pay-extensions/src/middleware/rate-limit.ts and applied in app.ts to
both /disputes and /disputes/*.
Honest gaps (disputes)
- No first-class evidence-bundle model in MVS. Evidence lives entirely in
Hyperswitch; MVS does not persist what was submitted. The sidecar records only
that a platform action happened (
lastPlatformActionAt), not the contents. - No auto-accept scheduler.
autoAcceptAtis a column with no worker. - No dedicated dispute-event handler in the route layer. Dispute notification fan-out is the dispatcher’s responsibility and should be verified there before relying on it.
Mandates (recurring authorization)
A mandate is the standing authorization a customer grants for off-session
recurring charges. In MVS, the mandate is only the payment authorization — the
subscription-of-record is BillingSubscription in mvs-pay. The mandate id is
stored on the subscription and is what recurring captures ride.
Routes
All mandate routes live in apps/mvs-pay-extensions/src/routes/mandates.ts,
mounted at /v1/mandates.
SDK equivalents: client.mandates.list / get / revoke / amend
(packages/mvs-pay-sdk/src/namespaces/mandates.ts).
Creation is not its own endpoint here. A mandate is minted as a side effect
of a payment intent that carries setup_future_usage: "off_session" (or
on_session) plus a mandate_data.customer_acceptance block — handled on the
payments surface. There is no POST /v1/mandates. The amend route below uses
exactly this setup-mandate flow to mint a replacement.
Amend: why it is an orchestration, not a passthrough
Hyperswitch has no mandate-amend endpoint. A mandate’s amount cap is fixed at creation. To change it, mvs-pay-extensions revokes the old mandate and creates a fresh one. This is the single most surprising thing on this page.
POST /v1/mandates/:id/amend runs a three-step server-side orchestration. The
caller must supply a positive new_amount (minor units) and a fresh
customer_acceptance object — most card schemes and bank rails (SEPA, BACS, ACH)
require renewed consent for an amount increase, which is why the route rejects a
request without it rather than silently re-using the old consent.
Fetch the old mandate
hyperswitch.mandates.get(mandateId) — used to carry customer_id and
payment_method forward when the caller omits them, and to return the pre-revoke
shape.
Revoke the old mandate
hyperswitch.mandates.revoke(mandateId). After this point the old mandate is
terminal and no further captures ride it.
Mint the replacement via a $0 setup intent
hyperswitch.payments.create({ amount: 0, confirm: true, setup_future_usage: "off_session", mandate_data: { customer_acceptance, mandate_type: { multi_use: { amount: new_amount, currency } } }, metadata: { mvs_amend_of_mandate, mvs_org_id } }). The mandate_id returned by this intent is re-fetched and returned as
newMandate.
The route returns both records so the caller can persist the transition: mark
oldMandate revoked and point the subscription at newMandate.mandate_id.
newMandate.status may be pending until the connector confirms the acceptance
block — poll client.mandates.get(newMandateId) until active and surface a
“confirming” state in the UI.
multi_use is mandatory for recurring. The replacement mandate is created
with mandate_type.multi_use, carrying the new per-transaction amount cap. A
single_use replacement would authorize exactly one off-session payment and
silently break the subscription’s future automatic captures. Do not change this
to single_use.
Idempotency and the dangerous middle state
The amend is multi-step and not atomic. The most important failure mode: the revoke succeeds but the setup-intent fails (e.g. the connector rejects the new acceptance). The subscription is now left with no live mandate.
Always pass a stable idempotency-key header (the SDK forwards it; derive it from
the subscription + amendment version, e.g. amend_${mandateId}_${version}).
Retrying without an idempotency key after a partial failure risks re-revoking an
already-revoked mandate. The idempotency key is forwarded only to the
setup-intent payments.create call — the revoke is not idempotency-keyed — so a
keyless retry is genuinely unsafe.
Validation and recovery, lifted directly from the runbook:
The setup intent stamps metadata.mvs_amend_of_mandate = <oldMandateId> and
metadata.mvs_org_id, so an amendment is traceable in Hyperswitch’s event stream.
A successful amend also logs [mandates] amend complete with the old/new mandate
ids and new amount.
SDK usage
Honest gaps (mandates)
- No native amend. The entire amend behavior is synthesized client-side over revoke + setup-mandate. There is no transaction wrapping the two; the middle-state window is real and must be handled by the caller.
- No
POST /v1/mandates. Mandate creation is implicit via the payments surface; there is no standalone create route or SDK method. - The revoke step is not idempotency-keyed, so retries lean entirely on the caller’s stable key and the runbook’s recovery table.
Where MVS ends and Hyperswitch begins
- Hyperswitch owns: dispute state and evidence contents; mandate state, amount caps, and acceptance validation.
- MVS owns: dispute workflow metadata (alerts, assignment, deadlines, notes), the subscription-of-record, and the amend orchestration that papers over the missing Hyperswitch endpoint.
Related
Source files referenced on this page
apps/mvs-pay-extensions/src/routes/disputes.tsapps/mvs-pay-extensions/src/routes/mandates.tsapps/mvs-pay-extensions/src/services/hyperswitch-client.tsapps/mvs-pay-extensions/src/routes/webhooks-hyperswitch.tsapps/mvs-pay-extensions/src/middleware/rate-limit.tsapps/mvs-pay-extensions/src/app.tspackages/mvs-pay/prisma/schema.prisma(DisputeExtension)packages/mvs-pay-sdk/src/namespaces/disputes.tspackages/mvs-pay-sdk/src/namespaces/mandates.tsdocs/runbooks/mandate-amendment.md