Skip to content

Integrate with DUST Go

DUST Go is an embedded mobile browser: it loads your web app in a WebView and exposes the device’s DUST scanning hardware to the page through a small JavaScript bridge, @dustid/dust-go-connect. You build and host an ordinary web app; DUST Go supplies the camera, optical accessories, and capture pipeline — no native toolchain required.

This page takes you to one resolved scan. Everything beyond that — sign-in, geolocation, compatibility, desktop hardware — is in Beyond the first scan below.

Before you start

Role
A Service Account credential held by your backend
Device
An iPhone with DUST Go and a supported Loupe accessory for DUST capture
  1. A user opens your web app inside DUST Go (via an app link).
  2. Your page imports @dustid/dust-go-connect; the library detects the DUST Go bridge and exposes a connector.
  3. Your page calls scanAsync(). DUST Go opens the native scanner over your page.
  4. On capture, DUST Go delivers a scan event back to your page: the payload carries the capture data and metadata.
  5. Your page posts the capture to your backend, which calls APID (/api/v1/tags/identify, /bind, or /verify) to resolve it.

A DUST scan hands your page a raw capture (a base64-encoded JPEG) plus capture metadata — not a resolved Identifier. Identification, verification, and binding all happen server-side.

Terminal window
npm install @dustid/dust-go-connect

The package is dependency-free, MIT-licensed, and ships TypeScript types.

The connector export is undefined when your page is not running inside DUST Go, so the same build of your app can serve regular browsers and DUST Go:

dust-go.ts — runs in the BROWSER
import { connector } from "@dustid/dust-go-connect";
export const insideDustGo = Boolean(connector);

Two things to know:

  • Import timing. Detection happens at module-import time, in the browser. If you server-render, gate any connector usage on hydration.
  • Server-side detection. Recent DUST Go builds also tag the WebView User-Agent with DustGo/<version> (<app id>), so your server can detect the app before any JavaScript runs. Treat this as a hint, not a security boundary — anyone can spoof a User-Agent.

The simplest path is the promise API — present the scanner and await one capture:

scan.ts — runs in the BROWSER
import { scanAsync } from "@dustid/dust-go-connect";
const payload = await scanAsync();
// payload: { type: 'DUST' | 'QR' | 'BARCODE' | 'DATA_MATRIX' | 'NFC',
// data: string, metadata?: ScanMetadata }

scanAsync() returns a Promise<ScanPayload>. It rejects if the user closes the scanner without capturing, and it rejects immediately when called outside DUST Go (no connector present) — so guard on connector first if the same code path runs in regular browsers.

payload.typepayload.dataNotes
DUSTBase64-encoded JPEG of the DUST captureLarge; resolve it server-side via APID
QR, BARCODE, DATA_MATRIXThe decoded symbol contents
NFCThe hex id read from the NFC chip

payload.metadata (typed ScanMetadata) describes the capture: device identifiers (deviceId, modelName, osName, osVersion, appVersion), lens/camera selection, and optionally optics fields (zoom, focus, exposure, ISO), geolocation (latitude/longitude/accuracy), and capture-source details for external scan accessories (captureSource, usbVendorId, usbProductId, dragonBackend). Forward it opaquely when binding — APID stores it with the Identifier.

Read this before the resolve step: it decides the shape of your whole integration.

So the resolve step is two files in two places:

FileRunsHolds the DUST credential?
Your scanning pageBrowser inside DUST GoNo
Your /api/dust-scan handlerYour serverYes

The DUST capture is only useful once APID has matched it. The page decodes the base64 capture to binary and posts it to your own endpoint:

identify.ts — runs in the BROWSER
// No DUST credential in this file.
async function identifyDustScan(base64Jpeg: string) {
// The scan arrives base64-encoded; APID expects binary multipart data.
const bytes = Uint8Array.from(atob(base64Jpeg), (c) => c.charCodeAt(0));
const form = new FormData();
form.set("operation", "identify");
form.set("tagType", "DUST");
form.set("data", new Blob([bytes], { type: "image/jpeg" }));
const response = await fetch("/api/dust-scan", {
method: "POST",
body: form,
credentials: "same-origin",
});
return { status: response.status, body: await response.json() };
}

Your backend adds the credential and the context, and names the search scope itself:

server/dust-scan.ts — runs on YOUR SERVER
export async function handleIdentify(form: FormData, session: Session) {
// `searchTeamIds` is a JSON array of Team UUIDs. Set it on the server — a
// browser-supplied scope is a request, never an authorization.
form.set("searchTeamIds", JSON.stringify(session.allowedTeamIds));
const response = await fetch("https://apid.dustid.io/api/v1/tags/identify", {
method: "POST",
headers: {
Authorization: `Bearer ${await getDustToken()}`,
"Dust-Ctx-Org-Id": session.organizationId,
},
body: form,
});
// Pass the status and body through unchanged: the page needs to tell
// "nothing matched" (404 IDENTIFIER_NOT_FOUND) from "try again"
// (503 SCAN_SEARCH_INCOMPLETE).
return new Response(await response.text(), {
status: response.status,
headers: { "Content-Type": "application/json" },
});
}

