Reader SDK Documents

IFU049(02) WASM SDK Vanilla JS Integration Guide

Novarum DX Ltd
Instructions for Use / Guides

Document ID: IFU049 
Revision: 02 
Released: Jul 20, 2026 

Overview

The NDX Imaging Web SDK (@novarumdx/ndx-imaging-web) enables web applications to capture and analyse lateral flow tests using the device camera. It handles camera setup, image capture, and analysis result delivery using a WebAssembly imaging engine running in a background web worker.

This guide explains how to integrate the NovarumDX Reader SDK WASM package into plain HTML and JavaScript applications without a framework. It combines quickstart, installation, scan flow, results handling, diagnostics, and configuration reference into a single practical guide.

Deployment benefit

The SDK does not require SharedArrayBuffer and therefore does not require COOP or COEP headers. This makes deployment simpler on standard web hosting platforms.

Content Security Policy (CSP)

The SDK runs its imaging engine in a same-origin Web Worker and compiles a WebAssembly module. If your application (or a proxy in front of it) sets a Content-Security-Policy, include these directives so the Worker, WASM, captured images, and same-origin fetches are allowed:

Directive

Value

Why it is needed

worker-src

'self'

The SDK creates a same-origin Web Worker (imagingWorker.js).

script-src

'self' 'wasm-unsafe-eval'

The Worker importScripts the WASM JS glue (ndx_imaging_wasm.js); WebAssembly compilation requires 'wasm-unsafe-eval'.

connect-src

'self'

The SDK fetches the unified cassette JSON and the .wasm binary from your origin.

img-src

'self' blob:

Captured frames and PMF-story images are shown via blob: object URLs.

A minimal policy that works with the SDK:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'wasm-unsafe-eval';
  worker-src 'self';
  connect-src 'self';
  img-src 'self' blob:;
  • 'wasm-unsafe-eval' is the modern directive for WebAssembly compilation; very old browsers may instead require 'unsafe-eval' in script-src.

  • The live camera feed uses a MediaStream (via srcObject), which CSP does not gate, so no media-src directive is needed for it.

  • The SDK does not use SharedArrayBuffer, so COOP / COEP headers are not required.

Before You Start

Requirement

Detail

Node.js

18 or later

npm

8 or later

Browser

Chrome 80+, Safari 15+, Firefox 79+, Edge 80+

HTTPS

Required for camera access in deployed environments; localhost counts during local development

AWS CLI

Required to fetch a registry auth token

Registry credentials

AWS Access Key ID and Secret Access Key provided by NovarumDX

Cassette configuration file

Your assay-specific .unified.json file from NovarumDX

Important

Browser camera access is usually only available in secure contexts. Use HTTPS in deployed environments, or localhost during local development. Opening pages with file:// will not work.

If you want the fastest path to a working scanner, start with the quickstart below. If you are setting up package access, CI, or troubleshooting installation issues, use the installation section first.

Vanilla JS Quickstart

Get from zero to a working scanner in a plain HTML and JavaScript project with no framework and no build step.

Assumptions

This quickstart assumes you already have Node 18+, your .npmrc file from NovarumDX, and your cassette .unified.json file.

Temporary note - SDK v1.0.0 (removed in the next release)

The self-contained browser bundle ndx-imaging-web.browser.mjs is not yet included in the published 1.0.0 package, so it will not appear in your project root after installing. This is fixed in the next release. Until you upgrade, make two small adjustments while following this guide:

  1. After npm install, copy the SDK's bundle folder next to your app: cp -r node_modules/@novarumdx/ndx-imaging-web/dist sdk

  2. Wherever this guide imports from ./ndx-imaging-web.browser.mjs, import from ./sdk/index.mjs instead - for example import { createNDXReader } from './sdk/index.mjs';

Once you move to the release that includes the bundle, undo both adjustments and follow the guide exactly as written.

Step 1: Create a project folder

Bash
mkdir my-scanner
cd my-scanner
npm init -y

Step 2: Configure registry access

If you haven't already, install the AWS CLI and configure a profile with the credentials from your NovarumDX onboarding pack (use region eu-west-2 and output json):

If you haven't already, install the AWS CLI and configure a profile with the credentials from your Novarum DX onboarding pack (use region eu-west-2 and output json):

Bash
aws configure --profile novarumdx-sdk

Then fetch an auth token:

Bash
export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
  --domain novarumdx --domain-owner 945969778369 --region eu-west-2 \
  --query authorizationToken --output text --profile novarumdx-sdk)

If npm install later fails with 401 Unauthorized, refresh the token and try again. The token expires after 12 hours.

Step 3: Install the SDK

Bash
NDX_WASM_PUBLIC_DIR=. npm install @novarumdx/ndx-imaging-web

The SDK's install step copies four runtime files. By default it copies them into a public/ folder; the NDX_WASM_PUBLIC_DIR=. above tells it to copy them into your project root instead, so they sit next to index.html. After installation, these files should appear in your project root:

  • imagingWorker.js

  • ndx_imaging_wasm.js

  • ndx_imaging_wasm.wasm

  • ndx-imaging-web.browser.mjs

Do not delete or move these generated files. They are required at runtime and must be served alongside your application.

Temporary note — SDK v1.0.0

On 1.0.0, ndx-imaging-web.browser.mjs will not appear in the list above. Do adjustment 1 from the note at the top of this section now — copy the SDK's bundle folder next to your app: cp -r node_modules/@novarumdx/ndx-imaging-web/dist sdk

Step 4: Add your cassette file

Create a cassettes folder and copy your cassette file into it:

my-scanner/
  cassettes/
    my-cassette/
      my-cassette.unified.json

Step 5: Create index.html

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Scanner</title>
  <style>
    body { margin: 0; font-family: sans-serif; }
    #scanner-root { width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; }
    #results { padding: 2rem; display: none; }
    button { padding: 1rem 2rem; font-size: 1rem; margin-top: 1rem; display: block; }
  </style>
</head>
<body>
  <div id="scanner-root"></div>
  <div id="results">
    <h2>Results</h2>
    <p id="result-text"></p>
    <button id="btn-again">Scan again</button>
  </div>
  <script type="module" src="app.js"></script>
</body>
</html>

Step 6: Create app.js

JavaScript
import { createNDXReader } from './ndx-imaging-web.browser.mjs';

const scannerRoot = document.getElementById('scanner-root');
const resultsDiv  = document.getElementById('results');
const resultText  = document.getElementById('result-text');
const btnAgain    = document.getElementById('btn-again');

