Disputes & Mandates

Chargebacks and recurring authorization

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

Method & pathPurposeHyperswitch callMVS sidecar effect
GET /v1/disputesList disputes (optional limit)disputes.list/disputes/listnone
GET /v1/disputes/:idRead one dispute, merged with sidecardisputes.get/disputes/:idreads disputeExtension (org-scoped)
POST /v1/disputes/:id/evidenceAttach an evidence file/fielddisputes.submitEvidence/disputes/evidencenone
POST /v1/disputes/:id/submitFinalize & submit evidence to connectordisputes.submitEvidenceFinal/disputes/evidence/submitupserts disputeExtension, stamps lastPlatformActionAt
PATCH /v1/disputes/:id/extensionEdit MVS-only workflow metadatanoneupserts disputeExtension

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:

1return c.json({ ...(dispute as object), mvs_extension: ext });

The sidecar is DisputeExtension in packages/mvs-pay/prisma/schema.prisma, keyed by a globally unique hyperswitchDisputeId and owned by exactly one organizationId:

Column groupFieldsNotes
IdentityhyperswitchDisputeId (unique), hyperswitchPaymentIntentId, hyperswitchMerchantIddispute id is server-controlled, never mass-assignable
AlertingalertPriority, alertAcknowledgedAt, alertAcknowledgedBy, emailSentAtpopulated by the alert/notification path
WorkflowplatformNotes, assignedToUserId, lastPlatformActionAt, autoAcceptAt, platformPriorityedited via PATCH /:id/extension
Soft deleteisDeleted, deletedAtrows are soft-deleted, never hard-deleted

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:

1const { organizationId: _o, hyperswitchDisputeId: _h, ...data } = body;

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. autoAcceptAt is 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.

Method & pathPurposeHyperswitch call
GET /v1/mandatesList mandates (optional customer_id)mandates.list/mandates/list
GET /v1/mandates/:idRead one mandatemandates.get/mandates/:id
POST /v1/mandates/:id/revokeRevoke (terminal) a mandatemandates.revoke/mandates/revoke/:id
POST /v1/mandates/:id/amendChange a mandate’s amount capsynthesized — revoke + setup-mandate

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.

1

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.

2

Revoke the old mandate

hyperswitch.mandates.revoke(mandateId). After this point the old mandate is terminal and no further captures ride it.

3

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:

SymptomCauseRecovery
400 new_amount must be a positive numbermissing/zero new_amountsupply a positive minor-unit amount
400 customer_acceptance is requiredomitted consent blockcollect fresh acceptance (IP + UA + timestamp) and resend
400 invalid_jsonunparseable request bodysend valid JSON
400 tenant_requiredno resolved organizationId on contextfix the auth/org header (see Authentication)
revoke succeeded, setup-intent failedconnector rejected the new acceptanceold mandate is revoked — subscription has no live mandate; create a brand-new mandate (not an amend) and update BillingSubscription
newMandate.status === "pending"connector hasn’t confirmed acceptancepoll client.mandates.get(newMandateId) until active

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

1import { MvsPayClient } from "@mvs/mvs-pay-sdk";
2
3const mvsPay = new MvsPayClient({
4 baseUrl: process.env.MVS_PAY_EXTENSIONS_URL!,
5 internalSecret: process.env.MVS_PAY_EXTENSIONS_INTERNAL_SECRET!,
6 orgId: tenant.mvsOrgId,
7});
8
9const { oldMandate, newMandate } = await mvsPay.mandates.amend(
10 {
11 mandateId: "man_existing",
12 newAmount: 4900, // new cap, minor units
13 customerAcceptance: {
14 acceptance_type: "online",
15 accepted_at: new Date().toISOString(),
16 online: { ip_address: req.ip, user_agent: req.headers["user-agent"] },
17 },
18 // optional — hydrated from the old mandate if omitted:
19 // currency, customerId, paymentMethod, paymentMethodType, profileId
20 },
21 { idempotencyKey: `amend_${mandateId}_${version}` },
22);

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.
  • apps/mvs-pay-extensions/src/routes/disputes.ts
  • apps/mvs-pay-extensions/src/routes/mandates.ts
  • apps/mvs-pay-extensions/src/services/hyperswitch-client.ts
  • apps/mvs-pay-extensions/src/routes/webhooks-hyperswitch.ts
  • apps/mvs-pay-extensions/src/middleware/rate-limit.ts
  • apps/mvs-pay-extensions/src/app.ts
  • packages/mvs-pay/prisma/schema.prisma (DisputeExtension)
  • packages/mvs-pay-sdk/src/namespaces/disputes.ts
  • packages/mvs-pay-sdk/src/namespaces/mandates.ts
  • docs/runbooks/mandate-amendment.md