Extensions Service

apps/mvs-pay-extensions

apps/mvs-pay-extensions is the internal payment-orchestration service. It is a standalone Hono HTTP server that wraps Hyperswitch’s REST API with MVS-specific behaviour — extension-table persistence, Plaid instant payouts, POS device management, custom billing, settlement/residual logic — and exposes a single typed surface that every consumer reaches through the @mvs/mvs-pay-sdk.

It runs in the payments Kubernetes namespace on EKS, not as Next.js API routes inside apps/mvs-pay. It is the only service in the platform that holds the Hyperswitch admin API key and talks to the Hyperswitch router, and it sits outside the PCI cardholder-data ring.

This page documents the service’s shape and middleware. For the request/response contract of each route, see the API Reference tab — it is generated from the OpenAPI fragments under fern/openapi/fragments/ and is the authoritative source for payloads.

Why it exists as a separate service

Two ADRs pin down the two foundational choices. Read them in full; the summary here is deliberately short.

Separate EKS service, not Next API routes (ADR-006)

Hyperswitch orchestration could have lived in apps/mvs-pay’s Next.js API routes. It was extracted because:

  • Clean PCI / non-PCI boundary. Extensions sits outside the PCI ring; only Hyperswitch (router + card-vault) is inside. Keeping orchestration in its own process keeps the boundary auditable.
  • Cross-repo consumers. apps/mvs-pay is not the only caller — healthos and mvs-c2 call the extensions service directly over HTTP. tRPC-style in-process coupling does not work across repo boundaries.
  • Independent scaling, observability, and deployment cadence. The payment hot path scales and ships separately from the merchant/operator UI.
  • EKS operational features (mTLS, network policies) that Vercel functions don’t offer.

Hono, not tRPC (ADR-003)

The service is service-to-service infrastructure that needs typed routes, fast cold start, simple middleware, and easy request validation.

  • Hono 4.12.23, with @hono/zod-validator for request schema validation and the ContentfulStatusCode literal-union pattern for c.json(body, status) typing.
  • tRPC was rejected because the service is consumed by multiple repos over HTTP; tRPC’s tightly coupled types don’t map across repo boundaries. The typed @mvs/mvs-pay-sdk provides equivalent type safety over a stable HTTP contract.
  • Express / Fastify were rejected as heavier with weaker TS ergonomics.

PCI scope and the Hyperswitch boundary

This service does not hold raw card data, Finix/connector credentials, or the card vault. It never sees a PAN in the clear. Hyperswitch (router + card-vault) is the PCI boundary. See The Hyperswitch Pivot.

The only secret that lets this service mutate money movement is the Hyperswitch admin API key (HYPERSWITCH_ADMIN_API_KEY), loaded at runtime from Infisical. Connector credentials (e.g. Finix) live inside Hyperswitch’s merchant_connector_account configuration, managed through the Hyperswitch Control Center — never in this service’s environment. The .env.example contract is explicit about this:

$# Hyperswitch (no Finix credentials here — Hyperswitch holds them in
># its merchant_connector_account configuration via the control-center).
$HYPERSWITCH_API_BASE_URL=https://hyperswitch.payments.internal
$HYPERSWITCH_ADMIN_API_KEY=
$HYPERSWITCH_WEBHOOK_SECRET=

All outbound calls to Hyperswitch go through one typed client, src/services/hyperswitch-client.ts. It is JSON-in / JSON-out, reads the admin key from runtime config, retries on 5xx + network failures, and wraps each call in a circuit breaker (withCircuitBreaker from @mvs/cache, keyed hyperswitch:<METHOD>:<path>). Higher-level orchestration (combining a Hyperswitch call with extension-table writes) lives in the route handlers, not in the client.

Request flow

Everything is assembled in createHonoApp() in apps/mvs-pay-extensions/src/app.ts. The server entrypoint (src/server.ts, run as node dist/server.js) boots that app via @hono/node-server, listens on PORT (default 3004), and wires SIGINT / SIGTERM to a graceful shutdown that closes the HTTP server and the mvs-pay DB pool. Local dev uses tsx watch src/dev.ts.

On startup, if Infisical is configured (isInfisicalConfigured()), secrets are injected into process.env before config is parsed. If injection fails, the service logs and continues with whatever env vars are present — it does not hard-fail on Infisical.

The middleware chain

The order on the authenticated surface is fixed and load-bearing. From app.ts:

// Order matters: auth → rate-limit → idempotency → audit-log → handler.

Global middleware (app.use("*", …)) runs first for every request: CORS, then request logging (method, path, status, durationMs). The four chain stages below are mounted on the /v1 sub-app.