const scanner = createNDXReader({
  config: 'cassettes/my-cassette/my-cassette.unified.json',
  onReady: () => {
    scanner.start();
  },
  onSuccess: (strips) => {
    scanner.stop();
    scannerRoot.style.display = 'none';
    console.log('Scan result (StripAnalysis[]):', strips);

    const strip = strips[0];
    resultText.textContent =
      `Control line: ${strip.cHeight.toFixed(4)} | T/C ratio: ${strip.tcRatio[0].toFixed(4)}`;

    resultsDiv.style.display = 'block';
  },
  onError: (error) => {
    console.error('Scanner error:', error.message);
  },
});

scanner.mount(scannerRoot);

btnAgain.addEventListener('click', () => {
  resultsDiv.style.display = 'none';
  scannerRoot.style.display = 'block';
  scanner.start();
});

Temporary note — SDK v1.0.0

On 1.0.0, import from ./sdk/index.mjs instead of ./ndx-imaging-web.browser.mjs - that is, import { createNDXReader } from './sdk/index.mjs'; (adjustment 2 from the note at the top of this section).

Step 7: Serve and open

Use a local HTTP server:

Bash
npx serve .

Open http://localhost:3000, allow camera access, and hold a cassette in front of the camera.

Testing on a phone

The scanner is built for phone cameras, so you'll usually want to test on a real device.

Camera access needs a secure context

localhost counts, but a plain http://<your-IP> address on a phone does not - the page loads but the camera won't start (an insecure-context error). So the phone has to reach the app over HTTPS.

Easiest: a tunnel (no certificates). A tunnel gives you a public, already-trusted HTTPS URL, so there's nothing to generate or install on the phone. Using cloudflared (no account required):

  1. Install cloudflared once - see the cloudflared downloads page (for example brew install cloudflared on macOS).

  2. You'll use two terminals. In the first, serve the app over plain HTTP:

    Bash
    npx serve . -l 3000
    
  3. In the second, start the tunnel:

    Bash
    cloudflared tunnel --url http://localhost:3000
    
  4. cloudflared prints many log lines - near the top, in a boxed message, is your URL:

    +-----------------------------------------------------------+
      Your quick Tunnel has been created! Visit it at ...:     |
      https://<random-words>.trycloudflare.com                 |
    +-----------------------------------------------------------+
    
  5. Open that https://<random-words>.trycloudflare.com address on your phone, allow the camera, and scan. It works from any network. The URL is temporary and changes each run; close the tunnel when done.

(ngrok works the same way — ngrok http 3000 — but needs a free account and authtoken.)

Alternative: local certificates with mkcert (offline, more setup). If you can't use a tunnel, serve over HTTPS with a locally-trusted certificate using mkcert. Install it for your OS (see the mkcert installation guide), then:

Bash
mkcert -install                       # one-time: create and trust a local CA
mkcert localhost YOUR_COMPUTER_IP     # cert for localhost + your current LAN IP
npx serve . --ssl-cert localhost+1.pem --ssl-key localhost+1-key.pem
  • Use your current LAN IP (find it with ipconfig getifaddr en0 on macOS, ipconfig on Windows, or hostname -I on Linux), and regenerate the cert if it changes.

  • mkcert names the files after the hosts you pass: mkcert localhost YOUR_COMPUTER_IP produces localhost+1.pem / localhost+1-key.pem. If you pass different hosts, use the matching filenames, and run mkcert in the folder you serve from.

  • On the phone you must install AND trust the mkcert root CA or the camera stays blocked (on iOS, tapping past the warning is not enough): transfer rootCA.pem from $(mkcert -CAROOT) to the phone, install it, then enable it under Settings → General → About → Certificate Trust Settings.

Then open https://YOUR_COMPUTER_IP:3000 on your phone (same Wi-Fi).

What you just built

  • createNDXReader sets up the engine and manages the camera and scan loop

  • scanner.mount(el) injects the camera feed and overlay into your container

  • scanner.start() opens the camera and begins scanning

  • onSuccess(strips) receives the final results

Installation

Use this section when you need the full setup flow, including AWS CLI configuration, registry authentication, generated runtime files, CI/CD setup, and installation troubleshooting.

Temporary note - SDK v1.0.0 (removed in the next release)

The self-contained browser bundle ndx-imaging-web.browser.mjs is not yet included in the published 1.0.0 package, so it will not appear in your project root after installing. This is fixed in the next release. Until you upgrade, make two small adjustments while following this guide:

  1. After npm install, copy the SDK's bundle folder next to your app: cp -r node_modules/@novarumdx/ndx-imaging-web/dist sdk

  2. Wherever this guide imports from ./ndx-imaging-web.browser.mjs, import from ./sdk/index.mjs instead - for example import { createNDXReader } from './sdk/index.mjs';

Once you move to the release that includes the bundle, undo both adjustments and follow the guide exactly as written.

Prerequisites

Requirement

Version

Check

Node.js

18 or later

node --version

npm

8 or later

npm --version

AWS CLI

Any recent version

aws --version

AWS credentials

Provided by NovarumDX

Contact NovarumDX

Step 1: Configure the AWS CLI

Run this once per machine:

Bash
aws configure --profile novarumdx-sdk

The profile name is your choice - we use novarumdx-sdk throughout these examples, so if you pick a different name, use it consistently in every command.

Credential safety

The AWS Access Key ID and Secret Access Key are issued to your organisation by NovarumDX and grant read-only access to the Novarum SDK package registry only. Store them in your secret manager, do not commit them to source control, and do not share them. If they are ever exposed, contact NovarumDX so we can rotate them.

Enter the following values when prompted:

  • AWS Access Key ID: value provided by NovarumDX

  • AWS Secret Access Key: value provided by NovarumDX

  • Default region name: eu-west-2

  • Default output format: json

Security note

Keep your credentials safe. Do not commit them to source control or share them in chat or email. If you think they have been exposed, contact NovarumDX immediately so they can be rotated.

Step 2: Configure npm to use the registry

Create a file named .npmrc in your project root alongside package.json. NovarumDX will provide this file.

@novarumdx:registry=https://novarumdx-945969778369.d.codeartifact.eu-west-2.amazonaws.com/npm/novarum.client.npm/
//novarumdx-945969778369.d.codeartifact.eu-west-2.amazonaws.com/npm/novarum.client.npm/:always-auth=true
//novarumdx-945969778369.d.codeartifact.eu-west-2.amazonaws.com/npm/novarum.client.npm/:_authToken=${CODEARTIFACT_AUTH_TOKEN}

