Use a USB fingerprint reader
For partners with physical hardware scanners — the contactful capture path — the SDK drives the reader through FingerprintReaderClient. You work with three operations directly: enroll, verify, and search (1:N). These biometric actions are dispatched through the middleware orchestrator (/v1/orchestrator/jobs/ → NATS → the hardware service), an async dispatch→poll flow; the bearer is validated once, at the middleware. Only device discovery and the testCapture() diagnostic still touch the local hardware service on the workstation. The transport, orchestration, device discovery, status polling, and PascalCase wire format are all hidden behind the client.
This guide covers all three operations end to end. If you only need the API shapes, jump to the Fingerprint reader reference.
Companion application required
Every contactful (physical-scanner) capture flow requires the SladeID companion application running on the user’s device alongside the browser. The web SDK alone cannot drive USB scanners — the companion app exposes the workstation’s scanner to the platform. Device discovery and the testCapture() diagnostic reach it directly over a localhost service; enroll, verify, and search are dispatched to it through the middleware orchestrator.
- SladeID Android — install from the Google Play Store .
- SladeID Windows — available on request. Contact your Slade ID integration contact.
The browser-only camera capture flow (Enroll a face) does not require the companion app.
Prerequisites
- The SladeID companion application running on the workstation, with a compatible scanner attached.
- A token source for the middleware — implement
getTokento fetch a middleware (Keycloak) bearer from your auth service. It is called on demand and again after a401, so token rotation is transparent. The bearer is validated once, at the middleware.
Create the client
The recommended path is sladeId.reader — a getter on SladeID that needs no extra config and reuses the middleware auth and orchestrator. Use createFingerprintReader when you want a standalone client; it returns a long-lived client. Construct it once per page, attach lifecycle listeners, and reuse it across every enroll / verify / search. Call dispose() when you tear down the view.
import { createFingerprintReader, HwsError } from '@sladeid/slade-id-sdk';
// Recommended: reuse the SladeID instance's middleware auth + orchestrator.
const reader = sladeId.reader;
// Standalone: `middlewareUrl` is required; `getToken` returns a middleware
// (Keycloak) bearer. `localServiceUrl` (default http://localhost:18065)
// points at the local /status + /test service.
const standalone = createFingerprintReader({
getToken: async () => fetchMiddlewareToken(), // your auth service
middlewareUrl: 'https://middleware.example.com',
// integratorId: 'my-integrator', // optional
// localServiceUrl: 'http://localhost:18065', // optional
});
// Lifecycle — the client polls the companion app every 3s in the background.
reader.on('connected', () => console.log('companion app reachable'));
reader.on('disconnected', (err) => console.warn('companion app gone', err.code));
reader.on('device-attached', (d) => console.log('scanner ready:', d.name));
reader.on('device-detached', (d) => console.log('scanner removed:', d.id));
// `poll-failed` fires (de-duped by error code) when a background status
// poll fails — including the very first poll, before `connected` ever fires.
// Use it to render "why am I not connected?" without subscribing to everything.
reader.on('poll-failed', (err: HwsError) => showBanner(err.code));You don’t have to pick a device — every action auto-selects the first attached scanner. The device list (reader.status.devices) is exposed only for integrators who want to render a device picker. Read ReaderStatus for the full status shape.
The three operations come in two flavours. The promise methods (enroll, verify, search) are the shortest path — await them and branch on the result. The session factories (createEnrollmentSession, …) wrap the same calls with ready / scanning progress events and a cancel() handle, for UIs that prompt the user (“place your finger”) and show a spinner. Both dispatch through the same orchestrator; pick per screen.
Enrollment is two captures
Enrolling a finger on the reader is not a single scan — it takes two captures, and only the second one completes it. Design your UI for this from the start. (For the full state model, see Fingerprint enrollment lifecycle.)
- First capture —
enroll().reader.enroll({ enrollee, position })stores the print as an unverified record. The position then appears infetchEnrolled().nonVerified. An unverified finger does not yet count toward the subject being enrolled. - Second capture —
verify().reader.verify({ enrollee, position })captures the same finger again and matches it against the stored template. On a match it promotes the record to verified — the position moves fromnonVerifiedtoverifiedand that finger is done.verify()resolves{ matched }:trueconfirms it;falsemeans the confirming capture didn’t match, so the finger stays unverified and you re-prompt for the verify capture. - Progress —
fetchEnrolled(). This is your source of truth:verified(complete),nonVerified(captured, awaiting their verify capture), andenrollmentStatus—'partially_enrolled'until enough fingers are verified, then'fully_enrolled'. Only verified fingers advance the status. The required finger count is backend-configured, so read the status instead of counting. - Reset → re-enroll. The backend allows only a limited (backend-configured) number of failed verify attempts per finger. Once they’re exhausted it resets the finger, deleting the unverified record. You observe this as the position vanishing from both lists in
fetchEnrolled— recover by enrolling that finger again (back to step 1). There is no reliable typed attempts-remaining counter in the SDK; detect the reset throughfetchEnrolled. - The loop. For each target finger:
enroll→verify(retry whilematchedisfalse) → refreshfetchEnrolled→ if the finger vanished, re-enroll → repeat untilenrollmentStatus === 'fully_enrolled'. See the end-to-end journey below.
Finger positions
Every enroll and verify call takes a position (0–10). The numbering follows the middleware contract:
| Hand | Thumb | Index | Middle | Ring | Little |
|---|---|---|---|---|---|
| Right | 1 | 2 | 3 | 4 | 5 |
| Left | 6 | 7 | 8 | 9 | 10 |
0 is reserved for minors or an “any finger” capture. Pass isMinor: true to have the service apply relaxed quality checks.
Enroll a fingerprint (first capture)
The first capture registers a subject’s finger. Provide the subject identifier (enrollee) and the position being captured. enroll() stores the print as an unverified record — it lands in fetchEnrolled().nonVerified and does not count toward enrollment until a second verify capture confirms it. Repeat per finger if you enroll more than one.
Promise style
import { DuplicateEnrollmentError, HwsError } from '@sladeid/slade-id-sdk';
try {
const result = await reader.enroll({
enrollee: 'subject-123',
position: 2, // right index
});
console.log('backend record', result.raw); // { fingerprint_id, enrollee, position, device_id }
console.log('positions confirmed by the service', result.verifiedPositions);
} catch (err) {
if (err instanceof DuplicateEnrollmentError) {
// This finger already has a live record. Re-capturing will NOT overwrite
// it, so retrying the same position loops forever. Move on to the next
// finger, or have the existing record deactivated.
offerNextPosition();
} else if (err instanceof HwsError) {
console.error(err.code, err.correlationId);
}
}EnrollmentResult no longer carries a device template — the orchestrated backend finaliser returns { fingerprint_id, enrollee, position, device_id }, surfaced under raw, and template is now typically undefined. It still carries any verifiedPositions.
Duplicate fingers
The backend keeps one active record per enrollee + position + source. Enrolling a finger that already has one is refused, and the SDK raises a DuplicateEnrollmentError carrying the code READER_DUPLICATE_TEMPLATE.
Detect it by class or by code — never by message text:
// Typed check. Preferred inside a single JS realm.
if (err instanceof DuplicateEnrollmentError) { /* ... */ }
// Equivalent, and the one to use once the error has crossed a boundary that
// dropped its prototype: a worker postMessage, a redux action, a log pipeline.
if (err instanceof HwsError && err.code === 'READER_DUPLICATE_TEMPLATE') { /* ... */ }Matching on the message — err.message.includes('already enrolled') — is what integrators had to do before this error existed, and it breaks silently the moment the backend rewords its detail string. The code is part of the contract; the wording is not.
DuplicateEnrollmentError subclasses ReaderValidationError, so if you already have an instanceof ReaderValidationError branch it keeps catching duplicates exactly as before. The only change is that err.code is now the specific READER_DUPLICATE_TEMPLATE rather than the generic READER_VALIDATION_FAILED — so if you were asserting on that generic code for the duplicate case, update the assertion.
Session style (with progress events)
Use a session when you want to drive the UI through the capture lifecycle:
const session = reader.createEnrollmentSession({
enrollee: 'subject-123',
position: 2, // right index
});
session.on('ready', () => prompt('Place your right index finger on the scanner'));
session.on('scanning', () => showSpinner());
session.on('captured', (result) => {
hideSpinner();
console.log('enrolled', result.verifiedPositions);
});
session.on('error', (err) => {
hideSpinner();
console.error('enrollment failed', err);
});
await session.start();To enroll several fingers, run one session per position in sequence — sessions are single-shot.
Resume a paused enrollment
A multi-finger enrollment is often interrupted — the user walks away, the tab closes, the operator switches subjects. Rather than restart from scratch, call fetchEnrolled(enrollee) to read which positions are already on file, then prompt only for the ones still missing.
fetchEnrolled is a direct middleware lookup, not a capture: it queries the enrolled_fingerprints endpoint (not the orchestrator, not the local hardware service), touches no hardware, selects no device, and resolves even when no scanner is attached.
const PLAN = [1, 2, 3]; // right thumb, index, middle — the fingers this flow intends to enroll
const state = await reader.fetchEnrolled('subject-123');
console.log(state.enrollmentStatus); // 'fully_enrolled' | 'partially_enrolled' | 'not_enrolled'
if (state.enrollmentStatus === 'fully_enrolled') {
done();
} else if (state.enrollmentStatus === 'not_enrolled') {
// Nothing on file at all — this subject has never been enrolled. Start the
// whole plan from the first finger; there is nothing to resume.
startFreshEnrollment(PLAN);
} else {
// Skip what's already verified; the gap still needs the full two-capture
// loop below — nonVerified fingers are captured but NOT done: they still
// owe their verify capture.
const remaining = PLAN.filter((p) => !state.verified.includes(p));
// Run the end-to-end enrollment journey (below) for each position in `remaining`.
}EnrolledFingerprints is { verified, nonVerified, total, enrollmentStatus, notes?, raw } — all positions are the same 0–10 numbering as above. verified is the set you can safely skip; nonVerified are positions captured but still needing their verify (second) capture. See Fingerprint enrollment lifecycle.
enrollmentStatus distinguishes three states, and the third one matters for resume flows. 'not_enrolled' means no records at all — the middleware answers an unknown subject with an empty body, which decodes to zero total and two empty position lists. Before this member existed that collapsed into 'partially_enrolled', so a subject who had never been enrolled looked identical to one who was halfway through, and the UI would announce “resuming enrollment” for something that had never started. Branch on it explicitly: 'not_enrolled' starts fresh, 'partially_enrolled' resumes.
Complete enrollment (second capture)
verify() confirms a fresh capture against the enrollee’s stored record. During enrollment this is the second capture that completes a finger: reader.verify({ enrollee, position }) re-captures the finger you just enrolled, matches it against the stored unverified template, and on a match promotes it — the position moves from nonVerified to verified in fetchEnrolled, and that finger’s enrollment is done.
The same call also serves 1:1 verification of a claimed identity: once a subject is enrolled, verify() confirms that a live capture belongs to the enrollee they claim to be. Same method, same { matched, matchLogId?, verifiedPositions? } result — the only difference is whether you’re completing an enrollment or checking an existing one.
A genuine no-match is not an error. verify() resolves with { matched: false }; the session emits a dedicated mismatch event. Reserve try/catch and the error event for transport, auth, and device failures. Conflating the two — verify().catch(() => failedAttempt()) — would report a network outage as a biometric rejection. During enrollment, matched: false simply means the confirming capture didn’t match: the finger stays unverified, so re-prompt for the verify capture. Operational failures, however, do throw: verifying with no enrolled template surfaces as a ReaderValidationError (READER_VALIDATION_FAILED), and an unavailable validator or database surfaces as a ReaderServerError (READER_SERVER_ERROR) — neither is a biometric outcome, so keep them out of your mismatch path.
Promise style
const verdict = await reader.verify({
enrollee: 'subject-123',
position: 2, // right index — the finger you just enrolled
});
if (verdict.matched) {
// Enrollment: this finger is now verified and complete.
// 1:1 check: the claimed identity is confirmed.
console.log('confirmed; audit id', verdict.matchLogId);
} else {
// The capture didn't match. During enrollment the finger stays
// unverified — re-prompt and verify again.
console.log('did not match; ask the user to present the finger again');
}VerificationResult is { matched, matchLogId?, verifiedPositions? }. Persist matchLogId whenever matched is true — it is the server-side audit row you quote in support tickets and reconciliation. After a match during enrollment, re-read fetchEnrolled to see the position move into verified.
Session style
The verification session splits the outcome into two events so your UI never treats a non-match as a crash:
const session = reader.createVerificationSession({
enrollee: 'subject-123',
position: 2,
});
session.on('ready', () => prompt('Place the same finger again to confirm'));
session.on('scanning', () => showSpinner());
session.on('captured', (result) => {
// matched === true — finger promoted to verified (or claimed identity confirmed)
onConfirmed(result.matchLogId);
});
session.on('mismatch', () => {
// matched === false — a normal outcome, not an error. Ask the user to retry.
showRetry('Finger did not match. Try the capture again.');
});
session.on('error', (err) => {
// transport / auth / device failure only
showError(err);
});
await session.start();If you prefer a rejection-style flow, the BiometricMismatchError class exists for callers who wrap their own helper — but the built-in verify() never throws it.
End-to-end enrollment journey
Putting both captures together: loop over the target fingers, and for each one enroll, then verify to confirm. Drive the loop off fetchEnrolled — and if a finger was reset (it vanished from both lists after too many failed confirmations), enroll it again. Stop when the backend reports 'fully_enrolled'. Don’t hardcode how many fingers are required or how many retries you get — both are backend-configured; read enrollmentStatus and observe resets through fetchEnrolled.
import { HwsError } from '@sladeid/slade-id-sdk';
const enrollee = 'subject-123';
const TARGET_FINGERS = [1, 2, 3]; // right thumb, index, middle
const MAX_VERIFY_TRIES = 3; // your own UX budget for re-prompting
// Second capture: confirm the same finger. Resolves true once it's verified.
async function confirmCapture(position: number) {
for (let attempt = 0; attempt < MAX_VERIFY_TRIES; attempt++) {
const { matched } = await reader.verify({ enrollee, position });
if (matched) return true; // promoted: nonVerified → verified
prompt('Finger did not match — place the same finger again');
}
return false; // the backend may reset this finger; fetchEnrolled will tell us
}
// First capture then confirm.
async function enrollFinger(position: number) {
await reader.enroll({ enrollee, position }); // stores an unverified record
await confirmCapture(position);
}
try {
// Drive the journey off the source of truth, not local counters.
let state = await reader.fetchEnrolled(enrollee);
while (state.enrollmentStatus !== 'fully_enrolled') {
for (const position of TARGET_FINGERS) {
if (state.verified.includes(position)) continue; // already done
if (state.nonVerified.includes(position)) {
await confirmCapture(position); // captured already — just confirm it
} else {
await enrollFinger(position); // missing or reset — (re-)enroll from scratch
}
}
// Refresh from the backend: promotions land here, and resets show up as
// a position missing from BOTH verified and nonVerified.
state = await reader.fetchEnrolled(enrollee);
}
console.log('subject fully enrolled', state.verified);
} catch (err) {
if (err instanceof HwsError) console.error(err.code, err.correlationId);
}A non-match is a normal result, but transport, auth, and device failures still throw HwsError. In production also give the operator a way to cancel and cap the number of rounds, so a finger that never confirms can’t loop forever.
Search / identify a fingerprint (1:N)
Search answers “who is this?” — the subject is anonymous, and the service ranks the live capture against the enrolled corpus. There is no enrollee input; the capture alone is the query.
1:N identification has consent and data-protection implications that 1:1 verification does not — the subject does not assert an identity in advance, and some jurisdictions require explicit opt-in. Confirm your compliance posture before enabling search. See Enroll vs verify vs identify.
Promise style
// Naming the finger is optional but worth doing whenever the UI knows which
// one it is about to prompt for: the service resolves matching thresholds per
// finger group, so it can apply the values tuned for that group instead of the
// untuned global ones. Omitted, it defaults to 0 ("any finger") server-side.
const result = await reader.search({ position: 2 }); // right index
if (result.errorCode) {
// The search FAULTED — it never reached a verdict (e.g. VALIDATOR_UNAVAILABLE).
// `found: false` here does NOT mean "not enrolled": the subject may well be
// on file. Ask the operator to retry; do not tell them the subject is unknown.
showRetry(`Identification unavailable (${result.errorCode}) — try again.`);
} else if (result.found) {
// `similarity` is a 0..1 confidence, and it may be absent. Treat undefined as
// UNKNOWN, never as zero: a middleware revision that reports no score still
// returns real matches, and `(result.similarity ?? 0) >= 0.7` would reject
// every one of them.
if (result.similarity !== undefined && result.similarity < 0.7) {
routeToManualReview(result.enrollee, result.similarity);
} else {
showSubject(result.enrollee);
}
// Persist for audit — not for thresholding. See below.
logMatchScore(result.matchScore);
} else {
showBanner('No enrolled subject matched');
}SearchResult is { found, enrollee?, similarity?, matchScore?, errorCode?, raw }. found is true only when the service returns a matching enrollee. Fingerprint search resolves to a single best match — not a ranked candidate list (unlike SladeID.searchFace). raw still carries every server field verbatim, so nothing is lost by the typed promotion.
Threshold on similarity, not on matchScore. They are on different scales server-side: similarity is a 0..1 confidence, while matchScore is whatever the matcher that decided the hit reported — sometimes a 0..1 similarity, sometimes an unbounded validator score. They are surfaced as two fields precisely so you cannot accidentally compare a validator score against a 0..1 threshold. Record matchScore in your audit trail; gate your UI on similarity.
Both are only promoted when the server sent a finite number, so a NaN or Infinity on the wire arrives as undefined rather than as a comparison that silently evaluates false.
An errorCode on a found: false result is a fault, not a miss. The service could not complete the search — the validator was unavailable, the corpus query failed. The subject may be perfectly well enrolled. Treating that as “not enrolled” is the failure mode to avoid: at a cafeteria till it turns an infrastructure blip into a child being told they have no account. Check errorCode before you branch on found, and surface a retry.
Session style
const session = reader.createSearchSession({ position: 2 }); // same optional input
session.on('ready', () => prompt('Place your right index finger to identify'));
session.on('scanning', () => showSpinner());
session.on('found', (result) => showSubject(result.enrollee));
session.on('not-found', (result) => {
// `not-found` carries the full SearchResult, so a faulted search arrives
// here too. Check errorCode before announcing "no match".
if (result.errorCode) showRetry(`Identification unavailable (${result.errorCode})`);
else showBanner('No match in the corpus');
});
session.on('error', (err) => showError(err));
await session.start();Cancel an in-flight capture
Sessions are abortable. Call cancel() to abort a request the user has walked away from — the start() promise rejects and error fires:
const session = reader.createVerificationSession({ enrollee: 'subject-123', position: 2 });
cancelButton.onclick = () => session.cancel();
await session.start().catch((err) => {
// aborted captures surface here as well
});Test the scanner
testCapture() grabs a single frame without enrolling or matching — useful for a “test your reader” diagnostic screen. It is the one action that still talks directly to the local hardware service (http://localhost:18065) rather than the orchestrator. Pass { normalized: true } for the preprocessed template.
const { template, device } = await reader.testCapture();
console.log('captured from', device.name, 'len', template.length);What can go wrong
| Error | Code | Meaning & fix |
|---|---|---|
ConnectionError | READER_CONNECTION_FAILED | The companion app isn’t running. Ask the user to launch SladeID Android / Windows. |
DeviceUnavailableError | READER_DEVICE_UNAVAILABLE | No scanner attached, or it’s held by another application. |
ReaderAuthError | READER_AUTH_FAILED | The middleware bearer was rejected. Check your getToken and its Keycloak configuration. |
TimeoutError | READER_TIMEOUT | The user never presented a finger within the capture window. Re-prompt and retry. |
ReaderValidationError | READER_VALIDATION_FAILED | The request was rejected — a malformed input (e.g. an out-of-range position), or verify() with no enrolled template. |
DuplicateEnrollmentError | READER_DUPLICATE_TEMPLATE | enroll() only. That finger already has a live record; re-capturing won’t overwrite it. Offer another position. Subclasses ReaderValidationError. |
ReaderServerError | READER_SERVER_ERROR | The validator or database was unavailable while matching. Quote err.correlationId to your Slade ID contact. |
BiometricMismatchError | READER_BIOMETRIC_MISMATCH | Only thrown by rejection-style helpers. Plain verify() resolves { matched: false } instead. |
Every error in that table extends HwsError, which carries code and an optional correlationId for cross-referencing server logs. A failed match is never one of these — see the verify section above.
Not every failure is an HwsError. An HTTP-level failure from the middleware — the dispatch itself being refused — comes from the shared transport, so it arrives as a NetworkError (code: 'SERVER_ERROR', or 'TIMEOUT', or 'NETWORK_FAILURE'), which extends SladeIDError and not HwsError. The one you will actually meet is an offline workstation:
Middleware returned 503: Target workstation is not currently live.So a catch that only tests err instanceof HwsError silently drops it. Test both, or fall back to SladeIDError, which is the common ancestor of everything the SDK throws.
See also
- Fingerprint reader reference — full method, input, result, and event tables.
- Fingerprint enrollment lifecycle — unverified → verified, resets, and why
fetchEnrolledis the source of truth. - Enroll vs verify vs identify — the conceptual difference between the three operations.
- Pin a dispatch target — drive these flows from a host that has no companion application.
- Use the SDK with SSR frameworks — why the reader must be constructed lazily in Next.js and friends.