The field is searchTeamIds. Identify payloads reject properties they do not declare, so a legacy searchGroupIds spelling fails the whole request with 400 INVALID_REQUEST rather than being ignored. The one surviving legacy group name is the Dust-Ctx-Grp-Id header, still accepted as an alias for Dust-Ctx-Team-Id.

The same multipart shape serves the other two operations:

  • /api/v1/tags/bind — add threadId. options.enrollmentSessionId is optional: supply a client-generated UUID, reused across a run, when several captures belong together (an enrollment station working through a batch); omit it for a one-off bind.
  • /api/v1/tags/verify — add threadId and tags, an array of objects ([{ "tagId": "…", "tagType": "DUST" }], JSON-encoded in multipart), not id strings. tags is required.

See Identifiers for the full request/response contracts, and the React Scanner for a copy-ready component that implements all three operations against a backend handler like the one above.

  1. Install DUST Go from the App Store or Google Play (see Supported Devices for hardware requirements — DUST capture needs a supported optical accessory).
  2. Serve your app over HTTPS on a URL the device can reach (a LAN address works for development).
  3. In DUST Go, add your URL as a custom app link and open it.
  4. Verify the connector is detected, then run a scan.
  5. Confirm the capture reaches your backend and that your backend’s DUST call returns a result you can render — including the “no match” case.

A minimal diagnostic page that exercises the whole bridge (detection, scanAsync, event log) is available in the package repository.


Everything below is optional. Come back to it once a single scan resolves end to end.

For scan-many-then-submit flows, use the listener API — the scanner stays open across captures:

scan-many.ts — runs in the BROWSER
import { connector } from "@dustid/dust-go-connect";
connector?.add("my-listener", (event) => {
switch (event.type) {
case "scan":
handleScan(event.payload);
break;
case "hide": // scanner closed
case "show": // scanner opened
break;
default:
// Ignore unknown event types — the protocol may grow.
break;
}
});
connector?.showScanner();
// later: connector?.hideScanner(); connector?.remove("my-listener");

addScanListener is the same thing without the listener bookkeeping — it receives every scan and returns its own unsubscribe function:

scan-many-simple.ts — runs in the BROWSER
import { addScanListener } from "@dustid/dust-go-connect";
const stop = addScanListener((payload) => handleScan(payload));
// later: stop();

One scanner session can deliver a series of captures, so submit them one at a time. A DUST payload is a large base64 JPEG; uploading several at once starves the connection and gives the operator no usable progress. Queue the payloads and await each submission before starting the next.

Hosts differ — a phone can adjust zoom and exposure, a USB microscope accessory exposes a different set, and an older build may expose nothing. Ask, rather than inferring from the User-Agent:

capabilities.ts — runs in the BROWSER
const capabilities = connector?.getCapabilities?.();

Treat an absent or empty announcement as single captures only, nothing adjustable. Never treat silence as capability.

Standard navigator.geolocation calls work inside DUST Go — the app transparently proxies them through the native OS permission prompt. No connector code needed.

If your app uses OAuth/OIDC, be aware that DUST Go hands external identity-provider navigations off to the system browser, and the callback returns to your page via a custom URL scheme.

When you construct an OAuth redirect_uri in the page, always wrap it:

redirect.ts — runs in the BROWSER
import { connector } from "@dustid/dust-go-connect";
const origin = window.location.origin;
const redirectUri = (
connector?.rewriteRedirect(new URL(`${origin}/auth/callback`)) ??
new URL(`${origin}/auth/callback`)
).href;

Outside DUST Go (and on hosts where no rewrite is needed) this is a no-op. Inside DUST Go it swaps the URL’s protocol for the app’s custom scheme, producing a URI like com.dustidentity.dustgo://your-host/auth/callback (the exact scheme comes from the DUST Go build; dustgo is the fallback), which your identity provider must have registered as an allowed redirect URI.

The bridge speaks a numbered protocol. The page announces which events it understands in an automatic hello handshake at import time, and the host only dispatches events the page advertised — so an older host and a newer page interoperate by degrading, not by failing.

Read the number from the package you installed, not from this page: the SDK exports it.

import { CONNECT_PROTOCOL_VERSION } from "@dustid/dust-go-connect";
ProtocolAdded
1The implicit legacy contract: scan / hide / show, no handshake.
2The hello handshake and the calibrationResult event.
3The host→page capabilities announcement, the page→host runCapture command, and the captureStatus event that answers it.
4CaptureRequirements.app (a host-enforced app version policy) and CaptureCapabilities.enforcedRequirements, so a page can tell a host that checks a requirement from one that would ignore it.
5ScanPayload.exif — the capture’s photographic EXIF with its provenance. Purely additive.
6The page→host capturePhoto command and the photo event that answers it: a plain photograph (for example a Thread attachment), deliberately not a scan.