The ${CODEARTIFACT_AUTH_TOKEN} value is read from the environment variable you set before install.

Best practice

The .npmrc file above is safe to commit because it contains an environment variable reference rather than a real token.

Step 3: Get an auth token

Bash
export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
  --domain novarumdx \
  --domain-owner 945969778369 \
  --region eu-west-2 \
  --query authorizationToken \
  --output text \
  --profile novarumdx-sdk)

The token expires after 12 hours. Fetch a new token before installs when needed.

You can save time by creating a shell alias:

Bash
alias ndx-login='export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
  --domain novarumdx \
  --domain-owner 945969778369 \
  --region eu-west-2 \
  --query authorizationToken \
  --output text \
  --profile novarumdx-sdk)'

Step 4: Install the SDK

Bash
NDX_WASM_PUBLIC_DIR=. npm install @novarumdx/ndx-imaging-web

The SDK runs a postinstall script that copies its compiled runtime files. By default they go into a public/ folder; setting NDX_WASM_PUBLIC_DIR=. copies them into your project root instead, alongside index.html, which is what the examples in this guide expect.

Generated runtime files

File

What it is

ndx-imaging-web.browser.mjs

ES module browser bundle. Import createNDXReader from this in plain JavaScript projects.

imagingWorker.js

Web Worker script for the imaging pipeline.

ndx_imaging_wasm.js

WebAssembly loader used by the worker.

ndx_imaging_wasm.wasm

The compiled imaging engine.

Required at runtime

These files must be served alongside your application over HTTP or HTTPS. Do not move them unless you are also updating your hosting setup accordingly.

Temporary note — SDK v1.0.0

On v1.0.0, ndx-imaging-web.browser.mjs is not published yet, so it will not be among the copied files. Do adjustment 1 from the note at the top of this section — copy the SDK's bundle folder next to your app: cp -r node_modules/@novarumdx/ndx-imaging-web/dist sdk

Add generated files to .gitignore

ndx-imaging-web.browser.mjs
imagingWorker.js
ndx_imaging_wasm.js
ndx_imaging_wasm.wasm

Verify the installation

The SDK bundle is built for the browser, so verify it from a page rather than from Node. With your files served (see 'Serve and open'), open the browser devtools console on your page and run:

JavaScript
const { createNDXReader } = await import('./ndx-imaging-web.browser.mjs');
console.log('createNDXReader loaded:', typeof createNDXReader);

Temporary note — SDK v1.0.0

On v1.0.0, import from ./sdk/index.mjs instead: const { createNDXReader } = await import('./sdk/index.mjs'); (adjustment 2 from the note at the top of this section).

Expected output:

createNDXReader loaded: function

Serving the SDK files

The browser bundle and WASM files must be served over HTTP. They will not work if opened with file://.

Bash
npx serve .

To test on a phone (the camera needs HTTPS), see 'Testing on a phone' in the Quickstart section.

In production, you can deploy to a static host such as Netlify, S3, or Nginx. The WASM file is large, about 10 MB, so ensure your host accepts it.

Using a build tool

If you use a bundler such as Vite, Webpack, or Rollup, you can import directly from the npm package:

JavaScript
import { createNDXReader } from '@novarumdx/ndx-imaging-web';

You will need bundler configuration that copies the WASM files to your output directory.

CI/CD environments

In CI, fetch a token before running npm install:

YAML
- name: Login to CodeArtifact
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.NOVARUM_AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.NOVARUM_AWS_SECRET_ACCESS_KEY }}
    AWS_DEFAULT_REGION: eu-west-2
  run: |
    export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
      --domain novarumdx \
      --domain-owner 945969778369 \
      --region eu-west-2 \
      --query authorizationToken \
      --output text)
    echo "CODEARTIFACT_AUTH_TOKEN=$CODEARTIFACT_AUTH_TOKEN" >> $GITHUB_ENV

- name: Install dependencies
  run: npm install
  • The example above is GitHub Actions syntax. For other CI systems (GitLab CI, Bitbucket Pipelines, Jenkins, etc.) use the equivalent secret/env-var mechanism — the steps are the same: provide the AWS Access Key ID/Secret as secrets, run aws codeartifact get-authorization-token to set CODEARTIFACT_AUTH_TOKEN, then install.

  • Your .npmrc must be committed to the repo (safe — it references ${CODEARTIFACT_AUTH_TOKEN}, not a real token) so the install uses the Novarum registry.

  • A fresh token is minted on every build, so the 12-hour expiry is never an issue in CI.

  • If your pipeline also builds/deploys the app (not just installs dependencies), apply the same steps from the Installation section — NDX_WASM_PUBLIC_DIR=. (or your public folder) so the runtime files land correctly, and, on v1.0.0, the cp -r node_modules/@novarumdx/ndx-imaging-web/dist sdk workaround.

Vanilla JS - Your First Scan

Use this section when you want a fuller, production-style scan flow with clear app states before, during, and after scanning.

Lifecycle summary

createNDXReader(config) creates the scanner and starts loading the engine immediately. scanner.mount(element) injects the camera view and overlay. scanner.start() opens the camera and begins scanning. scanner.stop() closes the camera. scanner.unmount() removes the scanner view from the DOM.

Create the scanner once and reuse it across scan sessions.

Complete example

This example uses three views: pre-scan, scanning, and results.

