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
How it fits together
Section titled “How it fits together”- A user opens your web app inside DUST Go (via an app link).
- Your page imports
@dustid/dust-go-connect; the library detects the DUST Go bridge and exposes aconnector. - Your page calls
scanAsync(). DUST Go opens the native scanner over your page. - On capture, DUST Go delivers a scan event back to your page: the payload carries the capture data and metadata.
- 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.
Install
Section titled “Install”npm install @dustid/dust-go-connectThe package is dependency-free, MIT-licensed, and ships TypeScript types.
Detect DUST Go
Section titled “Detect DUST Go”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:
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.
Capture a scan
Section titled “Capture a scan”The simplest path is the promise API — present the scanner and await one capture:
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.
What’s in the payload
Section titled “What’s in the payload”payload.type | payload.data | Notes |
|---|---|---|
DUST | Base64-encoded JPEG of the DUST capture | Large; resolve it server-side via APID |
QR, BARCODE, DATA_MATRIX | The decoded symbol contents | |
NFC | The 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.
Where credentials live
Section titled “Where credentials live”Read this before the resolve step: it decides the shape of your whole integration.
So the resolve step is two files in two places:
| File | Runs | Holds the DUST credential? |
|---|---|---|
| Your scanning page | Browser inside DUST Go | No |
Your /api/dust-scan handler | Your server | Yes |
Resolve the scan against APID
Section titled “Resolve the scan against APID”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:
// 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:
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— addthreadId.options.enrollmentSessionIdis 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— addthreadIdandtags, an array of objects ([{ "tagId": "…", "tagType": "DUST" }], JSON-encoded in multipart), not id strings.tagsis 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.
Test your integration
Section titled “Test your integration”- Install DUST Go from the App Store or Google Play (see Supported Devices for hardware requirements — DUST capture needs a supported optical accessory).
- Serve your app over HTTPS on a URL the device can reach (a LAN address works for development).
- In DUST Go, add your URL as a custom app link and open it.
- Verify the connector is detected, then run a scan.
- 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.
Beyond the first scan
Section titled “Beyond the first scan”Everything below is optional. Come back to it once a single scan resolves end to end.
Multi-scan workflows
Section titled “Multi-scan workflows”For scan-many-then-submit flows, use the listener API — the scanner stays open across captures:
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:
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.
What the scanner can do
Section titled “What the scanner can do”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:
const capabilities = connector?.getCapabilities?.();Treat an absent or empty announcement as single captures only, nothing adjustable. Never treat silence as capability.
Geolocation
Section titled “Geolocation”Standard navigator.geolocation calls work inside DUST Go — the app transparently proxies them through the native OS permission prompt. No connector code needed.
Sign-in flows inside DUST Go
Section titled “Sign-in flows inside DUST Go”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:
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.
Versioning and compatibility
Section titled “Versioning and compatibility”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";| Protocol | Added |
|---|---|
| 1 | The implicit legacy contract: scan / hide / show, no handshake. |
| 2 | The hello handshake and the calibrationResult event. |
| 3 | The host→page capabilities announcement, the page→host runCapture command, and the captureStatus event that answers it. |
| 4 | CaptureRequirements.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. |
| 5 | ScanPayload.exif — the capture’s photographic EXIF with its provenance. Purely additive. |
| 6 | The 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.typeand 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.
calibrationResultevents andackCalibrationResults()are internal plumbing for DUST’s first-party calibration workflows — third-party integrations can ignore them.
Desktop Dragon controls
Section titled “Desktop Dragon controls”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.
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.
Related
Section titled “Related”- Identifiers — the API this integration resolves captures against.
- Errors and scan outcomes — the outcome tables your scanning UI must branch on.
- Supported devices — the hardware DUST capture requires.
- React Scanner — the same flow without DUST Go, using the device camera.
- Building with AI agents — an agent skill covering this integration, for coding agents.