Pin a dispatch target
By default the fingerprint reader discovers its own workstation over loopback: it polls http://localhost:18065/status every 3 seconds and reads /workstation to learn the id to dispatch on. That requires the SladeID companion application on the same host as the browser.
If you already know the workstation and device ids, there is nothing to discover. Supply both and the SDK skips loopback entirely:
import { createFingerprintReader } from '@sladeid/slade-id-sdk';
const reader = createFingerprintReader({
getToken: async () => fetchMiddlewareToken(),
middlewareUrl: 'https://middleware.example.com',
// Both, or neither. Supplying one throws a TypeError at construction.
workstationId: 'SQM-MACHINE-ID-FROM-THE-WORKSTATION',
deviceId: 'scanner-serial-number',
});
// Dispatches straight to that workstation. No /status poll ever starts.
await reader.enroll({ enrollee: 'subject-123', position: 2 });Why this works
Enroll, verify, and search have not run over loopback for some time. Each one is a job posted to the middleware orchestrator (/v1/orchestrator/jobs/), which routes it over NATS to a workstation, where the companion application performs the capture and the middleware finalises the result. The SDK polls the job until it reaches a terminal state.
So the only thing the local hardware service is ever asked in those flows is which workstation and which device should I name? — two strings on the dispatch payload:
┌──────────────┐ loopback: "which workstation / device?" ┌──────────────────────┐
│ browser +SDK │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ▶ │ local hardware svc │
└──────┬───────┘ pinning removes THIS edge only │ 127.0.0.1:18065 │
│ └──────────────────────┘
│ dispatch job (HTTPS + bearer)
▼
┌──────────────────────┐ NATS ┌───────────────────────┐
│ middleware │ ──────────▶ │ workstation + scanner │
│ orchestrator │ ◀────────── │ (companion app) │
└──────┬───────────────┘ result └───────────────────────┘
│ terminal job (polled by the SDK)
▼
enroll / verify / search resultPinning removes the discovery edge only. The dispatch path — the one that actually captures the fingerprint — is untouched.
What it does not do
Pinning does not remove the need for a live workstation. It is not a no-hardware mode.
The capture still happens on that remote machine, on a real scanner, with the companion application running. All you have moved is the discovery of its id. When the workstation is offline the middleware answers:
503 Target workstation is not currently live.That is the failure you will hit if you pin an id and expect the flows to work with nothing plugged in anywhere. It means “go switch that machine on”, not “retry harder”.
It does not arrive as a reader error. The 503 comes from the orchestrator dispatch call, before any job exists, so it surfaces as a NetworkError with code: 'SERVER_ERROR' and the middleware’s text in the message:
import { HwsError, NetworkError } from '@sladeid/slade-id-sdk';
try {
await reader.search({ position: 2 });
} catch (err) {
if (err instanceof NetworkError && err.code === 'SERVER_ERROR') {
// e.g. "Middleware returned 503: Target workstation is not currently live."
showOperator('The scanner workstation is offline.');
} else if (err instanceof HwsError) {
// Reader-level failures: device, timeout, validation, auth.
showOperator(err.code);
}
}NetworkError extends SladeIDError, not HwsError — so a catch block that only tests err instanceof HwsError will let an offline workstation fall through to your generic handler. Worth checking before you ship a pinned deployment, because with discovery you would have seen the workstation go away through disconnected instead.
What this unlocks
Hosts that could not run the flows before, because they cannot run the companion application or should not have to:
- A developer machine. Work against a shared scanner on a colleague’s workstation or a lab box without installing the Windows companion app locally.
- A server-side batch job. Node, no browser, no loopback service — drive verification against a known till.
- A tablet or thin client driving a shared scanner. The scanner is attached to a workstation behind the counter; the tablet is just the UI.
- A kiosk whose browser and scanner live on different machines. Common when the browser runs in a locked-down container.
The tradeoffs
Everything that came from the /status poll is gone, because the poll never starts.
| What | With discovery | Pinned |
|---|---|---|
reader.status | Live snapshot, refreshed every 3s | Stays empty: connected: false, isAuthed: false, devices: [] |
connected / disconnected | Fire as the companion app comes and goes | Never fire |
device-attached / device-detached | Fire per scanner | Never fire |
poll-failed | Fires on poll failure | Never fires — there is no poll |
refreshStatus() | Forces a fresh /status fetch | Still hits loopback; pointless on a host without the app |
testCapture() | Works | Still needs loopback — it is a direct hardware-service call |
enroll / verify / search | Orchestrated | Orchestrated, unchanged |
fetchEnrolled() | Direct middleware lookup | Direct middleware lookup, unchanged |
Two consequences worth designing for:
You lose your readiness signal. With discovery, connected plus a non-empty devices array told you the operator could scan. Pinned, you find out the workstation is down when a dispatch comes back 503. If you need a pre-flight check, either run one cheap dispatch and treat the 503 as “not ready”, or expose readiness from your own backend, which can query the middleware.
testCapture() is not available. It is the one action that talks straight to http://localhost:18065, so on a host without the companion app it fails with a ConnectionError (READER_CONNECTION_FAILED). Keep your “test your reader” diagnostic screen on the workstation itself.
Also note the device the SDK reports is synthesised, not discovered: it has your deviceId as both id and serialNumber, and empty name / manufacturer. Don’t render it in a device picker.
Sourcing the workstation id
The id you need is the SQM MachineId, which is what the orchestrator keys NATS delivery on. It is not the workstationId field on ReaderStatus — that one is a MachineGuid kept for reporting, and dispatching on it targets a subject the hardware service never subscribes to.
It has to come from out of band, from a machine that does have the companion application. On that workstation, ask its local service:
curl -H "Authorization: Bearer <middleware token>" http://localhost:18065/workstation
# { "work_station_id": "SQM-MACHINE-ID", ... }The id is machine-stable, so read it once during provisioning and store it with the rest of that till’s configuration — alongside the deviceId, which is the scanner’s serial number (visible in reader.status.devices[].serialNumber on that same machine, or on the device label).
Treat both as deployment configuration, not as something a user types. A wrong workstationId produces the same 503 as an offline workstation, and a wrong deviceId fails at capture time on the workstation.
SSR and framework code
A pinned reader has one more upside: because no poll starts, constructing one has no background cost. That does not make it safe at module scope in a server-rendered app — see Use the SDK with SSR frameworks — but it does remove the 3-second timer that makes an accidentally-early construction expensive.
See also
- Use a USB fingerprint reader — the full enroll / verify / search walkthrough.
FingerprintReaderConfig— every field, with defaults.- Orchestrator — the dispatch → poll job API the reader is built on.