The SDK is a launcher, not a scanner. It opens a session, picks the right hand-off for the device (iframe on a phone, QR on a desktop), listens for completion, downloads the result, and hands it to you. That is the whole surface area, and it is deliberately small enough to read in one sitting.
Install
npm install @scanner-cloud/embed-sdk
Or use the script build, which also gives you the declarative mode:
<script src="https://instantscan.io/v1/scanner.js"></script>
Mode A — declarative
Add data-key to the script tag and data-scanner to any file input. There is no application code.
<input type="file" name="id_document" data-scanner />
<script
src="https://instantscan.io/v1/scanner.js"
data-key="pk_test_demo"
data-api="https://api.instantscan.io"
data-auto-init
></script>
| Attribute | On | Required | Meaning |
|---|---|---|---|
data-key | <script> | yes | Your publishable key. |
data-api | <script> | no | API base URL. Defaults to the production API. |
data-auto-init | <script> | yes (declarative mode) | Opt in to zero-code attach after DOMContentLoaded. Omit in React/Next — call init() yourself. |
data-scanner | <input type="file"> | yes | Marks the input to take over. |
What declarative mode does, in order:
- Reads
data-keyfrom the script tag. - Waits for
DOMContentLoadedso it does not race framework hydration. - Calls
init({ autoAttach: true }), which attaches everyinput[type=file][data-scanner].
Inputs added later are not picked up automatically. Attach them yourself:
const scanner = ScannerSDK.init({
publicKey: "pk_test_demo",
apiBase: "https://api.instantscan.io",
});
scanner.attach("#late-input");
attach() is idempotent — calling it twice on the same input is a no-op.
Mode B — React, Next.js, and other SPAs
Do not use data-auto-init in a server-rendered app. The script tag only downloads the
launcher; initialization must happen after hydration:
"use client";
import { useEffect, useRef } from "react";
export function ScanField() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const sdk = window.ScannerSDK;
if (!sdk || !inputRef.current) return;
const scanner = sdk.init({
publicKey: "pk_test_demo",
apiBase: "https://api.instantscan.io",
});
scanner.attach(inputRef.current);
return () => sdk.destroy();
}, []);
return <input ref={inputRef} type="file" name="proof" />;
}
Load the bundle once in your root layout — without data-key or data-auto-init:
<script src="https://instantscan.io/v1/scanner.js" async></script>
init() is idempotent. destroy() removes every scan box and drops the singleton, so route changes
and strict-mode double-mounts do not leave duplicate buttons behind.
Mode C — programmatic modal
import { Scanner, ScannerError } from "@scanner-cloud/embed-sdk";
const scanner = Scanner.init({
publicKey: "pk_test_demo",
apiBase: "https://api.instantscan.io",
});
const result = await scanner.open({
metadata: { application_id: "app_1042" },
allowedOrigins: [window.location.origin],
});
Scanner.init(options)
| Option | Type | Required | Meaning |
|---|---|---|---|
publicKey | string | yes | Publishable key (pk_…). Throws ScannerError("config") if missing. |
apiBase | string | no | API base URL, without a trailing slash. |
scanner.open(options)
Opens the scanner and resolves once a document has been scanned. On phones and tablets this is a fullscreen iframe; on desktop it shows a QR code and polls until the phone finishes.
| Option | Type | Meaning |
|---|---|---|
metadata | Record<string, unknown> | Echoed back verbatim in the session, the webhook and analytics. |
allowedOrigins | string[] | Origins permitted to embed this session and receive its result. Defaults to [window.location.origin]. Every entry must already be on your key’s allowlist. |
label | string | Text on the scan box in attach() mode. |
mode | "auto" | "iframe" | "qr" | Override device detection. Defaults to auto. |
Resolves with:
interface ScanResultFile {
sessionId: string;
pageCount: number;
/** Signed, short-TTL URL of the produced document. */
url: string;
/** The document as a File, ready to drop into a form. */
file: File;
}
scanner.attach(target, options?)
Turns a file input into a scan box. target is an HTMLInputElement or a selector string. Same
options as open(). Call from useEffect in SPAs — not at module load time.
scanner.detach(target) / scanner.destroy()
detach() removes one scan box and restores the input. destroy() detaches every input this
instance touched. The module-level ScannerSDK.destroy() also drops the singleton from init().
Errors
Rejections are always a ScannerError with a code:
code | When | What to do |
|---|---|---|
cancelled | The user closed the modal or discarded the scan. | Nothing. This is not an error path — swallow it. |
config | Missing publicKey, or session creation was refused (bad key, origin not allowed, no credits). | Read the message; it carries the HTTP status and body. |
network | The API or the result download was unreachable. | Retry, or fall back to a plain file input. |
scan_error | The hosted scanner reported a failure. | Show a retry affordance. |
try {
const { file } = await scanner.open();
} catch (err) {
if (err instanceof ScannerError && err.code === "cancelled") return;
showFallbackFileInput();
}
Treating cancelled as an error is the single most common integration mistake. A user changing their
mind is normal.
postMessage contract
If you would rather manage the iframe yourself, the SDK is skippable — the hosted scanner posts these
to window.parent:
type ScannerMessage =
| { type: "scanner:ready"; sessionId: string }
| { type: "scanner:complete"; sessionId: string; pageCount: number; resultUrl: string }
| { type: "scanner:close"; sessionId: string }
| { type: "scanner:error"; sessionId: string; message: string };
Two checks are not optional if you do this yourself:
if (event.origin !== "https://scan.instantscan.io") return;
if (event.source !== iframe.contentWindow) return;
Your iframe needs allow="camera", and must not be sandboxed — a sandboxed iframe cannot get
camera permission.
What the SDK does not do
By design, and it will not change:
- No camera access, no OpenCV, no detection, no PDF assembly. Those live on our origin.
- No result storage. It hands you a
Fileand forgets. - No retry of a failed webhook. That is the API’s job.
- No obfuscation. There is no IP in a launcher, and pretending otherwise would only make it harder for you to debug.
Size budget
The SDK is held under 8 KB gzipped by a build gate. If a change pushes it over, the build fails rather than quietly shipping a heavier launcher. Bundling everything into your page is exactly the outcome this product exists to avoid.