The SDK in this documentation’s source tree is @dustid/dust-go-connect 0.2.0, which speaks protocol 6. What the public npm registry currently offers is not asserted here — check CONNECT_PROTOCOL_VERSION and the version field of the package you actually installed, and ask DUST Identity if you need a specific release.

Rules that outlast any version number:

  • Runtime capability detection is authoritative. A protocol number says what the page’s SDK can express; it says nothing about the host on the other side, or about what hardware is attached. Ask with getCapabilities() and treat an absent announcement as “single captures only, nothing adjustable”.
  • Switch on event.type and ignore what you do not recognize. New event types are added; a page that throws on an unknown type breaks on a host upgrade it never had to care about.
  • A manual control in a host’s interface is not a protocol capability, and a successful hardware command is not proof the value was applied at capture time.
  • calibrationResult events and ackCalibrationResults() are internal plumbing for DUST’s first-party calibration workflows — third-party integrations can ignore them.

This section is about a Dragon attached to a desktop computer, driven from a regular browser through the companion app. It is not the Android path: inside DUST Go on Android, the app drives the connected Dragon itself and its captures arrive through the ordinary runCapture flow above — createDragonClient() is not involved, and no companion app is installed on the phone. See Supported Devices.

Your website can own the Dragon preview and its focus controls. The installed companion handles USB commands in the background. On first use, users approve your website in DUST Camera Controls and grant browser camera and local-device access when prompted. Approval is remembered for that website and browser. The website must use HTTPS. Camera Controls can stay in the menu bar with its window closed; no connection popup is required. No DUST-hosted website is required.

Install DUST Camera Controls in Applications and open it once. The app shows whether the Dragon is connected and stays available in the menu bar. Start at login is enabled by default; you can turn it off in the app.

Installers for each platform are listed on the Downloads page. Update-enabled distributions check for updates automatically and provide Check for Updates… in the menu bar. You choose when to install an update; save your work and finish scanning before restarting the app. Evaluation builds without an update feed require a replacement installer from DUST.

In DICE, open Scan, select DUST, turn on Dragon focus controls under the scanner, and choose Connect on first use. After approval, the viewport offers autofocus, focus settings, and Scan through the selected operation. When you return to Scan with the mode on, DICE reconnects automatically if Camera Controls is running and browser camera access is still granted. If browser permissions need attention, choose Connect or Start camera to finish connecting.

dragon.ts — runs in the BROWSER
import { createDragonClient } from "@dustid/dust-go-connect";
const dragon = createDragonClient();
const video = document.querySelector("video")!;
// First use: request native approval and browser permissions from a user action.
connectButton.onclick = async () => {
try {
await dragon.connect();
await dragon.openVideo(video);
const controls = await dragon.getControls();
const focus = controls.find((control) => control.name === "focus");
// Use focus.min / focus.max for your manual slider when focus is available.
} catch (error) {
showConnectionError(error);
}
};
// On a later visit, reuse approval without creating a native approval prompt.
// If this rejects, present Connect Dragon; do not repeatedly request approval.
// await dragon.connect({ interactive: false });
// Reopen video only after browser camera permission has already been granted.
manualSlider.onchange = async () => {
await dragon.setControl("focus", Number(manualSlider.value));
};
autofocusButton.onclick = async () => {
try {
const result = await dragon.autofocus(video, {
radius: Number(autofocusRangeSlider.value),
onProgress: ({ position, sampled }) => showProgress(position, sampled),
});
manualSlider.value = String(result.position);
// "low-contrast" means autofocus retained the starting focus.
showFocusResult(result.outcome);
} catch (error) {
showFocusError(error);
}
};
cancelButton.onclick = () => dragon.cancelAutofocus();
captureButton.onclick = async () => {
const payload = await dragon.capture(video);
// Send the raw DUST JPEG to your server, which calls the Identifier API.
await sendToYourServer(payload);
};
const unsubscribe = dragon.onState(({ connected, controls }) => {
updateDeviceUI(connected, controls);
});
// On page/component teardown: unsubscribe(); dragon.dispose();

Only one website session controls the Dragon at a time. The user can revoke access using Manage website access… in Camera Controls. Closing the native window leaves the connection available; quitting the app stops it. An approved session receives device-state updates when the Dragon disconnects or reconnects. Reopen the video after reconnection. Connecting hardware does not silently give websites access to it.

Autofocus searches around the last focus command, bounded by the device’s calibrated range. Its range slider is a distance on each side of that starting position. Keep the page visible and the specimen still, with the desired detail in the center of the image. A featureless or poorly lit specimen may not provide enough contrast to select focus. Check the resulting image before capture.

Focus values are commands, not measured lens positions. These manual-control methods do not promise settings at shutter time and do not enable prescribed or union captures. The mobile scanner and its existing scanAsync() contract remain available independently.