Revenue Recovery

Dunning, delegated to Hyperswitch

When a recurring renewal declines, recovering that decline (dunning) means deciding when to retry, which connector to retry on, and what retry curve to use per BIN / MCC / time-of-day. MVS Pay does not build that orchestration. Per ADR-011, retry timing and connector selection are handed off to Hyperswitch Revenue Recovery (a first-class Juspay feature as of router v1.123.1). MVS keeps the parts it owns end-to-end: subscription state, billing line items, invoice generation, and the operator-facing recovery-plan UI.

The result is a thin brokering surface. mvs-pay-extensions proxies three Hyperswitch recovery resources, and a single signed webhook flows the other way to advance local BillingSubscription state. Nothing in MVS schedules a retry, picks a connector, or models a BIN curve.

This page is the feature-level “how it works / how to operate it” walkthrough. For the decision and its rationale (the >20 retry signals, the rejected alternatives), read ADR-011. For the broader “prefer native Hyperswitch” stance this is an instance of, see ADR-007 and the Hyperswitch pivot. For the wire contract, see the API Reference.

Division of ownership

The seam is deliberately drawn so that MVS never re-implements dunning intelligence, and Hyperswitch never holds the system-of-record subscription state.

ConcernOwnerWhere it lives
Subscription state machine (ACTIVE → PAST_DUE → UNPAID)MVSBillingSubscription.status in packages/mvs-pay/prisma/schema.prisma
Billing line itemsMVSBillingSubscriptionItem
Invoice generationMVSBillingInvoice
Operator UI for the recovery planMVS/revenue-recovery surface in apps/mvs-pay
Retry timing (BIN-aware curves)HyperswitchRevenue Recovery engine
Connector selection per attemptHyperswitchSmart Retries / Dynamic Routing
Recovery-rate analyticsHyperswitch/analytics/v1/recovery/stats

The rationale (from ADR-011) is that the retry decision is exactly where Hyperswitch’s >20 signals live — BIN issuer profile, MCC-specific curves, time-of-day windows, cross-connector decline correlation, velocity throttles. The “hybrid” option (MVS schedules, Hyperswitch executes) was rejected for precisely this reason: splitting the decision from the execution loses the value of the native engine.

The two-way seam

The handoff is bidirectional and the two directions have different trust models.

1

MVS → Hyperswitch: configure the plan

mvs-pay-extensions posts the merchant’s retry plan and registers a callback URL. This is an authenticated, idempotent outbound call (the typed client adds idempotency-key passthrough).

2

Hyperswitch → MVS: outcome webhooks

For each retry attempt that resolves (succeeded / failed / pending), Hyperswitch POSTs a signed event to /webhooks/revenue-recovery. The handler HMAC-verifies it and advances local subscription state. This is the only writer of the three recovery columns.

MVS → Hyperswitch (the proxy)

The route handler is apps/mvs-pay-extensions/src/routes/revenue-recovery.ts, mounted at /v1/revenue-recovery (see apps/mvs-pay-extensions/src/app.ts). It exposes three resources, each a thin proxy over the typed Hyperswitch client (services/hyperswitch-client.ts, the revenueRecovery namespace):

MVS endpointMethodProxies to (Hyperswitch)
/v1/revenue-recovery/planPOST/account/{merchant_id}/recovery/plan
/v1/revenue-recovery/plan?merchantId=GET/account/{merchant_id}/recovery/plan
/v1/revenue-recovery/subscription-platformPOST/account/{merchant_id}/recovery/subscription_platform
/v1/revenue-recovery/stats?from=&to=&merchantId=GET/analytics/v1/recovery/stats

The plan body is validated with Zod before it leaves MVS (planInputSchema in routes/revenue-recovery.ts):

1const planInputSchema = z.object({
2 merchantId: z.string().min(1),
3 retryBudget: z.number().int().nonnegative(),
4 startAfter: z.object({
5 value: z.number().int().positive(),
6 unit: z.enum(["minutes", "hours"]),
7 }),
8 maxRetries: z.number().int().min(1).max(10),
9 cadence: z.enum(["aggressive", "moderate", "conservative"]),
10});

So MVS owns the shape of the plan (budget, start-after window, max retries ≤ 10, and a cadence preset), but Hyperswitch owns what those presets mean in terms of actual retry spacing and connector choice. The proxy is honest about its passthrough nature: the client typed the Hyperswitch responses as unknown (request<unknown>(...) in services/hyperswitch-client.ts), so the GET /plan and GET /stats payloads are returned verbatim from the router — MVS does not re-model them.

