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.
Why a standalone EKS service and not Next.js API routes.
Why Hono 4.12.23 over tRPC / Express / Fastify.
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-payis not the only caller —healthosandmvs-c2call 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-validatorfor request schema validation and theContentfulStatusCodeliteral-union pattern forc.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-sdkprovides 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:
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.
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
internalSecretis unset on the service, it returns503 GATEWAY_NOT_CONFIGUREDand refuses the request. - The presented secret is compared in constant time (
timingSafeEqual). A mismatch or missing header returns401 UNAUTHORIZED. - Tenant context is read from
x-org-id/x-org-slug. If neither is present it returns400 TENANT_REQUIRED. x-request-idis propagated (a syntheticreq_…id is generated if absent), and{ tenant, requestId }is set on the Hono context (GatewayContextinsrc/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_LIMITEDwith aRetry-Afterheader.
Limits come from loadRateLimitConfig(), overridable per environment:
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.
Bypass on read or no key
GET / HEAD requests and any request without an idempotency-key header
skip the middleware entirely.
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.
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.
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).
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).
- Verify. Hyperswitch signs each payload with an HMAC of the raw body
using the merchant’s
payment_response_hash_key(exposed to this service asHYPERSWITCH_WEBHOOK_SECRET). It checks SHA-512 inx-webhook-signature-512first, with a SHA-256 fallback inx-webhook-signature-256. Constant-time compare. A failure returns401; an unset secret returns503. - Persist before dispatch (M17.1). A
WebhookDeliveryAttemptrow is upserted, idempotent onhyperswitchEventId, so Hyperswitch redeliveries collapse onto one drain target. Already-processedevents short-circuit with{ ok: true, replay: true }. - Dispatch.
dispatchHyperswitchWebhook()(insrc/services/webhook-dispatcher.ts) fans the event out into MVS extension tables — e.g. confirming aTransferExtension, opening aDisputeExtensionNotificationonWEBHOOK_DISPUTE_OPENED, updatingSettlementExtensionapproval state onPAYOUT_*. It applies a webhook-ordering check (M17.2): events older than the local row’supdatedAt(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.
- Outcome. Success marks the row
processed. A dispatch failure leaves itpending, incrementsattemptCount, recordslastError, and returns500so 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.
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.
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 tohttps://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):
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
The authoritative request/response contract for every route.
@mvs/mvs-pay-sdk — the only sanctioned way to call this service.
Why Hyperswitch, and where the PCI boundary lives.
Internal-secret model and deferred end-user auth.
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
userIdin this service — it doesn’t have one (audit rows use the syntheticsystem: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. /readyzis 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.