Routing & Smart Retries

Connector selection and decline recovery

MVS Pay decides which connector authorizes a payment and what happens when a connector declines. Both decisions run inside Hyperswitch, not in MVS code. The orchestration service (apps/mvs-pay-extensions) and the SDK (@mvs/mvs-pay-sdk) only configure the rules and flip the toggles; the actual selection and retry logic execute in the Hyperswitch router on every payment.

This is a direct application of ADR-007: Maximum native Hyperswitch bias — surface every Hyperswitch feature through the SDK or UI rather than rebuilding a parallel engine MVS-side. Routing, the decision-engine MAB, and Smart Retries are listed in that ADR as the canonical “native features are first-class consumers” cases.

Three independent layers, all delegated to Hyperswitch. Static rules pick a connector deterministically; dynamic routing (the MAB) overrides the static choice for a configurable share of traffic; Smart Retries recovers a declined attempt by retrying on an alternate connector. They compose — a payment can be MAB-routed and then Smart-Retried — but each is toggled separately on the merchant’s Hyperswitch business profile.

How the pieces fit together

The three configuration surfaces map cleanly onto the SDK:

LayerSDK surfaceService route groupBacked by
Static routing rulesclient.routing.*/v1/routingHyperswitch /routing resource
Dynamic routing (MAB)client.routing.dynamic.*/v1/routing/dynamic/*Hyperswitch + decision-engine
Smart Retriesclient.smartRetries.*/v1/smart-retriesHyperswitch business_profile + /gsm

Source of truth:

  • apps/mvs-pay-extensions/src/routes/routing.ts
  • apps/mvs-pay-extensions/src/routes/routing-dynamic.ts
  • apps/mvs-pay-extensions/src/routes/smart-retries.ts
  • packages/mvs-pay-sdk/src/namespaces/routing.ts, routing-dynamic.ts, smart-retries.ts
  • packages/mvs-pay-sdk/src/data/gsm-seed.ts

Static routing rules

A static routing rule is a Hyperswitch routing algorithm scoped to a business profile. MVS Pay supports the four upstream variants, validated at the service edge in routing.ts against a local Zod mirror of the SDK’s CreateRoutingInput discriminated union (the SDK exports types only, so the runtime schema is re-declared inline — keep both in lockstep when the union grows).

algorithm.typedata shapeBehavior
single{ connector, merchant_connector_id? }Always route to one connector.
prioritystring[]Try connectors in order; first available wins.
volume_split[{ connector, split }]Split traffic by percentage.
volume_split_priority[{ split, output: string[] }]Volume split where each bucket is itself a priority list.
advanced{ default_selection, rules[] }Conditional rules (the rule-DSL); falls back to default_selection.

The Zod routingAlgorithmSchema in routing.ts accepts all five variants including volume_split_priority. The rule-DSL editor UI (apps/mvs-pay/app/(dashboard)/routing/page.tsx) only exposes four — single, priority, volume_split, and advanced. volume_split_priority is reachable via the SDK / API but has no first-class control in the operator UI; use the Control Center deep-link for it.

The advanced rule-DSL

The advanced variant carries a list of rules, each with a name, a routing_type (priority or volume_split), an output (connector list or weighted split), and statements. Each statement is an array of conditions:

1{
2 "lhs": "amount", // field to test
3 "comparison": "greater_than", // equal | not_equal | greater_than |
4 // less_than | contains | not_contains
5 "value": { "type": "number", "value": 5000 } // type: number | str |
6 // str_array | number_array
7}

A full advanced algorithm posted through the SDK:

1await client.routing.create({
2 name: "High-value cards to Adyen",
3 profile_id: profileId,
4 algorithm: {
5 type: "advanced",
6 data: {
7 default_selection: { connector: "stripe" },
8 rules: [
9 {
10 name: "big-ticket",
11 routing_type: "priority",
12 output: ["adyen", "cybersource"],
13 statements: [
14 {
15 condition: [
16 {
17 lhs: "amount",
18 comparison: "greater_than",
19 value: { type: "number", value: 5000 },
20 },
21 ],
22 },
23 ],
24 },
25 ],
26 },
27 },
28});

Lifecycle: create, activate, deactivate

Creating a rule does not activate it. Activation is a separate step, profile-scoped. The service exposes:

MethodRouteNotes
create(input)POST /v1/routingPass ?activate=true to create + activate in one call.
list(query)GET /v1/routingFilter by profile_id; supports limit / offset.
get(id)GET /v1/routing/:idFetch one algorithm.
activate(id)POST /v1/routing/:id/activateMakes the rule the active one for its profile.
deactivate(profileId)POST /v1/routing/deactivateProfile-scoped — clears the active rule for a profile, not for an algorithm id.

On activate/deactivate the service also writes a mirror of the active algorithm id onto the tenant’s MerchantExtension row. setActiveRoutingId() finds the MerchantExtension whose hyperswitchProfileId matches the rule’s profile_id and overwrites routingAlgorithmId; if no profile matches it falls back to the tenant’s isDefault row. This lets the operator UI surface the active rule without an extra Hyperswitch round-trip.

The mirror is best-effort and authoritative-on-Hyperswitch-only. If the MerchantExtension write misses (no profile match and no default row), setActiveRoutingId() is a no-op — the active rule is still correct on Hyperswitch’s side, but the UI mirror will be stale. The live rule is always the one Hyperswitch reports, never the mirror. The same caveat applies to the Smart Retries mirror below.

Simulating a rule before activation

The operator UI calls POST /api/routing/simulate (a Next.js route handler in apps/mvs-pay, not a service route). The page comment states the simulator runs the same evaluator the rule-DSL editor targets, so the “Simulate” button is faithful to the real engine. The simulate response carries chosen, candidates[], matchedRule, and notes[].

/api/routing/simulate is the safe way to validate an advanced rule against a sample payment payload before you activate it. There is no separate SDK method for simulation — it is a UI-only convenience endpoint.

The rule-DSL editor is deliberately a thin shell over Hyperswitch’s algorithm JSON. When a rule is too complex for the editor (or you need volume_split_priority, or any field the editor doesn’t expose), the footer deep-links the Hyperswitch Control Center routing screen:

https://hyperswitch.payments.internal/routing

This URL is hardcoded in apps/mvs-pay/app/(dashboard)/routing/page.tsx (HYPERSWITCH_CONTROL_CENTER_ROUTING_URL) because the Control Center lives outside the per-org surface and is not threaded through env. It is an internal hostname; it resolves only from inside the cluster / VPN, not from the public internet.

Dynamic routing — the decision-engine MAB

Dynamic routing lets Hyperswitch override the static choice using runtime signals. The connector recommendation is computed by the decision-engine, a Rust service from Juspay that runs a Multi-Armed Bandit (MAB) over per-connector success rates. The Hyperswitch router queries it over an in-cluster ClusterIP on every payment where the profile has dynamic routing enabled. See the Decision-engine runbook for deploy and operation.

routing-dynamic.ts exposes six pass-through routes. There is no MVS-side logic beyond tenant-scoping and Zod validation — every route forwards to Hyperswitch’s per-profile dynamic-routing toggles.

SDK methodRoutePurpose
dynamic.toggleSuccessBased({ enable })POST /v1/routing/dynamic/success-based/toggleTurn the MAB on (dynamic_connector_selection) or off (none).
dynamic.setSuccessBasedSplit({ splitPct })POST /v1/routing/dynamic/success-based/split% of traffic routed by the MAB (0100). Remainder follows the static rule.
dynamic.toggleElimination({ enable })POST /v1/routing/dynamic/elimination/toggleDrop a connector that fails too often in a sliding window.
dynamic.toggleContract({ enable })POST /v1/routing/dynamic/contract/toggleHonor signed connector volume contracts.
dynamic.getConfig()GET /v1/routing/dynamic/configRead which modes are enabled and their params.
dynamic.getScoreWindow()GET /v1/routing/dynamic/score-windowRead the rolling per-connector success scores.

The three modes are orthogonal and may be enabled together:

  • success-rate-based — the MAB exploits the highest-scoring connector, exploring others a fixed fraction of the time.
  • elimination — temporarily drops a connector that flaps (fails repeatedly in a sliding window) so the next payment skips it.
  • contract — routes a fixed percentage to a connector to satisfy a signed volume SLA.
