Use a USB fingerprint reader
For partners with physical hardware scanners — the contactful capture path — the SDK talks to a local hardware service running on the user’s workstation. You work with three operations directly: enroll, verify, and search (1:N). The transport, device discovery, status polling, and PascalCase wire format are all hidden behind FingerprintReaderClient.
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 brokers access to the hardware over a localhost service.
- 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 companion service — implement
getTokento fetch a bearer token from your auth service. It is called on demand and again after a401, so token rotation is transparent.
Create the client
createFingerprintReader 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';
const reader = createFingerprintReader({
getToken: async () => fetchHwsToken(), // your auth service
});
// 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 hit the same hardware service; 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 { HwsError } from '@sladeid/slade-id-sdk';
try {
const result = await reader.enroll({
enrollee: 'subject-123',
position: 2, // right index
});
console.log('enrolled; template length', result.template?.length);
console.log('positions confirmed by the service', result.verifiedPositions);
} catch (err) {
if (err instanceof HwsError) console.error(err.code, err.correlationId);
}EnrollmentResult carries the base64 template the device produced (when the service returns one), any verifiedPositions, and the untouched server payload under raw.
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 metadata lookup, not a capture: it 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'
if (state.enrollmentStatus === 'fully_enrolled') {
done();
} 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.
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 non-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.
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
const result = await reader.search();
if (result.found) {
console.log('identified as', result.enrollee);
} else {
console.log('no enrolled subject matched');
}SearchResult is { found, enrollee?, 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). When the backend includes scoring fields on a hit (e.g. similarity, match_score), they’re passed through untouched under raw — read them defensively, since their presence depends on the middleware revision.
Session style
const session = reader.createSearchSession();
session.on('ready', () => prompt('Place any finger to identify'));
session.on('scanning', () => showSpinner());
session.on('found', (result) => showSubject(result.enrollee));
session.on('not-found', () => 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. 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 token was rejected. Check your getToken and the companion app’s auth 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 malformed (e.g. an out-of-range position). Check the inputs you passed. |
ReaderServerError | READER_SERVER_ERROR | The service returned a 5xx. 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 reader error 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.
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.