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
Step-by-step amend procedure, failure modes, and billing-platform coupling.
Decline recovery for the recurring charges that ride an active mandate.
Dunning for failed recurring charges is handed to Hyperswitch Revenue Recovery.
Why disputes and mandates wrap rather than reimplement Hyperswitch.
Tenant resolution, internal-secret auth, and webhook signature verification.
Full request/response shapes for the disputes and mandates resources.
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