Using the JavaScript / React SDK
The @attestid/sdk package provides JavaScript and React APIs for integrating AttestID identity verification into web applications.
Features include:
- Document capture with the camera, with a review-before-submit step
- Client-side photo quality checks, including passport MRZ and ID-card text detection
- A built-in face liveness check
- Retrying a failed face check without re-uploading the ID
- Device-bound capture signing and safe automatic retries
- Helpers to verify signed results and webhooks on your backend
React applications can use the drop-in component, the useAttestID hook, or the lower-level SDK APIs.
1. Install
npm install @attestid/sdk
React support is a separate entry point (@attestid/sdk/react). react / react-dom (≥ 18) are optional peer dependencies, only needed if you use the React entry point.
2. Environment setup
Your API credentials and client certificate must stay on your backend.
Don't put apiKey or accessKeyId in client-side environment variables such as VITE_*, NEXT_PUBLIC_* or REACT_APP_*.
Backend .env (server-only):
ATTESTID_API_URL=https://api.attestid.brimsage.co.ls
ATTESTID_API_KEY=sk_live_your_key_here
ATTESTID_ACCESS_KEY_ID=ak_live_your_access_key_id_here
# Client certificate issued in the dashboard (Client Certificates). Required.
ATTESTID_CLIENT_CERT_FILE=./client.crt
ATTESTID_CLIENT_KEY_FILE=./client.key
Frontend .env (safe to expose: no secrets):
VITE_API_URL=https://api.attestid.brimsage.co.ls
Runtime enforcement
The SDK checks its configuration when you construct it, and refuses to run insecurely:
apiUrlmust behttps://. Plainhttp://is only allowed forlocalhost/127.0.0.1/::1, for local development.- A config containing
apiKey/accessKeyId(server mode) is rejected outside a real Node.js process. If a bundler ships that config to a browser, React Native or an edge runtime, the SDK throwsAttestIDErrorwith codeINSECURE_CONFIG.
3. Backend endpoint
Your frontend asks your backend for an SDK token. Your backend calls AttestID with the API key and its client certificate (mutual TLS is required on every API-key request) and returns the token.
The SDK's createSdkSession() does the call for you, including safe retries. Node's built-in fetch can't present a client certificate on its own, so pass one that can. This example uses undici (npm install undici):
// your-backend/routes/attestid-session.ts (Node/Express, never shipped to the browser)
import { readFileSync } from 'node:fs';
import { Agent, fetch as undiciFetch } from 'undici';
import { createSdkSession, AttestIDError } from '@attestid/sdk';
const mtls = new Agent({
connect: {
cert: readFileSync(process.env.ATTESTID_CLIENT_CERT_FILE!),
key: readFileSync(process.env.ATTESTID_CLIENT_KEY_FILE!),
},
});
const fetchWithClientCert = (url: string, init?: RequestInit) =>
undiciFetch(url, { ...init, dispatcher: mtls } as never) as unknown as Promise<Response>;
app.post('/api/attestid-session', requireLogin, async (req, res) => {
// Use YOUR authenticated user, never a userId from the request body.
const userId = req.user.id;
try {
const session = await createSdkSession(
process.env.ATTESTID_API_URL!,
process.env.ATTESTID_API_KEY!,
process.env.ATTESTID_ACCESS_KEY_ID!,
userId,
{ fetch: fetchWithClientCert },
);
res.json(session); // { sdk_token, user_id, expires_in }
} catch (err) {
const status = err instanceof AttestIDError ? (err.status ?? 502) : 502;
res.status(status).json({ message: err instanceof Error ? err.message : 'Session failed' });
}
});
Not using Node? Send POST /v1/kyc/session with the X-API-Key and X-Access-Key-Id headers and your client certificate from any language. See Authentication.
4. Configuration
import { AttestID } from '@attestid/sdk';
const client = new AttestID({
apiUrl: import.meta.env.VITE_API_URL, // no trailing slash
// Never put apiKey/accessKeyId here: call your own backend instead.
getSdkToken: (userId) =>
fetch('/api/attestid-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }),
}).then(r => r.json()),
});
The SDK has two configuration modes:
| Config | Where it's used | Required fields |
|---|---|---|
| Browser config | Frontend applications | apiUrl, getSdkToken |
| Server config | Node.js backends only | apiUrl, apiKey, accessKeyId (plus an mTLS fetch) |
Optional fields for both modes:
| Option | Default | Description |
|---|---|---|
deviceBinding | true in browsers, always off in server mode | Sign each uploaded photo and video with a non-extractable device key. See Security |
retry | { attempts: 3, baseDelayMs: 500 } | Automatic retries with Idempotency-Key. See Security |
fetch | global fetch | Custom fetch for every API call, for example one that presents your client certificate in server mode |
The SDK reuses valid SDK tokens (refreshing 15 seconds before expiry) and requests a new one when needed.
5. Path A: drop-in component (fastest)
AttestIDVerifier renders the whole flow: consent → document capture with quality checks and a review step → face check → result screen, with a Retry face check button when that's allowed.
// main.tsx
import '@attestid/sdk/styles.css';
import { AttestID } from '@attestid/sdk';
import { AttestIDVerifier } from '@attestid/sdk/react';
const client = new AttestID({
apiUrl: import.meta.env.VITE_API_URL,
getSdkToken: (userId) =>
fetch('/api/attestid-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }),
}).then(r => r.json()),
});
export default function KYCPage() {
return (
<AttestIDVerifier
client={client}
defaultUserId={currentUser.id}
onVerified={result => {
// Send result.attestation to your backend and verify it there
// before you act on the result (see section 8).
fetch('/api/kyc-result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result),
});
}}
onError={err => console.error('KYC error:', err)}
/>
);
}
AttestIDVerifier props
| Prop | Type | Required | Description |
|---|---|---|---|
client | AttestID | ✓ | The configured SDK client |
defaultUserId | string | The user being verified. Rendered as a hidden field | |
onVerified | (result: KYCResult) => void | Called when verification completes (pass or fail) | |
onError | (error: string) => void | Called on an API or liveness error | |
mobileHandoffUrl | string | Page a desktop visitor's phone is sent to through a QR code or link. Defaults to the current page | |
allowDesktopCapture | boolean | Let laptops and desktops capture the document with the webcam (the phone QR is still offered). Default true. Set false to make capture phone-only | |
allowPhotoUpload | boolean | Show an Upload photo option instead of the camera. For testing only: uploads bypass live capture. Default false |
On desktop, AttestIDVerifier offers a QR code or link to continue on the phone. The link carries a unique, short-lived handoff token, and consent already given on desktop isn't asked for again.
6. Path B: custom UI with the useAttestID hook
The useAttestID hook exposes the flow's state and actions so you can build your own interface.
import { AttestID, checkPhotoQuality } from '@attestid/sdk';
import { useAttestID, AttestIDLivenessCheck } from '@attestid/sdk/react';
import type { IdType } from '@attestid/sdk';
const client = new AttestID({ /* same config as above */ });
export default function KYCPage() {
const verification = useAttestID(client);
const {
state, error, qualityIssues, result, isLoading,
startVerification, canRetryLiveness, retryLiveness, reset,
} = verification;
async function handleUpload(file: File, userId: string, idType: IdType, consentGivenAt: Date) {
// consentGivenAt must be when the user actually agreed (e.g. clicked "Agree"),
// not when the upload fires. AttestIDVerifier (Path A) captures this for you.
await startVerification({ userId, idType, idPhoto: file, consentGivenAt });
}
if (state === 'idle' || state === 'quality_check' || state === 'uploading' || state === 'error') {
return (
<div>
{error && <p>{error}</p>}
{qualityIssues.map(i => <p key={i}>{i}</p>)}
<input type="file" accept="image/*" onChange={e => {
const file = e.target.files?.[0];
if (file) handleUpload(file, currentUser.id, 'national_id', consentTime);
}} />
{isLoading && <p>Working…</p>}
</div>
);
}
if (state === 'liveness') {
// Runs the face check, then completes the verification.
return (
<AttestIDLivenessCheck
verification={verification}
onError={err => console.error(err)}
/>
);
}
if (state === 'done' && result) {
return (
<div>
<h2>{result.verified ? '✓ Verified' : '✗ Not verified'}</h2>
<p>{result.message}</p>
{canRetryLiveness && <button onClick={retryLiveness}>Retry face check</button>}
<button onClick={reset}>Start again</button>
</div>
);
}
return <p>Working…</p>;
}
startVerification runs the photo quality check first. If it fails, state becomes 'error', qualityIssues is filled in, and no API call is made.
Flow state machine
startVerification(options)
│
▼
[quality_check] ← checkPhotoQuality runs client-side
│
pass │ fail ──► state = 'error', qualityIssues populated, no API call
│
▼
[uploading] ← ID photo uploaded (signed with the device key)
│ and the face check prepared
▼
[liveness] ← mount <AttestIDLivenessCheck />
│
▼ face check finishes
[completing]
│
▼
[done] ← result available
│
└─ canRetryLiveness? ── retryLiveness() ──► [retrying] ──► [liveness]
useAttestID return values
| Property | Type | Description |
|---|---|---|
state | VerifyState | 'idle' | 'quality_check' | 'uploading' | 'retrying' | 'liveness' | 'completing' | 'done' | 'error' |
error | string | null | Last API error message |
qualityIssues | string[] | Human-readable photo quality problems |
result | KYCResult | null | Final verification result, including attestation |
isLoading | boolean | true during quality_check, uploading, retrying and completing |
startVerification | (options) => Promise<void> | Starts the flow |
canRetryLiveness | boolean | true when the result failed only at liveness |
retryLiveness | () => Promise<void> | Gets a fresh liveness session on the same verification, without re-uploading the ID |
reset | () => void | Resets state back to idle |
startVerification options
await startVerification({
userId: 'user-123',
idType: 'national_id', // 'national_id' | 'passport' | 'drivers_licence'
idPhoto: file, // File | Blob
consentGivenAt: consentTime, // required: when the user agreed
processingPurpose: 'identity_verification', // optional, this is the default
});
processingPurpose must be one of identity_verification, kyc_onboarding, kyc_refresh, account_recovery or age_verification. consentGivenAt is rejected if it's more than 24 hours old.
7. The face check
The face check confirms that a real person is in front of the camera and that they match the ID photo. The SDK runs it for you.
AttestIDVerifierruns it as part of the drop-in flow.- With the hook, mount
<AttestIDLivenessCheck verification={...} />whilestate === 'liveness', as in Path B above. It runs the check and completes the verification when it finishes.
Things to know:
- Lighting. The SDK coaches the user towards better light ("It's too dark…") before starting, for up to 10 seconds (
lightingCheckMs), then starts anyway. - Photosensitivity. The check can flash colours on screen. The SDK shows a notice first (
showFlashNotice, default on). Keep it, or show your own. - Timing. A face-check session expires about 3 minutes after it starts.
- Browser support. Current Chrome, Edge, Firefox, Safari 14.1+ and iOS 14.3+, in a secure context (https, or http://localhost).
AttestIDLivenessCheck props
| Prop | Type | Required | Description |
|---|---|---|---|
verification | ReturnType<typeof useAttestID> | ✓ | The object returned by useAttestID() |
onError | (error: unknown) => void | Called on a camera or face-check error | |
deviceId | string | Passed on when the verification completes | |
showFlashNotice | boolean | Show the flashing-colours notice first. Default true | |
lightingCheckMs | number | How long to coach towards better light before starting. Default 10000; 0 skips it |
Retrying a failed face check
When a verification fails only at liveness (for example liveness_failed or liveness_confidence_too_low, usually bad lighting or framing), the user can retry the face check without uploading the ID photo again. The document checks already done still count.
AttestIDVerifier shows a Retry face check button automatically. With the hook, show your own button when canRetryLiveness is true and call retryLiveness(): state goes back to 'liveness' and AttestIDLivenessCheck runs the check again.
The API refuses a retry (KYCError, HTTP 409 or 410) for suspected spoofs, face-match failures and fraud holds, after 2 retries on one verification, or more than 30 minutes after the ID was uploaded. Then start over with reset() or a new startVerification().
8. Verify the result on your backend
The verified field travels through the user's browser, so it can be tampered with there. Every result includes result.attestation, a signature from AttestID. Verify it on your backend before you act on the result:
// your backend
import { verifyAttestation } from '@attestid/sdk';
const claims = await verifyAttestation(result.attestation, {
apiUrl: process.env.ATTESTID_API_URL!,
verificationId: expectedVerificationId,
document: result.document, // optional: proves the ID data wasn't altered
});
if (!claims.verified) throw new Error('Not verified');
See Security → Signed verification results for the claims and how keys rotate.
9. Photo quality
checkPhotoQuality() checks a photo before upload, so users get instant feedback:
import { checkPhotoQuality } from '@attestid/sdk';
const result = await checkPhotoQuality(file, 'national_id');
// result.ok true when all checks pass
// result.issues human-readable strings, safe to show directly to users
Checks performed client-side:
- File size and format. JPEG or PNG, checked from the file's bytes, not its name or
Content-Type - Blur and brightness. Flags camera shake, out-of-focus, under- and overexposed shots
- Document type. Flags a passport uploaded as an ID card, or the other way round, from its shape
- Passport MRZ. The data page must show the machine-readable zone (the two lines of
<<<text). Rejects the cover, visa pages, a cropped-off MRZ, or glare across it. In a two-page spread, only the half with the MRZ is checked - ID card / driver's licence text. The front must show several printed lines (name, date of birth, ID number). Rejects the back of the card, a blank surface, or a photo of something else
- Face count. Where the browser supports it, flags no face or more than one face
These checks look at shapes and layout, not characters: no OCR library is bundled, and they take about 20–70 ms. The API reads the document properly once it's uploaded.
AttestIDVerifier and useAttestID won't submit a photo while checkPhotoQuality reports issues.
10. Error handling
import { AttestIDError, AuthError, KYCError, CameraError, ValidationError, SignatureError } from '@attestid/sdk';
try {
await client.startVerification({ /* ... */ });
} catch (e) {
if (e instanceof AuthError) console.error('Invalid key or expired sdk_token', e.status); // 401/403
if (e instanceof KYCError) console.error('KYC failed', e.code, e.status);
if (e instanceof CameraError) console.error('Camera error', e.message);
if (e instanceof ValidationError) console.error('Rejected before upload', e.message);
if (e instanceof AttestIDError) console.error(e.code, e.message); // e.g. CONSENT_REQUIRED
}
Every error extends AttestIDError (message, code, optional status).
| Class | code | When thrown |
|---|---|---|
AuthError | AUTH_ERROR | Invalid API key or client certificate, an expired or invalid sdk_token, or getSdkToken() returned no token |
KYCError | KYC_ERROR | The API returned a non-auth error |
CameraError | CAMERA_ERROR | Camera access denied or capture failed |
AttestIDError | CONSENT_REQUIRED / INSECURE_CONFIG | Missing consentGivenAt; apiKey configured outside a Node.js backend; or a non-https apiUrl |
SignatureError | SIGNATURE_INVALID | verifyAttestation() / verifyWebhook(): bad signature, wrong IDs, altered document data, or a webhook outside the replay window |
AttestIDError | LIVENESS_UNSUPPORTED / LIVENESS_RECORDING_FAILED / LIVENESS_ABORTED / LIVENESS_TIMEOUT | Face check: the browser can't run it, it failed or was cancelled, or no decision arrived in time (default 120 s) |
AttestIDError | JWKS_UNAVAILABLE / CRYPTO_UNAVAILABLE | AttestID's public keys couldn't be fetched, or the runtime has no WebCrypto |
Failure reasons
result.failure_reason | Meaning | Retry face check? |
|---|---|---|
liveness_failed / liveness_confidence_too_low | Couldn't confirm a live person | Yes |
face_match_failed | Selfie didn't match the ID photo | No |
fraud_review_required | Held for a human to review (for example a borderline face match, or a capture signature that didn't check out) | No |
fraud_detected | A fraud rule was triggered, for example an expired document or an identity already used elsewhere. See checks.fraud_flags | No |
document_tampered | The document photo shows signs of editing | No |
ocr_failed / mrz_failed | Couldn't read the ID card text or the passport MRZ | No |
Every result also includes message, a plain-language explanation that's safe to show to the user, and checks.fraud_reasons, which explains each flag.
A face match just below the threshold is held for manual review instead of being rejected, because it usually means a genuine user with an old ID photo or a dim camera. Use faceMatchOutcome(result) to read the API's decision: 'pass', 'review' or 'fail'. Don't compare face_match_confidence to a threshold of your own: the threshold is set on the server and can change.
11. Camera utilities
import { openCameraSession, DOC_ASPECT } from '@attestid/sdk';
const session = await openCameraSession('national_id');
videoElement.srcObject = session.stream;
const file = await session.captureFrame(frameOverlayElement, videoElement);
session.stop();
DOC_ASPECT gives the overlay aspect ratio for each document type (national_id / drivers_licence: 1.586, ISO 7810 ID-1; passport: 1.420, ID-3 data page).
12. Runtime requirements
The verification helpers and device signing use WebCrypto (globalThis.crypto.subtle): any modern browser, Node.js 19+, Deno, Bun or edge runtimes. Without it, verifyAttestation() / verifyWebhook() throw CRYPTO_UNAVAILABLE, and device signing is skipped.
Camera capture requires a secure context: https://, or http://localhost during development.
Demo example
See JavaScript/React Demo App for a complete working example.