This guide is for web application developers who want to trigger scans from a browser-based system and receive the scanned PDF back from the local Windows workstation.
ScanLink runs on the user's Windows machine as a tray application. Your web app talks to it through a loopback HTTP/HTTPS API or opens the registered scanlink:// protocol.
ScanLink is designed for apps that are hosted somewhere else but need access to scanner hardware attached to the user's PC.
Typical flow:
- User opens your web application in a browser.
- Your frontend checks whether ScanLink is running with
GET /health. - User clicks a scan button.
- Your frontend posts to
POST /scan. - Your frontend polls
GET /scan/<job_id>until completion. - Your frontend downloads the PDF from
GET /scan/<job_id>/result. - Your app uploads or attaches the PDF to the target document record.
The local API listens on:
http://127.0.0.1:5000https://127.0.0.1:5443when trusted localhost certificates are available
The bundled static/scanlink.js helper tries HTTPS first and falls back to HTTP.
Include the helper in your page:
<script src="/static/scanlink.js"></script>Use it from your scan button:
<button id="scan-button" type="button">Scan</button>
<script>
const scanLink = new ScanLink({
onConnectionChange(connected) {
console.log("ScanLink connected:", connected);
},
onStatus(status) {
console.log("Scan status:", status.status, status.page_count || 0);
},
onPageScanned(pageCount) {
console.log("Pages scanned:", pageCount);
},
onComplete(jobId, pageCount) {
console.log("Scan complete:", jobId, pageCount);
},
onError(error) {
console.error("ScanLink error:", error.code, error.message);
},
});
document.getElementById("scan-button").addEventListener("click", async () => {
const job = await scanLink.startScan({
docId: "DOC-123",
metadata: {
source: "invoice-form",
},
});
const pdfBlob = await scanLink.waitForResult(job.job_id);
const formData = new FormData();
formData.append("pdf", pdfBlob, `scan_${job.job_id}.pdf`);
formData.append("job_id", job.job_id);
await fetch("/documents/DOC-123/upload-scan/", {
method: "POST",
body: formData,
});
});
</script>If you do not want to use the helper:
async function scanDocument(docId) {
const baseUrl = "http://127.0.0.1:5000";
const startResponse = await fetch(`${baseUrl}/scan`, {
method: "POST",
mode: "cors",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ doc_id: docId }),
});
if (!startResponse.ok) {
throw await startResponse.json();
}
const { job_id } = await startResponse.json();
while (true) {
const statusResponse = await fetch(`${baseUrl}/scan/${job_id}`, {
method: "GET",
mode: "cors",
});
const job = await statusResponse.json();
if (job.status === "completed") {
const resultResponse = await fetch(`${baseUrl}/scan/${job_id}/result`, {
method: "GET",
mode: "cors",
});
return resultResponse.blob();
}
if (["failed", "cancelled", "timed_out"].includes(job.status)) {
throw job.error || { code: job.status, message: "Scan failed." };
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}ScanLink registers a custom protocol during installation:
<a href="scanlink://scan?docId=DOC-123">Scan</a>Deep links are useful when the browser cannot call localhost directly or when you want a simple launch trigger. They do not return the PDF to the browser by themselves. For most web apps, use the REST API when you need a result file in the page.
Supported query parameters:
docIdordoc_idcallbackorcallback_url
Example with callback:
<a href="scanlink://scan?docId=DOC-123&callback=https%3A%2F%2Fexample.com%2Fscan-callback">
Scan
</a>If ScanLink is already running, a protocol launch forwards the command to the existing tray instance through the internal local endpoint.
When callback_url is provided, ScanLink posts the completed PDF to that URL after a successful scan.
The callback request is multipart/form-data with:
pdf: scanned PDF filejob_id: ScanLink job IDdoc_id: document ID from the original request, if providedpage_count: number of scanned pagesstatus:completed
Use callbacks when your backend can receive files directly and you do not want the browser to upload the PDF after polling.
Common statuses:
queued: job accepted and waiting.processing: worker started the job.selecting_scanner: ScanLink is waiting for scanner selection.acquiring: scanner acquisition is active.page_scanned: at least one page has been captured. Checkpage_count.generating_pdf: pages are being converted to a PDF.completed: PDF is ready for download.failed: unrecoverable error.cancelled: user cancelled scanner selection or no pages were acquired.timed_out: scanner or helper process exceeded a timeout.
Only one scan job can be queued or running at a time. If another request arrives while busy, the API returns 409 busy.
Completed results are kept in memory temporarily. Defaults:
- Job metadata TTL: 900 seconds.
- Result PDF TTL: 300 seconds.
Downloading /scan/<job_id>/result consumes the stored PDF. A second download for the same job can return 410 result_unavailable.
- The API is bound to
127.0.0.1, not a public network interface. - CORS defaults to
["*"]in%LOCALAPPDATA%\ScanLink\config.json. - The first scan may show a scanner selection dialog.
- If the user selects "Set as default scanner", later scans skip scanner selection.
- Tray settings control source, side, color mode, resolution, scanner UI visibility, paper protection, startup, and diagnostics.
- The default profile for archive uploads is ADF, front-only/simplex, 200 DPI, color, and paper protection off.
- Silent scanning disables scanner UI by default and configures feeder/autofeed, duplex, pixel type, paper protection, transfer count, and indicators where supported.
- Paper protection is disabled by default because some TWAIN drivers report false paper-stuck errors during batch scanning.