OrderMiddlewareFileMounted onPurpose
1authMiddleware()src/middleware/auth.ts/v1/* (and /admin/webhooks/dlq)Validate internal secret, attach tenant context.
2rateLimit()src/middleware/rate-limit.tsper-route (payments, refunds, payouts, disputes) + webhooksRedis-backed per-(orgId, route) limit.
3idempotency()src/middleware/idempotency.tsspecific POST routesReplay cached responses for repeated idempotency-key.
4auditLog()src/middleware/audit-log.ts/v1/* (and /admin/webhooks/dlq)Persist a redacted AdminAuditLog row per mutation.

1. Auth — internal secret + tenant context

authMiddleware() enforces the service-to-service boundary only. Every consumer presents the rotated MVS_PAY_EXTENSIONS_INTERNAL_SECRET in the x-internal-secret header.

  • If internalSecret is unset on the service, it returns 503 GATEWAY_NOT_CONFIGURED and refuses the request.
  • The presented secret is compared in constant time (timingSafeEqual). A mismatch or missing header returns 401 UNAUTHORIZED.
  • Tenant context is read from x-org-id / x-org-slug. If neither is present it returns 400 TENANT_REQUIRED.
  • x-request-id is propagated (a synthetic req_… id is generated if absent), and { tenant, requestId } is set on the Hono context (GatewayContext in src/types.ts).

End-user authentication is deferred to the @mvs/auth-client integration in each consumer (mvs-pay UI, healthos, mvs-c2). This middleware proves only that a trusted caller is talking to the service — it does not establish a user identity. See ADR-010 · Internal secret / deferred auth and Authentication & Tenancy.

2. Rate limiting

rateLimit() is a Redis-backed sliding bucket via the @mvs/cache Redis client (INCR + EXPIRE on ratelimit:<scope>:<bucket>). The scope is the organizationId from tenant context; the bucket defaults to the request path.

  • It is mounted per route group (both the collection path and the /* subtree), not globally — payments, refunds, payouts, disputes.
  • Webhook ingest uses a shared global bucket (bucketKey: () => "global", allowMissingTenant: true) so a single misbehaving upstream cannot starve other tenants.
  • Fail-open semantics: on a Redis outage the limiter logs and lets the request through — taking traffic beats dropping legitimate writes during a cache hiccup. Over-limit responses return 429 RATE_LIMITED with a Retry-After header.

Limits come from loadRateLimitConfig(), overridable per environment:

BucketEnv (window / max)Default windowDefault max
paymentsMVS_PAY_RATE_LIMIT_PAYMENTS_{WINDOW_SEC,MAX}60 s600
refundsMVS_PAY_RATE_LIMIT_REFUNDS_{WINDOW_SEC,MAX}60 s60
payoutsMVS_PAY_RATE_LIMIT_PAYOUTS_{WINDOW_SEC,MAX}60 s60
disputesMVS_PAY_RATE_LIMIT_DISPUTES_{WINDOW_SEC,MAX}60 s600
webhooksMVS_PAY_RATE_LIMIT_WEBHOOKS_{WINDOW_SEC,MAX}1 s200

Routes without an explicit rateLimit() mount (e.g. customers, devices, settlements) are not rate-limited today. Only the four buckets above plus webhook ingest are covered. Per spec § M16.1.

3. Idempotency

idempotency() makes repeated mutations safe to retry. It is mounted only on the POST routes the spec calls out: /payments, /payment-links, /refunds, /payouts, /instant-payouts, /customers, and /plaid/exchange.

1

Bypass on read or no key

GET / HEAD requests and any request without an idempotency-key header skip the middleware entirely.

2

Hash the request

Computes sha256(idemKey + ":" + bodyText) and a route of "<METHOD> <routePath>". The body is read via c.req.text(), which Hono caches so downstream c.req.json() calls still work.

3

Replay on hit

Looks up an IdempotencyRecord keyed (organizationId, route, bodyHash). On a non-expired hit it returns the cached responseBody / responseStatus without invoking the handler.

4

Persist on miss

On a miss it runs the handler, then caches only success-shaped JSON (2xx + application/json) with a 24h TTL. Non-JSON or non-2xx responses are not cached — replaying a 5xx would defeat the safety net.

Failure handling is conservative: a lookup error proceeds to the handler (better a possible duplicate than a failed legitimate write), and a concurrent-write unique violation (P2002) is treated as a benign no-op. Per spec § M17.3.

4. Audit log

auditLog() persists one AdminAuditLog row per mutation (POST / PATCH / PUT / DELETE); it short-circuits on reads. It is best-effort — a failure to persist the row is logged but never fails the request (Datadog watches for that log line).

What it records: a derived action (<method>.<routePath>), a best-effort (entityType, entityId) inferred from the /v1/<entity>/<id> path, the requesting tenant in metadata, the response status, the client IP (x-forwarded-for), and the user agent.

Bodies and headers are redacted before storage. Sensitive headers (authorization, cookie, x-internal-secret, x-api-key, set-cookie) and sensitive body fields (card number, CVV, SSN, tax id, bank account / routing numbers, IBAN, passwords, PINs, secrets, tokens) become [REDACTED]. Any string that passes a Luhn check is masked to **** **** **** 1234. Bodies over 16 KB or non-JSON bodies are summarised, not stored raw.

Because the service is service-to-service, it does not know the real end user; AdminAuditLog.userId is set to the synthetic system:mvs-pay-extensions, and the actual user identity is carried inside the consumer. Per spec § M16.3.

The /v1/* surface

The authenticated API is a child Hono app mounted at /v1. Every route group is defined in its own file under src/routes/ and exposed through @mvs/mvs-pay-sdk. The contract for each lives in the API Reference tab.

Route groupPathFeature page
Payments/v1/paymentsPayments & Refunds
Refunds/v1/refundsPayments & Refunds
Payment links/v1/payment-linksPayment Links
Payment-method sessions/v1/payment-method-sessionsWallets & Click to Pay
Customers/v1/customers
Disputes/v1/disputesDisputes & Mandates
Mandates/v1/mandatesDisputes & Mandates
Payouts/v1/payoutsPayouts & Instant Payouts
Instant payouts/v1/instant-payoutsPayouts & Instant Payouts
Settlements/v1/settlements
Payout methods/v1/payout-methodsPayouts & Instant Payouts
Plaid/v1/plaidPayouts & Instant Payouts
Devices/v1/devicesDevices & POS
Merchants/v1/merchants
Routing (dynamic)/v1/routing/dynamicRouting & Smart Retries
Routing/v1/routingRouting & Smart Retries
Smart retries/v1/smart-retriesRouting & Smart Retries
Billable items/v1/billable-itemsSubscriptions & Billing
Notifications/v1/notifications
Cost observability/v1/cost-observabilityCost Observability
Revenue recovery/v1/revenue-recoveryRevenue Recovery
IIAS export/v1/iias-exportTax & Compliance

Mount order matters in app.ts: routing/dynamic is registered before routing so the more specific path wins. If you add a nested route group, register it before its parent.

Unmatched routes return a structured 404 NOT_FOUND; unhandled exceptions are caught by app.onError and return 500 INTERNAL_ERROR (the underlying message is only echoed in development).

Webhook ingest — /webhooks/*

Webhook ingress is not authenticated by the internal secret — these are inbound calls from external systems. Each endpoint instead verifies a signature inside its handler and is protected by the shared global rate-limit bucket (allowMissingTenant: true).

PathSourceFileVerification
/webhooks/hyperswitchHyperswitch routersrc/routes/webhooks-hyperswitch.tsHMAC of raw body
/webhooks/revenue-recoveryHyperswitch (recovery)src/routes/webhooks-revenue-recovery.tssignature in handler
/webhooks/plaidPlaidsrc/routes/webhooks-plaid.tsverified when PLAID_WEBHOOK_SECRET set

Hyperswitch webhook handling

The main ingest at /webhooks/hyperswitch is the canonical path for lifecycle events (PAYMENT_INTENT_*, WEBHOOK_REFUND_*, WEBHOOK_DISPUTE_*, MANDATE_*, PAYOUT_* — the UPP-normalized event set).

  1. Verify. Hyperswitch signs each payload with an HMAC of the raw body using the merchant’s payment_response_hash_key (exposed to this service as HYPERSWITCH_WEBHOOK_SECRET). It checks SHA-512 in x-webhook-signature-512 first, with a SHA-256 fallback in x-webhook-signature-256. Constant-time compare. A failure returns 401; an unset secret returns 503.
  2. Persist before dispatch (M17.1). A WebhookDeliveryAttempt row is upserted, idempotent on hyperswitchEventId, so Hyperswitch redeliveries collapse onto one drain target. Already-processed events short-circuit with { ok: true, replay: true }.
  3. Dispatch. dispatchHyperswitchWebhook() (in src/services/webhook-dispatcher.ts) fans the event out into MVS extension tables — e.g. confirming a TransferExtension, opening a DisputeExtension
    • Notification on WEBHOOK_DISPUTE_OPENED, updating SettlementExtension approval state on PAYOUT_*. It applies a webhook-ordering check (M17.2): events older than the local row’s updatedAt (minus a 1 s skew tolerance) are skipped as stale. Unmapped event types fall through silently — Hyperswitch’s own events table remains the source of truth.
  4. Outcome. Success marks the row processed. A dispatch failure leaves it pending, increments attemptCount, records lastError, and returns 500 so Hyperswitch retries on its own backoff.

The dispatcher is a pure function reused in three places: inbound ingest, the admin DLQ retry, and the Temporal webhookDlqDrainerWorkflow that re-runs pending rows every 5 minutes (see the Temporal Worker).

Admin — DLQ replay (/admin/webhooks/dlq)

The dead-letter-queue admin surface (src/routes/admin/webhooks-dlq.ts, spec § M17.1) lets an operator inspect and manually retry failed webhook deliveries. It is tenant-scoped and sits behind the internal secret plus the audit-log middleware (auth → audit-log), but it is mounted outside /v1.

MethodPathBehaviour
GET/admin/webhooks/dlq?status=&limit=List WebhookDeliveryAttempt rows for the tenant. statuspending / processed / failed; limit defaults 100, capped 500.
POST/admin/webhooks/dlq/:id/retryRe-run dispatch for one row.

Retry behaviour: rows already processed return 409 ALREADY_PROCESSED; an unknown id returns 404; a successful retry marks the row processed and bumps attemptCount; a failed retry records lastError and returns 500 DISPATCH_FAILED. All rows are filtered by tenant.organizationId.

The WebhookDeliveryAttempt.organizationId column is nullable — the org id may not have been parsed when a row was first written — but the dispatcher backfills it on the first successful parse, so admin queries scope cleanly to tenant thereafter.

Health, readiness, and discovery

Five public, unauthenticated endpoints. None of them call Hyperswitch; they report only the service’s own liveness and whether the Hyperswitch connection is configured.

EndpointUsed byBehaviour
GET /Service identity ({ service, status, version }).
GET /healthhealth checks{ status, service, hyperswitch, uptime }.
GET /healthzK8s liveness probePlain text OK.
GET /readyzK8s readiness probe200 { status: "ready" } when Hyperswitch is configured; else 503 { status: "not_ready", reason: "hyperswitch_unconfigured" }.
GET /infodiscovery / SDKService metadata plus a map of the /v1 route groups and webhook paths.

isHyperswitchConfigured() returns true purely on the presence of HYPERSWITCH_ADMIN_API_KEY. /readyz does not make a live call to the Hyperswitch router — it does not detect a misconfigured key or a router outage, only an unset key. Treat readiness as “config present”, not “Hyperswitch reachable”.

CORS

CORS is applied globally as the first middleware. The allowed origin is resolved per environment by resolveCorsOrigin():

  • In development, origin is "*".
  • In every other environment, origins come from MVS_PAY_EXTENSIONS_CORS_ORIGINS (comma-separated) or default to https://pay.mvscloud.com, https://healthos.mvscloud.com, https://c2.mvscloud.com.
  • A "*" origin in a non-development environment throws at startup — a wildcard is not allowed in staging/production.

Allowed methods are GET, POST, PUT, PATCH, DELETE, OPTIONS; allowed headers include Authorization, x-internal-secret, x-org-id, x-org-slug, x-request-id, and idempotency-key; credentials: true.

Configuration

Config is parsed once by a Zod schema in src/config/index.ts and cached. Key inputs (full list in Environment Variables):

VariablePurpose
PORTListen port (default 3004).
NODE_ENVdevelopment / staging / production.
HYPERSWITCH_API_BASE_URLRouter base URL (default https://hyperswitch.payments.internal).
HYPERSWITCH_ADMIN_API_KEYAdmin key for privileged Hyperswitch operations.
HYPERSWITCH_WEBHOOK_SECRETMerchant payment_response_hash_key for webhook HMAC.
MVS_PAY_EXTENSIONS_INTERNAL_SECRETService-to-service secret (rotated weekly).
MVS_PAY_EXTENSIONS_CORS_ORIGINSAllowed CORS origins in non-dev.
REDIS_URLRate-limit + cache backend.
PLAID_*Plaid instant-payout credentials and webhook URL/secret.
INFISICAL_*Secret injection at boot.

In production, secrets are sourced from Infisical at /apps/mvs-pay/mvs-pay-extensions/{dev,prod} — see Secrets Management. For how the service is built, shipped, and probed, see Deployment Topology and GitOps & Kubernetes.

For the new owner

Things to keep honest as you work:

  • Auth here is a boundary, not a user check. Real user identity lives in the consumer. Don’t reach for userId in this service — it doesn’t have one (audit rows use the synthetic system:mvs-pay-extensions).
  • Rate limiting is partial. Only payments / refunds / payouts / disputes and webhook ingest have limits. Adding a new money-moving route? Mount a rateLimit() for it deliberately.
  • /readyz is a config probe, not a connectivity probe. It will report ready against a wrong-but-present admin key.
  • The audit log and rate-limiter both fail open. That is intentional, but it means neither is a hard control — pair them with monitoring, not assumptions.