The POST /plan response is synthesized locally, not echoed from Hyperswitch. The handler returns { ...body, updatedAt: new Date().toISOString() } after a successful configure(...) call. If Hyperswitch normalizes or rejects a field server-side, the MVS response will not reflect that — it reflects what was sent. Treat GET /plan as the source of truth for the persisted plan.

Registering the callback (subscription-platform)

POST /v1/revenue-recovery/subscription-platform tells Hyperswitch where to send outcome events. The body carries merchantId, webhookUrl, and a webhookSecret (≥ 16 chars):

1const subscriptionPlatformInputSchema = z.object({
2 merchantId: z.string().min(1),
3 webhookUrl: z.string().url(),
4 webhookSecret: z.string().min(16),
5});

The client remaps these to Hyperswitch’s snake_case wire fields (webhook_url, webhook_secret) when calling /account/{merchant_id}/recovery/subscription_platform. That webhookSecret is what the inbound handler uses to HMAC-verify callbacks — it mirrors the payment_response_hash_key pattern used by the main webhook ingest at routes/webhooks-hyperswitch.ts.

Secret-wiring caveat — read before going to production. The subscription-platform body accepts a per-merchant webhookSecret, but the inbound webhook handler does not look it up per merchant. It verifies against a single process-wide secret, config.hyperswitch.webhookSecret, which is sourced from the HYPERSWITCH_WEBHOOK_SECRET environment variable (apps/mvs-pay-extensions/src/config/index.ts). For verification to succeed, the webhookSecret you register here must equal whatever HYPERSWITCH_WEBHOOK_SECRET is set to. The “per-merchant payment_response_hash_key” language in the code comments and in ADR-011 describes the intended pattern, not the current wiring. Flag this if you move to true per-merchant secrets.

Inbound: the outcome webhook

The ingest is apps/mvs-pay-extensions/src/routes/webhooks-revenue-recovery.ts, mounted at /webhooks/revenue-recovery alongside the main /webhooks/hyperswitch ingest. Both share the same global rate-limit envelope (bucketKey: () => "global-revenue-recovery") and are signature-verified inside the handler rather than via internal-secret auth (see app.ts).

Verification

The handler reads the raw body and accepts either an SHA-512 or SHA-256 HMAC, in x-webhook-signature-512 / x-webhook-signature-256 respectively, compared in constant time:

1const verified =
2 (sig512 && verifySignature(rawBody, sig512, secret, "sha512")) ||
3 (sig256 && verifySignature(rawBody, sig256, secret, "sha256"));

If HYPERSWITCH_WEBHOOK_SECRET is unset the handler returns 503 not_configured (it does not silently accept). A signature mismatch returns 401 signature_invalid.

Mapping an event to a subscription

The payload carries a content.object with an outcome and a locator. MVS maps the event to a BillingSubscription using, in order of preference:

  1. subscription_id → matched against BillingSubscription.id
  2. hyperswitch_mandate_id → matched against BillingSubscription.hyperswitchMandateId (the unique mandate column)

If neither is present, or no subscription matches, the handler acks with { ok: true, ignored: ... } rather than erroring — this stops Hyperswitch from retrying a dead event. The ack/ignore branches are logged at warn.

The dunning state machine (MVS side)

This is the part MVS owns. The webhook is the single writer of three columns on BillingSubscription (added in migration 20261001000000_phase4_revenue_recovery):

ColumnTypeMeaning
recoveryStatusString?"pending" | "succeeded" | "failed" | null
lastRecoveryAttemptDateTime?Timestamp of the most recent attempt (audit trail)
recoveryAttemptCountInt (default 0)Total Hyperswitch-driven retry attempts

computeTransition(...) derives the new state purely from the inbound outcome and the final flag. Hyperswitch is the timing authority, so MVS only records the latest attempt — it never schedules the next one.

Concretely, from computeTransition:

Inbound outcomefinalrecoveryStatus writtenrecoveryAttemptCountBillingSubscription.status
succeedednull (cleared)prev + 1ACTIVE
failedfalse"pending"prev + 1PAST_DUE
failedtrue"failed"prev + 1UNPAID
pending"pending"unchangedPAST_DUE