1// Route 20% of card volume through the MAB; the rest follows the static rule.
2await client.routing.dynamic.toggleSuccessBased({
3 merchantId,
4 profileId,
5 enable: "dynamic_connector_selection",
6});
7await client.routing.dynamic.setSuccessBasedSplit({
8 merchantId,
9 profileId,
10 splitPct: 20,
11});

All six dynamic routes require both merchantId and profileId. The GET routes return 400 missing_query if either query param is absent; all routes return 400 tenant_required if the org context header is missing (construct the MvsPayClient with orgId / orgSlug).

MAB tuning parameters

These live in the decision-engine chart values (deploy/decision-engine/values.yaml), not in MVS application code. They are documented here because they explain the routing behavior an engineer will observe:

ParameterValueMeaning
min_aggregates_size200A connector needs ≥ 200 MAB samples before its score is trusted; below that the router falls back to the static rule.
exploration_percent2020% of MAB-routed traffic explores random connectors instead of always exploiting the top score — prevents getting stuck on one connector.
specificity_levelENTITYPer-merchant Bandits; no cross-merchant score leakage.
decisionEngine.timeout_ms100Router gives the decision-engine 100ms; on timeout it falls back to the static rule. (In deploy/hyperswitch/values-prod.yaml.)

Fallback is silent and frequent during warm-up. Until a connector accumulates 200 samples, or if the engine times out (100ms) or its url is empty, the router uses the static rule with no error. If /routing/dynamic-stats shows all connectors with identical scores, the most likely cause is traffic below min_aggregates_size or the engine being in its exploration phase — not a bug. An empty score-window (entries: []) means the MAB has not yet hit 200 samples for any connector.

Reading scores

1const window = await client.routing.dynamic.getScoreWindow({
2 merchantId,
3 profileId,
4});
5// window.entries: [{ connector, successRate, sampleSize, ... }]

State lives in Redis, shared across decision-engine replicas, so the score window is global to the merchant, not per-pod. The operator UI surfaces this read-only at /routing/dynamic-stats. The successRateScoreReconcileWorkflow (Temporal, Track D) also consumes getScoreWindow to emit Datadog metrics — see the Temporal worker surface.

To disable dynamic routing globally without uninstalling the engine, set decisionEngine.url: "" in deploy/hyperswitch/values-prod.yaml (the router then falls back to static rules); Argo CD reconciles in ~60s. Full rollback in the Decision-engine runbook.

Smart Retries — native decline recovery

Smart Retries is Hyperswitch’s native auto-retry engine. When a connector declines, Hyperswitch consults the gateway_status_map (GSM) to decide whether to retry the attempt on an alternate connector, escalate to step-up 3DS, or surface the decline. The retry engine runs inside Hyperswitch — the /v1/smart-retries routes only configure the rules and the on/off switch.

Smart Retries is two cooperating halves:

1

Profile flag

POST /v1/smart-retries/enable flips is_auto_retries_enabled: true and sets max_auto_retries_enabled on the merchant’s Hyperswitch business_profile (defaults to 3 when maxRetries is omitted; capped at 10 by the Zod schema). /disable sets it back to false. Both mirror the state onto MerchantExtension.smartRetriesEnabled / maxAutoRetries via syncSmartRetriesFlag(), keyed by hyperswitchProfileId.

2

GSM rules

CRUD over Hyperswitch’s /v1/gsm admin API decides, per (connector, flow, code), what to do at decline time. POST /v1/smart-retries/gsm/seed bulk-loads the default rule set for a connector.

1// Enable (maxRetries defaults to 3 server-side if omitted)
2await client.smartRetries.enable({ merchantId, profileId, maxRetries: 3 });
3// → { enabled: true }
4
5await client.smartRetries.disable({ merchantId, profileId });
6// → { enabled: false }

A retry needs somewhere to retry to. If only one merchant_connector_account is configured, there is no alternate connector and declines will never retry even with auto-retries enabled. Confirm a second connector exists — see Connector Config. The other common cause of “declines never retry” is a missing GSM rule for the (connector, code) pair.

The gateway_status_map (GSM)

A GSM rule maps a connector’s decline code to a decision. The relevant fields:

FieldMeaning
connectorConnector name, or "*" for a network-level wildcard fallback.
flow / sub_flowThe payment flow the code applies to (e.g. Authorize).
codeThe connector’s raw decline / status code.
statusThe connector’s status string.
decisionretry or do_not_retry.
error_categorysoft / hard / auth / network (classification).
step_up_possibleIf true, the failure can be retried with step-up 3DS.
clear_pan_possibleIf true, retry by re-sending the clear PAN (tokenization failures).