index.html

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Scanner</title>
  <style>
    * { box-sizing: border-box; }
    body { margin: 0; font-family: sans-serif; background: #f9f9f9; }
    .view { width: 100vw; min-height: 100vh; display: none; }
    .view.active { display: flex; flex-direction: column; align-items: center; justify-content: center; }
    #view-scan { position: fixed; inset: 0; background: #000; }
    #scan-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; padding-bottom: 6rem; }
    #btn-back { position: fixed; top: 1rem; left: 1rem; z-index: 10; background: rgba(0,0,0,0.5); color:#fff; border:none; padding:.5rem 1rem; }
    #view-pre, #view-post { padding: 2rem; }
    button.primary { padding: 1rem 2rem; font-size: 1rem; cursor: pointer; }
    table { border-collapse: collapse; margin-bottom: 1.5rem; }
    td { padding: .5rem 1rem; border: 1px solid #ddd; }
    td:first-child { font-weight: bold; }
    .error { color: red; margin-top: 1rem; }
    /* Scan-page overlay (hint + fit + progress). The SDK does NOT draw these — your app owns them. */
    #scan-hud { position: fixed; left: 0; right: 0; bottom: 0; z-index: 10; padding: 1rem; text-align: center; color: #fff; background: linear-gradient(transparent, rgba(0,0,0,0.65)); }
    #scan-hint { margin: 0 0 .5rem; }
    #fit-chip { display: inline-block; padding: .25rem .75rem; border-radius: 999px; font-size: .85rem; margin-bottom: .5rem; }
    #fit-chip.fit-0 { background: #b00020; }
    #fit-chip.fit-1 { background: #b8860b; }
    #fit-chip.fit-2 { background: #1b7f3b; }
    #progress-bar { width: 80%; max-width: 320px; height: 12px; display: block; margin: 0 auto .25rem; }
    #progress-label { font-size: .85rem; }
    #raw-json { margin: 0 auto 1.5rem; max-width: 640px; text-align: left; }
    #raw-json pre { max-height: 240px; overflow: auto; background: #f4f4f4; padding: .75rem; font-size: .8rem; }
  </style>
</head>
<body>
  <div id="view-pre" class="view active">
    <h1>Ready to scan</h1>
    <p id="loading-msg">Loading imaging engine...</p>
    <button id="btn-start" class="primary" disabled>Begin Scan</button>
    <p id="error-pre" class="error" style="display:none;"></p>
  </div>
  <div id="view-scan" class="view">
    <div id="scan-container"></div>
    <button id="btn-back">Back</button>
    <div id="scan-hud">
      <p id="scan-hint">Point camera at the cassette and hold steady</p>
      <div id="fit-chip" class="fit-0">Fit: No Fit</div>
      <progress id="progress-bar" value="0" max="1"></progress>
      <div id="progress-label">0 / 0 frames</div>
    </div>
  </div>
  <div id="view-post" class="view">
    <h1>Results</h1>
    <table id="results-table"></table>
    <p>To turn these numbers into a positive/negative result, see "Working with Results" — the threshold is assay-specific and provided by NovarumDX.</p>
    <details id="raw-json" open><summary>Raw result (JSON)</summary><pre id="raw-json-pre"></pre></details>
    <button id="btn-again" class="primary">Scan again</button>
  </div>
  <script type="module" src="app.js"></script>
</body>
</html>

app.js

JavaScript
import { createNDXReader } from './ndx-imaging-web.browser.mjs';
const views         = document.querySelectorAll('.view');
const viewPre       = document.getElementById('view-pre');
const viewScan      = document.getElementById('view-scan');
const viewPost      = document.getElementById('view-post');
const scanContainer = document.getElementById('scan-container');
const btnStart      = document.getElementById('btn-start');
const btnBack       = document.getElementById('btn-back');
const btnAgain      = document.getElementById('btn-again');
const loadingMsg    = document.getElementById('loading-msg');
const resultsTable  = document.getElementById('results-table');
const errorPre      = document.getElementById('error-pre');
const fitChip       = document.getElementById('fit-chip');
const progressBar   = document.getElementById('progress-bar');
const progressLabel = document.getElementById('progress-label');
const scanHint      = document.getElementById('scan-hint');
function showView(el) {
  views.forEach(v => v.classList.remove('active'));
  el.classList.add('active');
}
// The SDK does not draw a progress bar or fit indicator — the app owns them,
// driven by the onReady / onFitStatus / onProgress callbacks below.
function resetScanHud() {
  const max = scanner.maxProgress || 0;
  progressBar.max = max;
  progressBar.value = 0;
  progressLabel.textContent = `0 / ${max} frames`;
  scanHint.textContent = 'Point camera at the cassette and hold steady';
  scanHint.style.display = '';
  fitChip.textContent = 'Fit: No Fit';
  fitChip.className = 'fit-0';
}
const scanner = createNDXReader({
  config: 'cassettes/my-cassette/my-cassette.unified.json',
  onReady: () => {
    loadingMsg.style.display = 'none';
    btnStart.disabled = false;
    btnStart.textContent = 'Begin Scan';
    progressBar.max = scanner.maxProgress;
    progressLabel.textContent = `0 / ${scanner.maxProgress} frames`;
  },
  onFitStatus: (status) => {
    // 0 = no fit, 1 = marginal, 2 = aligned
    fitChip.textContent = 'Fit: ' + (['No Fit', 'Marginal', 'Fit'][status] ?? 'No Fit');
    fitChip.className = 'fit-' + status;
    if (progressBar.value === 0) {
      scanHint.textContent = status === 0
        ? 'Point camera at the cassette and hold steady'
        : 'Hold still — locking on…';
    }
  },
  onProgress: (value, max, strip) => {
    progressBar.max = max;
    progressBar.value = value;
    progressLabel.textContent = `${value} / ${max} frames`;
    if (value > 0) scanHint.style.display = 'none';
  },
  onWarnings: (warnings) => {},
  onSuccess: (strips, pmfStory, diagnostics) => {
    scanner.stop();
    renderResults(strips);
    showView(viewPost);
  },
  onError: (error) => {
    scanner.stop();
    showView(viewPre);
    errorPre.textContent = error.message;
    errorPre.style.display = 'block';
  },
  onAbort: (pmfStory, diagnostics) => {},
});
scanner.mount(scanContainer);
btnStart.addEventListener('click', () => {
  errorPre.style.display = 'none';
  resetScanHud();
  showView(viewScan);
  scanner.start();
});
btnBack.addEventListener('click', () => {
  scanner.stop();
  showView(viewPre);
});
btnAgain.addEventListener('click', () => {
  resultsTable.innerHTML = '';
  resetScanHud();
  showView(viewScan);
  scanner.start();
});
function renderResults(strips) {
  console.log('Scan result (StripAnalysis[]):', strips);
  const num = (v) => (typeof v === 'number' ? v.toFixed(4) : String(v));
  const row = (label, val) => `<tr><td>${label}</td><td>${num(val)}</td></tr>`;
  let html = '';
  strips.forEach((strip, stripIndex) => {
    if (strips.length > 1) {
      html += `<tr><td colspan="2"><strong>Strip ${stripIndex + 1}</strong></td></tr>`;
    }
    html += row('Control line height (cHeight)', strip.cHeight);
    html += row('Control line position (cPos)', strip.cPos);
    html += row('Control area (cArea)', strip.cArea);
    strip.tcRatio.forEach((ratio, lineIndex) => {
      html += `<tr><td colspan="2"><em>Test line ${lineIndex + 1}</em></td></tr>`;
      html += row('T/C ratio', ratio);
      html += row('Test height (tHeight)', strip.tHeight[lineIndex]);
      html += row('Test position (tPos)', strip.tPos[lineIndex]);
      html += row('Test area (tArea)', strip.tArea[lineIndex]);
    });
    html += row('Baseline fit MSE (baselineMse)', strip.baselineMse);
  });
  resultsTable.innerHTML = html;
  document.getElementById('raw-json-pre').textContent = JSON.stringify(strips, null, 2);
}

Temporary note — SDK v1.0.0

On 1.0.0, import from ./sdk/index.mjs instead of ./ndx-imaging-web.browser.mjs — that is, import { createNDXReader } from './sdk/index.mjs';. The browser bundle isn't published yet; see the temporary note in the Quickstart or Installation section for the full workaround.

Why this pattern works

The SDK owns the camera, overlay, and scan pipeline. Your application owns which screen is visible, how errors are shown, and what happens with final results.

Scanner methods

Method

What it does

scanner.mount(element)

Injects the camera feed and overlay into the element. Call this before start().

scanner.start()

Opens the camera and begins the scan loop after the engine is ready.

scanner.stop()

Closes the camera and stops the scan loop.

scanner.unmount()

Removes the scanner view from the DOM.

Order matters

Always call mount() before start(). Mount once and reuse the scanner across scans: when a scan completes call stop(), and call start() again to scan once more — you do not need to unmount and re-mount between scans. Only call unmount() when you are finished with the scanner, or before creating a new reader for a different cassette.

Common patterns

Disable start until the engine is ready

JavaScript
btnStart.disabled = true;

const scanner = createNDXReader({
  config: '...',
  onReady: () => {
    btnStart.disabled = false;
  },
});

Clean up when the user leaves the page

JavaScript
window.addEventListener('beforeunload', () => {
  scanner.stop();
  scanner.unmount();
});

Time out a scan that is taking too long

JavaScript
let scanTimeout;

btnStart.addEventListener('click', () => {
  scanner.start();

  scanTimeout = setTimeout(() => {
    scanner.stop();
    showView(viewPre);
    errorPre.textContent = 'Scan timed out. Please try again.';
    errorPre.style.display = 'block';
  }, 60000);
});

const scanner = createNDXReader({
  config: '...',
  onSuccess: (strips) => {
    clearTimeout(scanTimeout);
  },
  onError: (error) => {
    clearTimeout(scanTimeout);
  },
});

Working with Results

When a scan completes, onSuccess is called with the analysis output. For most applications, the main value you need is strips.

The onSuccess callback

JavaScript
const scanner = createNDXReader({
  config: 'cassettes/my-cassette/my-cassette.unified.json',
  onSuccess: (strips, pmfStory, diagnostics) => {
    // strips      - array of StripAnalysis
    // pmfStory    - per-frame diagnostic log
    // diagnostics - image capture diagnostics
    console.log(strips);
  },
});

What is in StripAnalysis

Field

Type

What it is

tcRatio

number[]

T/C ratio for each test line. This is usually the primary result value.

cHeight

number

Height of the control line peak above the baseline.

tHeight

number[]

Height of each test line peak above the baseline.

cPos

number

Pixel position of the control line along the strip profile.

tPos

number[]

Pixel positions of each test line.

cArea

number

Integrated area under the control line peak.

tArea

number[]

Integrated area under each test line peak.

profile

number[]

Full intensity profile across the strip width.

baseline

number[]

Fitted polynomial baseline for the strip profile.

baselineMse

number

Mean squared error of the baseline fit. Lower is better.

profileRegions

object

Index ranges ({ baseline, control, test }, each an array of [start, end] pairs) marking regions along the profile — used to shade a chart.

The primary result: T/C ratio

tcRatio is the ratio of each test line height to the control line height. Your application decides what the number means.

JavaScript
onSuccess: (strips) => {
  const strip = strips[0];
  const ratio = strip.tcRatio[0];
  console.log('T/C ratio:', ratio);
},

NovarumDX will provide the correct threshold for your assay. Do not hardcode production thresholds without assay-specific confirmation.

JavaScript
const THRESHOLD = 0.05; // example only - confirm with Novarum DX

onSuccess: (strips) => {
  const ratio = strips[0].tcRatio[0];
  const result = ratio > THRESHOLD ? 'Positive' : 'Negative';
  showResult(result, ratio);
},

Checking the control line

Before trusting a T/C ratio, validate the control line:

JavaScript
const C_HEIGHT_THRESHOLD = 8.0; // example only - confirm with Novarum DX

onSuccess: (strips) => {
  const strip = strips[0];

  if (strip.cHeight < C_HEIGHT_THRESHOLD) {
    showError('Scan quality too low - please try again.');
    return;
  }

  const ratio = strip.tcRatio[0];
  showResult(ratio > THRESHOLD ? 'Positive' : 'Negative', ratio);
},

Important

Always validate the control line before using the test result. A weak control line can make the reported ratio unreliable.

Deciding the result: positive / negative / inconclusive

Putting the two checks together, most assays resolve to three outcomes:

  1. Inconclusive / invalid — the control line is too weak (cHeight below the control threshold). The scan can't be trusted; ask the user to re-scan.

  2. Positive or Negative — with a valid control line, compare tcRatio[0] against your assay threshold.

Both thresholds are assay-specific and supplied by Novarum DX — never hardcode production values without confirmation.

JavaScript
onSuccess: (strips) => {
  const strip = strips[0];

  // 1. Is the control line valid?
  if (strip.cHeight < C_HEIGHT_THRESHOLD) {   // threshold from Novarum DX
    showResult('Inconclusive — please re-scan');
    return;
  }

  // 2. Positive or negative?
  const positive = strip.tcRatio[0] > TC_THRESHOLD;  // threshold from Novarum DX
  showResult(positive ? 'Positive' : 'Negative');
},

Multi-strip cassettes

If a cassette has more than one strip, the strips array will have one entry per strip. Some strips may also have more than one test line.

JavaScript
onSuccess: (strips) => {
  strips.forEach((strip, index) => {
    console.log(`Strip ${index + 1}: T/C ratio = ${strip.tcRatio[0].toFixed(4)}`);
  });
},

Rendering a results table

JavaScript
function renderResults(strips) {
  const table = document.getElementById('results-table');
  let html = '';

  strips.forEach((strip, stripIndex) => {
    if (strips.length > 1) {
      html += `<tr><td colspan="2"><strong>Strip ${stripIndex + 1}</strong></td></tr>`;
    }

    html += `<tr>
      <td>Control line</td>
      <td>${strip.cHeight.toFixed(4)}</td>
    </tr>`;

    strip.tcRatio.forEach((ratio, lineIndex) => {
      html += `<tr>
        <td>T/C ratio (line ${lineIndex + 1})</td>
        <td>${ratio.toFixed(4)}</td>
      </tr>`;
    });
  });

  table.innerHTML = html;
}

Plotting the strip profile

Optional — a visualisation aid for QA/debugging; not needed to decide a result.

You can chart the strip's intensity profile with a library like Chart.js. It uses profile (the intensity trace), baseline (the fitted baseline), profileRegions (shaded baseline/control/test bands) and cPos/tPos (control/test line markers). Add Chart.js from a CDN and a canvas:

HTML
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script>
<div id="chart-wrap" style="max-width:640px;height:260px;"><canvas id="profile-chart"></canvas></div>

Then render the first strip. Create the chart only once its container is visible, or Chart.js sizes the canvas to 0:

JavaScript
let profileChart = null;

function renderChart(strip) {
  const canvas = document.getElementById('profile-chart');
  if (profileChart) { profileChart.destroy(); profileChart = null; }

  const n = strip.profile.length;
  const labels = Array.from({ length: n }, (_, i) => i);
  const regions = strip.profileRegions ?? { baseline: [], control: [], test: [] };
  const Y_MAX = 285;    // headroom above the 0–255 range so labels sit clear of the data
  const LABEL_Y = 278;
  const toPx = (chart, v) => {
    const { left, right } = chart.chartArea;
    return left + (v / (n - 1)) * (right - left);
  };

  const bandPlugin = {
    id: 'bands',
    beforeDraw(chart) {
      const { ctx, chartArea: { top, bottom }, scales: { y } } = chart;
      const bands = [
        ...regions.baseline.map(([s, e]) => ({ s, e, color: 'rgba(25,118,210,0.15)', text: 'Baseline', textColor: '#1565c0' })),
        ...regions.control.map(([s, e])  => ({ s, e, color: 'rgba(46,125,50,0.15)',  text: 'Control',  textColor: '#1b5e20' })),
        ...regions.test.map(([s, e])     => ({ s, e, color: 'rgba(211,47,47,0.15)',  text: 'Test',     textColor: '#b71c1c' })),
      ];
      const labelPx = y.getPixelForValue(LABEL_Y);
      ctx.save();
      ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      for (const b of bands) {
        const x1 = toPx(chart, b.s), x2 = toPx(chart, b.e);
        ctx.fillStyle = b.color; ctx.fillRect(x1, top, x2 - x1, bottom - top);
        ctx.fillStyle = b.textColor; ctx.fillText(b.text, (x1 + x2) / 2, labelPx);
      }
      ctx.restore();
    },
  };

  const markerPlugin = {
    id: 'markers',
    afterDraw(chart) {
      const { ctx, chartArea: { top, bottom } } = chart;
      ctx.save();
      const mark = (pos, label, color) => {
        const px = toPx(chart, pos);
        ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]);
        ctx.beginPath(); ctx.moveTo(px, top); ctx.lineTo(px, bottom); ctx.stroke();
        ctx.setLineDash([]);
        ctx.fillStyle = color; ctx.font = 'bold 10px sans-serif'; ctx.textAlign = 'center';
        ctx.fillText(label, px, top + 12);
      };
      mark(strip.cPos, 'C', '#2e7d32');
      strip.tPos.forEach((pos, i) => mark(pos, `T${i + 1}`, '#b71c1c'));
      ctx.restore();
    },
  };

  profileChart = new Chart(canvas, {
    type: 'line',
    plugins: [bandPlugin, markerPlugin],
    data: {
      labels,
      datasets: [
        { label: 'Profile',  data: strip.profile,  borderColor: '#1976d2', borderWidth: 2, pointRadius: 0, tension: 0 },
        { label: 'Baseline', data: strip.baseline, borderColor: '#9e9e9e', borderWidth: 1.5, borderDash: [4, 3], pointRadius: 0, tension: 0 },
      ],
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      animation: false,
      plugins: { legend: { position: 'top' }, tooltip: { enabled: false } },
      scales: { y: { min: 0, max: Y_MAX }, x: { ticks: { maxTicksLimit: 6 } } },
    },
  });
}

