Devices & POS Terminals
Devices & POS Terminals
MVS Pay keeps an inventory of card-present POS terminals. This survived the
Hyperswitch pivot as a first-class MVS concern — Hyperswitch does not model
POS hardware, so the Device table lives in the dedicated mvs-pay database
and is owned entirely by us.
The single most important thing to understand before you touch this code: the
device surface is largely read/inventory + reconcile today. You can list,
read, register/upsert, edit metadata, sync from a connector, and delete device
rows. You cannot drive the terminal hardware — the lifecycle commands a
payfac normally exposes (activate, action, idle-image) are honest 501
stubs left over from the decommissioned Finix integration. They are wired up,
authenticated, and documented as not-yet-implemented, deliberately, so callers
get a clear 501 instead of a silent call to a dead gateway.
The companion operational guide is the in-repo doc
apps/mvs-pay/docs/DEVICE_SETUP.md — registration fields, supported PAX/BBPOS
models, the dashboard form, and field-tech troubleshooting. This page is the
engineering reference for the data model and routes behind that guide. For exact request/response payloads, the
API Reference tab is authoritative.
Where the code lives
Devices span the two main surfaces. The truth is the extensions service; the Next.js app is a thin, scope-checked proxy in front of it.
The mounting is plain Hono — api.route("/devices", deviceRoutes) in
apps/mvs-pay-extensions/src/app.ts, advertised as devices: "/v1/devices" in
the service’s route index. See Extensions Service
for the surrounding middleware and the SDK page for the client.
The Device model
model Device in packages/mvs-pay/prisma/schema.prisma (mapped to the
device table). Every row is org-scoped, optionally bound to a
MerchantExtension, and references the underlying terminal by an opaque
connectorDeviceId. MVS application code never holds connector credentials.
Constraints worth knowing:
connectorDeviceIdis@uniqueglobally (across all orgs), and there is also a composite@@unique([organizationId, connectorDeviceId]).- Indexed on
organizationIdandtenantLocationId.
Several config-ish columns exist on the model but are storage only today —
idleMessage, allowStandaloneSales, allowStandaloneRefunds,
promptForSignature, signatureThreshold, and the software-version columns.
Nothing in the platform pushes these down to a terminal. They are read/write at
the row level (via sync or PATCH) but have no hardware effect until a terminal
command surface exists. Treat them as merchant-config metadata, not live device
control.
Routes — what is real
These are the routes the extensions service actually mounts under /v1/devices
(apps/mvs-pay-extensions/src/routes/devices.ts). All are tenant-scoped: every
handler reads tenant.organizationId, returns 400 tenant_required if absent,
and obtains a per-org client via getMvsPayDb(organizationId).
The @mvs/mvs-pay-sdk DevicesNamespace exposes exactly this surface and
nothing more — create, sync, list, get, update, delete. There is
no devices.activate, devices.action, or devices.idleImage method. That
absence is the contract.
Listing the inventory
listDevices (packages/mvs-pay/src/queries/devices.ts) defaults to
take: 100, skip: 0, orderBy: { createdAt: "desc" }. The query helper also
supports merchantExtensionId, enabled, limit, and offset filters that the
HTTP route does not currently expose — if you need them, plumb them through the
route, don’t reach around it.
Registering a device
POST /v1/devices is an upsert keyed on connectorDeviceId, not a strict
create. Calling it twice with the same connectorDeviceId updates the existing
row (this is upsertDevice under the hood). New rows default to
status: "INACTIVE" and enabled: false.
See apps/mvs-pay/docs/DEVICE_SETUP.md for the supported model codes
(PAX_A920PRO, PAX_A920, PAX_A800, D135, BBPOS_WISEPOS_E), the dashboard
registration form, and the physical pairing steps on the terminal itself.
Device sync & health
POST /v1/devices/sync is the reconciliation entry point a terminal connector /
POS agent calls to push device state into MVS Pay. It has two modes,
decided by whether the body carries a non-empty devices array:
Cross-tenant guard. Because connectorDeviceId is globally unique, a tenant
could otherwise overwrite another org’s row by colliding on a known
connectorDeviceId. The sync handler defends against this: before upserting it
runs db.device.findFirst({ where: { connectorDeviceId } }) and, if a row exists
under a different organizationId, it silently skips that entry
(continue). It does not error. If you’re debugging “my synced device didn’t
appear,” check for a connector-id collision against another org first.
What “health” means here
There is no active health-probe or heartbeat job. Device health is entirely connector-reported and pull/push-reconciled, not measured by MVS Pay:
connection/connectionStatusreflect the last state the connector told us via sync. MVS Pay never pings a terminal.lastConnectedAtis only advanced on aCONNECTEDsync; it is the closest thing to a liveness signal, and it is as fresh as the last sync call.statusis free-form and follows whatever the connector reports. TheDEVICE_SETUP.mdguide describes aREGISTERED → ONLINE ⇄ OFFLINEmental model, but the column is a plain string — nothing in this codebase enforces or transitions those values. Don’t write code that assumes a fixed enum.
A consequence: if a connector stops calling sync, a device row will stay frozen
at its last reported connectionStatus indefinitely. There is no TTL or
staleness sweep. Surface lastConnectedAt to operators rather than trusting
connectionStatus alone.
Terminal sales
A POS sale is not a device command. Post-pivot there is no
/v1/devices/:id/sale endpoint on the extensions service. The Next.js route
POST /api/devices/[id]/sale (apps/mvs-pay/app/api/devices/[id]/sale/route.ts)
is real, but it just creates an ordinary payment intent via payments.create,
carrying the originating device in metadata for downstream reconciliation:
It requires devices:write and a resolved merchant (requireMerchant: true)
and validates amount > 0 (integer cents). The extensions service resolves
connector routing for the org’s merchant. For the full payment-intent lifecycle
see Payments & Refunds and
Payment Lifecycle.
The variable name finixMerchantId in this handler is legacy — it’s the
auth context’s resolved merchant id, populated under the Hyperswitch model. The
name predates the pivot and hasn’t been renamed.
The 501 stubs — device lifecycle is not implemented
These routes exist in apps/mvs-pay/app/api/devices/ purely to return an honest
501 with a clear message. They authenticate (devices:write), await params,
and then return not-implemented. They are not wired to the extensions
service, the SDK exposes no method for them, and /v1/devices mounts no
corresponding surface.
Example payload they return:
Why 501 and not 404 or a silent forward?
These were the Finix-era device-control endpoints (terminal activate, remote
action/reboot, idle-image branding). Finix is decommissioned. The handlers’
own comments spell out the reasoning: returning an honest 501 is preferable to
“a silent call to the decommissioned payfac gateway with the wrong
secret/header.” They are kept as authenticated placeholders so:
- callers get an actionable, machine-readable error rather than a hang or a cryptic 5xx from a dead upstream;
- the route shape is preserved for whenever a real connector terminal-command
surface lands on
mvs-pay-extensions; - there’s no accidental coupling to credentials this service no longer holds.
When a terminal-command surface is built, it belongs on mvs-pay-extensions
under /v1/devices/... first, then the SDK namespace, then these proxies — not
as a direct connector call from the Next.js app.
Authentication & tenancy
Both the Next.js proxies and the extensions routes are tenant-scoped end to end:
- Next.js routes call
authenticateRequest(request, { requiredScope: ... })—devices:readforGET,devices:writeforPOST/PUT/sync/saleand the 501 stubs. They support both session auth (dashboard) and API-key auth (HealthOS / mvs-c2 integrations). - The extensions handlers derive the per-org Prisma client from
tenant.organizationIdviagetMvsPayDb(...)and scope every query/mutation byorganizationId. There is no cross-org read path; evenGET /:idfilters on the org. - The cross-tenant
connectorDeviceIdcollision guard insync(above) is the one place where global uniqueness meets tenancy — get familiar with it.
See Authentication & Tenancy for the scope model and how API keys resolve to an org and merchant.
Gaps & gotchas for the new owner
- Read-mostly surface. Inventory + reconcile + metadata edits + delete. No hardware control. Don’t promise terminal activation/reboot/branding from MVS Pay until a connector command surface exists.
- Config columns are inert.
idleMessage, signature prompts/thresholds, standalone-sale flags, and version columns are stored but never pushed to hardware. statusis a free-form string. TheREGISTERED/ONLINE/OFFLINEmodel inDEVICE_SETUP.mdis documentation, not an enforced enum. Validate before relying on specific values.- Health is only as fresh as the last sync. No heartbeat, no staleness
sweep, no TTL.
lastConnectedAtis your best liveness signal. - Upsert, not create.
POST /v1/deviceswill quietly update an existing row on aconnectorDeviceIdmatch. finixMerchantIdnaming in the sale handler is legacy — it’s the resolved Hyperswitch merchant.- Two filter sets.
listDevicessupports more filters than the HTTP route exposes; extend the route rather than bypassing it.
Related pages
The Hono service that mounts /v1/devices and owns the real surface.
client.devices.* — the only supported way to reach these routes.
Terminal sales are payment intents with source: "terminal" metadata.
Where Device sits relative to MerchantExtension and the org tree.
Authoritative request/response payloads for every /v1/devices route.
Scopes (devices:read / devices:write) and org/merchant resolution.