Wildcard rows (connector: "*") apply to every connector unless a more specific (connector, code) rule overrides them — Hyperswitch’s GSM lookup respects the fallback pattern.

Seeded default rules

POST /v1/smart-retries/gsm/seed with { "connector": "<name>" } iterates a server-side DEFAULT_GSM_RULES list and posts every rule matching the requested connector plus the wildcard rules.

DEFAULT_GSM_RULES is duplicated in two places that must be kept in sync by hand. The authoritative seed dataset is packages/mvs-pay-sdk/src/data/gsm-seed.ts (typed CreateGsmRuleInput[]), but apps/mvs-pay-extensions/src/routes/smart-retries.ts re-declares the same list locally — because the extensions service must not depend on the SDK (the SDK is a consumer of the service, not a peer). The two lists are identical today; if you edit one, edit the other.

The default list covers stripe, adyen, cybersource, braintree, and finix, plus one network-level wildcard. The pattern is: soft declines → retry (usually with step_up_possible: true), hard declines → do_not_retry.

ConnectorCode → decision (selected)
stripecard_declined → retry (soft, step-up); insufficient_funds / expired_card / incorrect_cvc → do_not_retry (hard)
adyen2 Refused → retry; 11 3DS-required → retry (auth, step-up); 5 BlockedCard / 6 ExpiredCard → do_not_retry
cybersource201 DECLINED → retry; 475 auth-required → retry (auth); 204 INSUFFICIENT_FUNDS → do_not_retry
braintree2000 do_not_honor → retry; 2001 insufficient_funds / 2008 account_length → do_not_retry
finixGENERIC_DECLINE → retry; INSUFFICIENT_FUNDS / EXPIRED_CARD → do_not_retry
* (all)tokenization_error → retry with clear_pan_possible: true (network)
1await client.smartRetries.seedDefaults({ connector: "stripe" });
2// → { rulesSeeded: n } (single round-trip; extensions iterates server-side)

Seeding is idempotent. Re-posting an existing (connector, code, sub_flow) tuple returns a < 500 “already exists” error from Hyperswitch, which the seed loop swallows and continues. The route returns { rulesSeeded: n } counting only newly created rules — so a re-seed of an already-seeded connector correctly returns rulesSeeded: 0. The connector wizard auto-seeds on connector creation, which is why the call must never fail the wizard. rulesSeeded: 0 can also mean the connector simply has no default rules.

GSM CRUD

The remaining routes pass straight through to Hyperswitch’s /v1/gsm:

SDK methodRoute
listGsmRules({ connector? })GET /v1/smart-retries/gsm
createGsmRule(input)POST /v1/smart-retries/gsm
updateGsmRule(id, partial)PATCH /v1/smart-retries/gsm/:id
deleteGsmRule(id)DELETE /v1/smart-retries/gsm/:id
1// Override a seeded rule to stop retrying a code
2await client.smartRetries.updateGsmRule(ruleId, { decision: "do_not_retry" });

For the full operational procedure (enable, seed, verify, troubleshoot), see the Smart Retries runbook.

What lives where

Gaps & caveats

  • Nothing is deployed yet. Like the rest of the platform, the decision-engine is built but not provisioned — Redis, the Argo CD application, and ExternalSecrets must be stood up before the MAB does anything. See Deployment Readiness.
  • Two copies of DEFAULT_GSM_RULES (gsm-seed.ts and smart-retries.ts) drift if not edited together. There is no test asserting they match.
  • The routing Zod schema and the SDK CreateRoutingInput union are manually kept in lockstep. Adding a variant means editing both routing.ts and the SDK types.
  • volume_split_priority is API-reachable but has no operator-UI control.
  • The MerchantExtension mirrors (active routing id, Smart Retries flag) are best-effort caches for the UI, not sources of truth. They can silently drift; Hyperswitch is always authoritative.
  • The Control Center deep-link hostname (hyperswitch.payments.internal) is hardcoded and internal-only — it will 404 from outside the cluster/VPN.
  • /api/routing/simulate is a Next.js UI route, not part of the SDK or the extensions service; consumers cannot call it programmatically.