// call renderChart(strips[0]) in onSuccess, after your results view is shown

This charts the first strip. Most cassettes are single-strip; for a multi-strip cassette, render one chart per strip (or add a strip selector) — the fields are the same on every StripAnalysis. Chart.js loads from a CDN, so there's no npm dependency.

The SDK also includes serializeAnalysisResult for packaging results into the JSON format expected by the Novarum DX server API. Contact Novarum DX for the server API specification and integration guidance.

Sending the result to Novarum

serializeAnalysisResult packages the scan into the server-format JSON that the Novarum DX server API expects. It needs engine-configuration context, so contact Novarum DX for the server API specification and integration guidance — they'll help you wire it up as part of your integration. (A future SDK release will make this a one-liner.)

Downloading or uploading the result

You can save the results to a file or POST them to your own server. This helper downloads any JSON string:

JavaScript
import { serializePmfStory } from './ndx-imaging-web.browser.mjs';

function downloadJSON(filename, text) {
  const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

// In onSuccess you have strips + pmfStory — wire them to buttons:
btnDownloadResult.addEventListener('click', () => {
  downloadJSON('result-strips.json', JSON.stringify(strips, null, 2));
});

btnDownloadPmf.addEventListener('click', () => {
  downloadJSON('pmf-story.json', serializePmfStory(pmfStory));
});

// Or POST the results to your own server:
await fetch('https://your-api.example.com/results', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(strips),
});

