Revenue Recovery
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.
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.
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):
The plan body is validated with Zod before it leaves MVS
(planInputSchema in routes/revenue-recovery.ts):
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):
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:
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:
subscription_id→ matched againstBillingSubscription.idhyperswitch_mandate_id→ matched againstBillingSubscription.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):
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:
Two subtleties worth internalizing:
- On success the
recoveryAttemptCountis not reset — it stays as the final tally so you can see how many attempts it took. OnlyrecoveryStatusis cleared tonull. - The
pendingoutcome (Hyperswitch announcing it scheduled the next attempt but hasn’t run it) does not increment the count. Only an actualfailedattempt 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 /revenue-recovery page in apps/mvs-pay lets an operator configure the
plan and read recovery stats.
client.revenueRecovery.{configure, getPlan, setSubscriptionPlatform, getStats}
in packages/mvs-pay-sdk.
The /v1/revenue-recovery/* HTTP contract exposed by mvs-pay-extensions.
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
Pre-flight checklist before enabling recovery for a merchant
HYPERSWITCH_WEBHOOK_SECRETis set onmvs-pay-extensions(otherwise the ingest returns503).POST /v1/revenue-recovery/subscription-platformhas been called with awebhookSecretequal to that env var (see the secret-wiring caveat above).POST /v1/revenue-recovery/plansucceeded andGET /v1/revenue-recovery/planreturns the persisted plan from Hyperswitch.- The merchant’s subscriptions have a
hyperswitchMandateId(recurring) — one-shot subscriptions without a mandate keep that columnnulland cannot be located by the mandate fallback.
How do I tell if recovery is actually working?
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/recoveryAttemptCountcolumns onBillingSubscription, written by the webhook. IflastRecoveryAttemptis stale while subscriptions sit inPAST_DUE, the webhook delivery or signature verification is likely failing — check the[webhooks/revenue-recovery]logs forsignature mismatchornot_configured.
Known gaps to track
- 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_keyframing. - No
event_iddedupe in the webhook — re-deliveries can double-count attempts. POST /planechoes the request, not Hyperswitch’s stored plan — read viaGET /planto confirm what persisted.- Premature
UNPAIDrisk on a badfinaloutcome, with no automatic un-flip.
Related reading
- ADR-011: Hand off dunning to Hyperswitch Revenue Recovery — the decision and rejected alternatives.
- ADR-007: Native Hyperswitch bias — the broader stance.
- Routing & Smart Retries — the related retry/connector-selection surface and
is_auto_retries_enabled. - Subscriptions & Billing — the
BillingSubscriptionlifecycle this feature drives. - SDK — the
revenueRecoverynamespace contract. - Payment Lifecycle — the underlying payment-intent state machine.