Fingerprint reader client
Drives a USB fingerprint reader (the contactful path) via the SladeID companion application. Biometric actions (enroll / verify / search) are dispatched through the middleware orchestrator; only device discovery and testCapture touch the local hardware service. See Use a USB fingerprint reader for the companion-app prerequisite, install links, and worked examples of enroll / verify / search.
Get one from a SladeID instance — sladeId.reader is a getter that needs no extra config and reuses the middleware auth and orchestrator:
const reader = sladeId.reader;Or construct a standalone client. middlewareUrl is required, and getToken returns a middleware (Keycloak) bearer (not a local-HWS token). localServiceUrl (default http://localhost:18065) points at the local /status + /test service:
import { createFingerprintReader, FingerprintReaderClient } from '@sladeid/slade-id-sdk';
const reader: FingerprintReaderClient = createFingerprintReader({
getToken: () => Promise.resolve('<middleware bearer token>'),
middlewareUrl: 'https://middleware.example.com',
// integratorId: 'my-integrator', // optional
// localServiceUrl: 'http://localhost:18065', // optional
// workstationId: 'SQM-MACHINE-ID', // optional, but only together
// deviceId: 'scanner-serial', // with each other — see below
});getToken is called on demand and again after a 401, so token rotation is transparent. The bearer is validated once, at the middleware. Throwing inside getToken surfaces as a ReaderAuthError to the caller.
Supplying workstationId and deviceId together pins the dispatch target: the client skips loopback discovery entirely and names that workstation on every orchestrator dispatch, which is what lets a host with no companion application drive enroll / verify / search. Supplying only one of the pair throws a TypeError at construction, because half a pinned target would silently fall back to discovery for the missing half. reader.status then stays empty and no device events fire. See Pin a dispatch target — it is not a no-hardware mode.
Methods
| Method | Returns | Notes |
|---|---|---|
reader.status | ReaderStatus | Synchronous snapshot from the last background poll. |
refreshStatus() | Promise<ReaderStatus> | Force an immediate /status fetch (e.g. before a device picker). |
enroll(input) | Promise<EnrollmentResult> | Register input.enrollee at input.position. Dispatched via the orchestrator. |
verify(input) | Promise<VerificationResult> | 1:1 match against the claimed enrollee. Dispatched via the orchestrator. |
search(input?) | Promise<SearchResult> | 1:N identify against the enrolled corpus. Optional { position } so the service applies that finger group’s tuned thresholds. Dispatched via the orchestrator. |
fetchEnrolled(enrollee) | Promise<EnrolledFingerprints> | List a subject’s enrolled positions. Direct middleware lookup; no hardware/device needed. |
testCapture(opts?) | Promise<TestCaptureResult> | Grab one frame; { normalized: true } for the preprocessed template. Talks directly to the local hardware service. |
createEnrollmentSession(input) | EnrollmentSession | Event-driven wrapper around enroll. |
createVerificationSession(input) | VerificationSession | Event-driven wrapper around verify. |
createSearchSession(input?) | SearchSession | Event-driven wrapper around search; takes the same optional SearchInput. |
dispose() | void | Stop polling, drop listeners. Subsequent actions throw. |
Inputs
EnrollmentInput and VerificationInput share the same shape:
| Field | Type | Required | Notes |
|---|---|---|---|
enrollee | string | yes | Subject identifier (e.g. beneficiary code). |
position | FingerPosition (0–10) | yes | 1–5 right hand, 6–10 left hand; 0 = minor / any. |
isMinor | boolean | no | Apply relaxed quality checks. |
attachments | Record<string, string> | no | Free-form metadata forwarded to the backend (e.g. encounter ids). |
SearchInput is separate, and every field is optional — the capture itself is the query, so a bare search() is still valid:
| Field | Type | Required | Notes |
|---|---|---|---|
position | FingerPosition (0–10) | no | The finger the subject is about to present. Defaults to 0 (“any finger”) server-side. |
Naming position is worth doing whenever you know it. The service resolves its matching thresholds per finger group, so telling it which finger is coming lets it apply the values tuned for that group instead of the untuned global ones — measurably fewer false negatives on thumbs. There is no isMinor or attachments on SearchInput: the backend’s search finaliser reads neither, so the SDK does not offer fields it would silently drop.
Results
interface EnrollmentResult {
template?: string; // now typically undefined — the orchestrated finaliser returns metadata under `raw`, not a device template
verifiedPositions?: number[]; // positions the service confirmed
raw: Record<string, unknown>; // backend finaliser payload: { fingerprint_id, enrollee, position, device_id }
}
interface VerificationResult {
matched: boolean; // false is a normal outcome, not an error
matchLogId?: string; // present when matched; persist for audit
verifiedPositions?: number[];
}
interface SearchResult {
found: boolean; // true only when an enrollee matched
enrollee?: string; // the matched subject id (single best match, not a ranked list)
similarity?: number; // 0..1 confidence when reported — threshold on THIS
matchScore?: number; // the raw matcher score; NOT the same scale as `similarity`
errorCode?: string; // set when the search FAULTED rather than found nobody
raw: Record<string, unknown>; // every server field, verbatim
}
interface EnrolledFingerprints {
verified: number[]; // positions already enrolled — safe to skip on resume
nonVerified: number[]; // captured but not yet confirmed by the backend
total: number; // total fingerprint records on file
enrollmentStatus: 'fully_enrolled' | 'partially_enrolled' | 'not_enrolled';
notes?: string; // enrollment notes, when present
raw: Record<string, unknown>;
}Reading a SearchResult
similarity and matchScore are not on the same scale and are deliberately kept as separate fields rather than merged. similarity is a 0..1 confidence; matchScore is whatever the matcher that decided the hit reported — sometimes a 0..1 similarity, sometimes an unbounded validator score. Threshold on similarity; record matchScore for audit. Both are promoted from the server payload only when they are finite numbers, so a NaN on the wire arrives as undefined rather than as a comparison that quietly evaluates false. Treat undefined as unknown, never as zero — a middleware revision that reports no scores still returns real matches.
errorCode (for example VALIDATOR_UNAVAILABLE) is the important one. A result with found: false and an errorCode means the search could not complete: it is a fault, not a confident “nobody matched”. The subject may well be enrolled. Branch on it before you show a verdict, and tell the operator to retry — never that the subject is not enrolled.
Reading an EnrolledFingerprints
fetchEnrolled(enrollee) is a direct middleware lookup against the enrolled_fingerprints endpoint (not orchestrated, not the local hardware service) — it selects no device and resolves even with no scanner attached. Use it to resume a paused multi-finger enrollment: skip the verified positions and prompt only for what’s missing.
enrollmentStatus has three members, and 'not_enrolled' is the one worth handling explicitly: it means the middleware has no records at all for the subject, which is what its empty-body response for an unknown subject decodes to. Previously that was indistinguishable from 'partially_enrolled', so a never-enrolled subject and a part-finished enrolment looked the same. Start a fresh enrolment on 'not_enrolled'; resume from verified / nonVerified on 'partially_enrolled'.
ReaderStatus
interface ReaderStatus {
connected: boolean; // service reachable
isAuthed: boolean; // service accepted the token
devices: ReaderDevice[]; // scanners currently visible
workstationId?: string;
version?: string;
lastError?: HwsError; // most recent poll failure; cleared on next success
}The action methods auto-select the first device, so most integrators never read devices — it’s exposed for rendering a device picker.
Client events
connected, disconnected (HwsError), device-attached (ReaderDevice), device-detached (ReaderDevice), poll-failed (HwsError).
poll-failed fires whenever a background /status poll fails — including the very first poll before the client ever went connected. It is de-duped by error code, so a stuck failure (e.g. an empty token) emits once rather than every 3s. The same error is also available synchronously on reader.status.lastError, which is cleared on the next successful poll.
Session events
Sessions are single-shot: call start() once, cancel() to abort an in-flight request. All three emit ready (just before the request goes out — cue “place finger”) and scanning (once in flight — cue spinner), then a terminal event. The terminal events differ per session type — note that verification and search split the outcome so a non-match never looks like an error:
| Session | Success terminal | Non-match terminal | Failure terminal | Result type |
|---|---|---|---|---|
EnrollmentSession | captured | — | error | EnrollmentResult |
VerificationSession | captured | mismatch | error | VerificationResult |
SearchSession | found | not-found | error | SearchResult |
error is reserved for transport, auth, device, and server failures (HwsError subclasses). A biometric non-match is delivered through mismatch / not-found, not error.
Errors
All reader errors extend HwsError (which extends SladeIDError) and carry code plus an optional correlationId.
| Class | Code |
|---|---|
ConnectionError | READER_CONNECTION_FAILED |
TimeoutError | READER_TIMEOUT |
ReaderAuthError | READER_AUTH_FAILED |
DeviceUnavailableError | READER_DEVICE_UNAVAILABLE |
ReaderValidationError | READER_VALIDATION_FAILED |
DuplicateEnrollmentError | READER_DUPLICATE_TEMPLATE |
ReaderServerError | READER_SERVER_ERROR |
BiometricMismatchError | READER_BIOMETRIC_MISMATCH |
DuplicateEnrollmentError is thrown by enroll() when the subject already has a live record on that finger — the backend keeps one active record per enrollee + position + source and refuses to store another. Re-capturing will not overwrite it; enroll a different position, or have the existing record deactivated first.
It subclasses ReaderValidationError, so existing instanceof ReaderValidationError handling keeps working unchanged. What it adds is a stable code of READER_DUPLICATE_TEMPLATE instead of the generic READER_VALIDATION_FAILED, so you can single the case out without regex-matching the message text — which is what integrators had to do before, and which broke the moment the backend reworded the detail string.
BiometricMismatchError is thrown only by rejection-style helpers; the built-in verify() resolves with { matched: false } instead. verify() does, however, throw for operational failures: a ReaderValidationError (READER_VALIDATION_FAILED) when there is no enrolled template to match against, and a ReaderServerError (READER_SERVER_ERROR) when the validator or database is unavailable. A genuine no-match is still { matched: false }, never a throw.