Temporary note — SDK v1.0.0

On v1.0.0, import from ./sdk/index.mjs instead of ./ndx-imaging-web.browser.mjs.

Optional: bundle everything as a ZIP

To send one file for support — the envelope, the PMF story, and all the images together — add JSZip from a CDN and zip them:

HTML
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
JavaScript
async function downloadBundle(strips, pmfStory, frames) {
  const zip = new JSZip();
  zip.file('result-strips.json', JSON.stringify(strips, null, 2));
  zip.file('pmf-story.json', serializePmfStory(pmfStory));

  let f = 0;
  for (const frame of frames) {
    if (frame.imageObjectUrl) {
      zip.file(`images/frame-${f}.png`, await (await fetch(frame.imageObjectUrl)).blob());
    }

    const stripUrls = frame.stripImageObjectUrls ?? [];
    for (let s = 0; s < stripUrls.length; s++) {
      if (stripUrls[s]) {
        zip.file(`images/frame-${f}-strip-${s}.png`, await (await fetch(stripUrls[s])).blob());
      }
    }

    f++;
  }

  const blob = await zip.generateAsync({ type: 'blob' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'scan-bundle.zip';
  a.click();
  URL.revokeObjectURL(url);
}

frames is the resolved array from resolveStoryImages(pmfStory) (see PMF Story). Because the envelope references images by key rather than embedding them, the ZIP is the simplest way to send results and images together.

PMF Story and Diagnostics

Advanced / optional. This section covers per-frame diagnostics and captured images — useful for QA, debugging and support. You don't need any of it to scan a test and read a result.

The PMF story is a per-frame diagnostic log captured during a scan. It records alignment quality, intensity profiles, and optionally images. It is mainly useful for debugging, quality assurance, and server-side support workflows.

Most applications do not need to display PMF story data to end users. A common pattern is to capture it and send it to the server alongside the final result.

Enabling PMF story capture

PMF story capture is enabled by default and controlled with pmfStoryConfig:

JavaScript
const scanner = createNDXReader({
  config: 'cassettes/my-cassette/my-cassette.unified.json',
  pmfStoryConfig: {
    enabled: true,
    captureImages: true,
    captureErrorFrameImages: true,
  },
  onSuccess: (strips, pmfStory, diagnostics) => {
    console.log(`Captured ${pmfStory.length} frames`);
  },
});

Option

Default

What it does

enabled

true

Captures the per-frame log. Set to false to disable PMF story collection entirely.

captureImages

false

Saves a PNG of each well-aligned frame to IndexedDB.

captureErrorFrameImages

false

Saves PNGs for frames where baseline or exposure errors occur.

Image capture requires OffscreenCanvas. It is supported in Chrome 69+, Firefox 105+, and Safari 16.4+. On unsupported browsers, image capture is ignored silently.

The pmfStory array

Field

Type

What it is

timestamp

number

Unix timestamp in milliseconds when the frame was processed.

pmfStatus

0, 1, or 2

Alignment quality: 0 no fit, 1 marginal, 2 well-aligned.

lux

number

Estimated ambient light level in lux.

stripStatuses

string[]

Status of each strip such as None, Good, Done, BaselineError, or ExposureError.

stripProfiles

array

Intensity profile for each strip for that frame.

imageUrl

string or null

IndexedDB key for the full-frame image, or null.

stripImageUrls

array

IndexedDB keys for cropped strip images.

Displaying captured images

Captured images are stored in IndexedDB. Resolve the stored keys into Blob URLs before rendering them:

JavaScript
import {
  createNDXReader,
  resolveStoryImages,
  revokeStoryImageUrls,
} from './ndx-imaging-web.browser.mjs';

let resolvedFrames = [];

const scanner = createNDXReader({
  config: 'cassettes/my-cassette/my-cassette.unified.json',
  pmfStoryConfig: { enabled: true, captureImages: true },
  onSuccess: async (strips, pmfStory) => {
    resolvedFrames = await resolveStoryImages(pmfStory);
    renderImages(resolvedFrames);
    scanner.stop();
    showResultsView();
  },
});

Temporary note — SDK v1.0.0

On 1.0.0, every import in this section should read from ./sdk/index.mjs instead of ./ndx-imaging-web.browser.mjs — for example import { createNDXReader, resolveStoryImages, revokeStoryImageUrls } from './sdk/index.mjs';. The browser bundle isn't published yet; see the temporary note in the Quickstart or Installation section for the full workaround.

Each resolved frame includes:

  • imageObjectUrl: Blob URL for the full-frame image, or null

  • stripImageObjectUrls: Blob URLs for cropped strip images, or null per strip

JavaScript
function renderImages(frames) {
  const frameImages = document.getElementById('frame-images');
  const stripImages = document.getElementById('strip-images');
  frameImages.innerHTML = '';
  stripImages.innerHTML = '';

  const downloadableImage = (url, filename, alt) => {
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    a.title = 'Click to download';

    const img = document.createElement('img');
    img.src = url;
    if (alt) img.alt = alt;

    a.appendChild(img);
    return a;
  };

  frames
    .filter((f) => f.imageObjectUrl)
    .forEach((frame, i) => {
      frameImages.appendChild(downloadableImage(frame.imageObjectUrl, `frame-${i}.png`, `Frame ${i}`));
    });

  frames
    .filter((f) => (f.stripImageObjectUrls ?? []).some(Boolean))
    .forEach((frame, i) => {
      const group = document.createElement('div');
      group.className = 'strip-group';

      const label = document.createElement('span');
      label.className = 'strip-label';
      label.textContent = `Frame ${i} — fit`;
      group.appendChild(label);

      (frame.stripImageObjectUrls ?? []).forEach((url, si) => {
        if (!url) return;
        group.appendChild(downloadableImage(url, `frame-${i}-strip-${si}.png`));
      });

      stripImages.appendChild(group);
    });
}

It renders into two containers on your page — <div id="frame-images"></div> and <div id="strip-images"></div> — with a little CSS (frame stills as 4:3 thumbnails; strip crops on black, grouped per frame):

CSS
#frame-images { display: flex; flex-wrap: wrap; gap: .4rem; }
#frame-images img { width: 120px; height: 90px; object-fit: cover; background: #000; }
#frame-images a, #strip-images a { text-decoration: none; line-height: 0; }
#strip-images { display: flex; flex-direction: column; gap: .75rem; }
.strip-group { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.strip-label { font-family: monospace; font-size: .75rem; color: #888; min-width: 90px; }
.strip-group img { height: 90px; width: auto; max-width: 120px; object-fit: contain; background: #000; }

Each image is wrapped in a download link, so a click saves the PNG. (On iOS Safari the download attribute may open the image in a new tab rather than saving it.)

Memory note

Blob URLs keep memory alive until they are revoked. Always call revokeStoryImageUrls when the images are no longer needed.

JavaScript
document.getElementById('btn-again').addEventListener('click', () => {
  if (resolvedFrames.length > 0) {
    revokeStoryImageUrls(resolvedFrames);
    resolvedFrames = [];
  }
});

Identifying error frames

You can distinguish error frames from normal frames using the metadata:

  • Normal captured frame: imageUrl is not null and stripImageUrls includes crop keys

  • Error frame: imageUrl is not null and all stripImageUrls are null

JavaScript
const errorFrames = pmfStory.filter(frame =>
  frame.imageUrl !== null &&
  frame.stripImageUrls.every(url => url === null)
);

console.log(`${errorFrames.length} error frames`);

Cleaning up between sessions

The SDK clears PMF story images automatically at the start of each scan, but old images may remain if the page closes mid-session. Clean up on load if needed:

JavaScript
import {
  createNDXReader,
  clearAllPmfStoryImages,
} from './ndx-imaging-web.browser.mjs';

clearAllPmfStoryImages().catch(() => {
  // best effort - ignore errors
});

const scanner = createNDXReader({ ... });

The SDK includes serializeAnalysisResult to package strips, PMF story, and diagnostics into the format expected by the Novarum DX server API.

Configuration Reference

Use relative paths for Vanilla JS projects, and make sure all SDK files and cassette files are served over HTTP rather than opened with file://.

createNDXReader options

JavaScript
const scanner = createNDXReader({
  config,
  hScale,
  nFrames,
  collectionMode,
  cameraDeviceId,
  torchEnabled,
  testName,
  pmfStoryConfig,
  onReady,
  onFitStatus,
  onProgress,
  onWarnings,
  onSuccess,
  onError,
  onAbort,
});

Option reference

config

Type: string
Required: yes

Path to the unified JSON cassette configuration file, relative to your index.html.

JavaScript
config: 'cassettes/my-cassette/my-cassette.unified.json'

hScale

Type: number
Default: value from the cassette config file

Homography scale factor override. Only set this if Novarum DX has asked you to override it.

nFrames

Type: number
Default: value from the cassette config file

Number of frames to collect per strip before running analysis. Higher values give more accurate results but take longer.

collectionMode

Type: boolean
Default: false

When true, the SDK collects frames and images but skips final strip analysis. onSuccess still fires, but strips will be empty.

cameraDeviceId

Type: string
Default: default rear camera

The device ID of the camera to use. Leave empty to use the default rear-facing camera in most applications.

JavaScript
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter(d => d.kind === 'videoinput');
// cameras[i].deviceId

torchEnabled

Type: boolean
Default: false

Requests the device torch while scanning. This mainly works on Android Chrome and is ignored on unsupported platforms.

testName

Type: string
Default: 'unknown'

Name for the scan, used to generate the session ID included in the PMF story. Example: 'covid-antigen'.

pmfStoryConfig

Type: object
Default: { enabled: true, captureImages: false }

JavaScript
pmfStoryConfig: {
  enabled: true,
  captureImages: true,
  captureErrorFrameImages: true,
  maxFrames: 1000,
}

onReady

Type: () => void

Called when the cassette model is loaded and the engine is ready to scan. Enable your Begin Scan button here.

JavaScript
onReady: () => {
  btnStart.disabled = false;
  btnStart.textContent = 'Begin Scan';
},

onFitStatus

Type: (fitStatus: 0 | 1 | 2) => void

Called on every frame with the current alignment quality. The built-in overlay changes colour automatically.

onProgress

Type: (value: number, max: number, strip: number) => void

Called each time a frame is stored, with the number stored so far, the total required, and the strip index. Use it to drive your own progress UI (see 'Your First Scan').

JavaScript
onProgress: (value, max, strip) => {
  document.getElementById('progress').textContent = `${value} / ${max}`;
},

onWarnings

Type: (warnings: Array<{ strip: number, warnCode: number }>) => void

Called when the set of active quality warnings changes.

warnCode

Meaning

1

Baseline quality too low. Check for shadows or uneven lighting.

2

Exposure out of range. The image is too bright or too dark.

3

Both baseline and exposure problems are present.

onSuccess

Type: (strips, pmfStory, diagnostics) => void

Called when the scan completes successfully.

onError

Type: (error: { code: string, message: string }) => void

Called when the camera or engine encounters an error it cannot recover from.

Field

Type

What it is

code

string

Error code identifier (see below).

message

string

Human-readable description that is safe to show to the user.

Common error codes:

code

Cause

camera-permission-denied

User denied camera access.

camera-not-found

No camera is available on the device.

camera-in-use

The camera is being used by another app or browser tab.

insecure-context

The page is not a secure context (needs HTTPS or localhost).

worker-error

The imaging engine failed to initialise.

onAbort

Type: (pmfStory, diagnostics) => void

Called if scanner.stop() is called before the scan completes. It provides the partial PMF story collected up to that point.

Imports from the browser bundle

JavaScript
import {
  createNDXReader,
  resolveStoryImages,
  revokeStoryImageUrls,
  clearAllPmfStoryImages,
  serializeAnalysisResult,
  serializePmfStory,
} from './ndx-imaging-web.browser.mjs';

Temporary note — SDK v1.0.0

On 1.0.0, import from ./sdk/index.mjs instead of ./ndx-imaging-web.browser.mjs. The browser bundle isn't published yet; see the temporary note in the Quickstart or Installation section for the full workaround.

Best practice

Start with a minimal setup: config, onReady, onSuccess, and onError. Add advanced options such as cameraDeviceId, torchEnabled, or pmfStoryConfig only when your application needs them.

Support and Troubleshooting

Common installation, camera, and runtime issues - for both React and Vanilla - are collected on the shared Troubleshooting page: Troubleshooting. Check there first.

For access credentials, configuration files, or integration assistance, please contact your Novarum support representative or technical account manager.

 

For streamlined support and rapid troubleshooting, users can access the SDK Service Desk. This portal provides a direct channel for submitting integration queries, reporting issues, and tracking the status of your requests. In addition, the service desk is equipped with an intelligent agent capable of answering common technical questions and guiding you through standard troubleshooting steps.

We recommend using this resource for the fastest resolution of SDK-related issues or to obtain up-to-date technical guidance.

Document History

Revision Summary Date
01 Initial revision Jul 20, 2026
02 Correction to Support section and add document history Jul 20, 2026