Two subtleties worth internalizing:

  • On success the recoveryAttemptCount is not reset — it stays as the final tally so you can see how many attempts it took. Only recoveryStatus is cleared to null.
  • The pending outcome (Hyperswitch announcing it scheduled the next attempt but hasn’t run it) does not increment the count. Only an actual failed attempt advances the tally.

A terminal failed (final: true) flips the subscription to UNPAID. This is the highest-risk transition in the whole feature.

Operational coupling (from ADR-011). Because the terminal-failure decision lives in Hyperswitch, a Revenue Recovery regression that mis-classifies a final outcome can prematurely flip MVS subscriptions to UNPAID. The mitigations of record are: alerting on mvs_pay.smart_retry.score_window (the Phase-3 SLO carried forward) and the lastRecoveryAttempt audit trail. There is no automatic un-flip; recovering from a bad UNPAID is a manual operation.

Persistence and idempotency

The update writes all three columns plus the conditional status transition. On a DB failure the handler returns 500 persistence_failed (so Hyperswitch will retry the delivery). Note the handler does not dedupe on event_id — a re-delivered failed event will increment recoveryAttemptCount again. The count is therefore best read as “attempts MVS observed,” not a guaranteed exact match of Hyperswitch’s internal counter.

The feature flag

ADR-011 states the native option is “live behind a feature flag, so the cost of trying it is bounded — if it underperforms MVS-side dunning, we can roll the flag back, keep the SDK contract, and swap the implementation underneath.”

Honest status: the rollback flag is a decision-level intent, not a wired gate. A grep across apps/mvs-pay-extensions/src, the Next API routes under apps/mvs-pay/app/api/revenue-recovery, and the SDK namespace turns up no REVENUE_RECOVERY_ENABLED-style env var, no feature-flag service (LaunchDarkly / Unleash / internal), and no conditional that disables the proxy or the webhook. The routes always forward to Hyperswitch. The only related toggle that actually exists is Hyperswitch’s own is_auto_retries_enabled on the merchant business_profile (referenced by the Smart Retries route), which gates auto-retries inside the router — not an MVS-side kill switch.

The “swap the implementation underneath” property does hold structurally: the SDK contract (packages/mvs-pay-sdk/src/namespaces/revenue-recovery.ts) and the /v1/revenue-recovery/* shape are stable, so an MVS-side dunning engine could be dropped in behind them later. But today, rolling back means changing code or disabling auto-retries on the Hyperswitch side — not toggling an MVS flag.

Surfaces

The feature is reachable from three layers, each a thin pass-through to the one below it.

The Next.js API route (apps/mvs-pay/app/api/revenue-recovery/route.ts) wraps the SDK for the dashboard: GET ?merchantId= reads the plan and POST writes it; stats live at the symmetric /api/revenue-recovery/stats. All of these are behind the org-session auth (getPaymentsContext) and ultimately resolve to the same mvs-pay-extensions proxy.

Operating notes for the new owner

  1. HYPERSWITCH_WEBHOOK_SECRET is set on mvs-pay-extensions (otherwise the ingest returns 503).
  2. POST /v1/revenue-recovery/subscription-platform has been called with a webhookSecret equal to that env var (see the secret-wiring caveat above).
  3. POST /v1/revenue-recovery/plan succeeded and GET /v1/revenue-recovery/plan returns the persisted plan from Hyperswitch.
  4. The merchant’s subscriptions have a hyperswitchMandateId (recurring) — one-shot subscriptions without a mandate keep that column null and cannot be located by the mandate fallback.

Two independent views:

  • Authoritative analytics: GET /v1/revenue-recovery/stats?from=&to=&merchantId= (recovered amount + recovery rate, computed by Hyperswitch).
  • Local audit: the recoveryStatus / lastRecoveryAttempt / recoveryAttemptCount columns on BillingSubscription, written by the webhook. If lastRecoveryAttempt is stale while subscriptions sit in PAST_DUE, the webhook delivery or signature verification is likely failing — check the [webhooks/revenue-recovery] logs for signature mismatch or not_configured.
  • No MVS-side feature flag / kill switch (see above). Rollback is code-level or via Hyperswitch’s is_auto_retries_enabled.
  • Single global webhook secret, not per-merchant, despite the payment_response_hash_key framing.
  • No event_id dedupe in the webhook — re-deliveries can double-count attempts.
  • POST /plan echoes the request, not Hyperswitch’s stored plan — read via GET /plan to confirm what persisted.
  • Premature UNPAID risk on a bad final outcome, with no automatic un-flip.