How it works
Two lines in, a clean PDF out
You paste a script tag and mark a file input. Your user scans a document with their phone. The finished, cropped, watermarked PDF appears in that same input. Everything in between happens on our side.
Step 1
Add it to your page
The launcher is about 6 KB. It finds every file input marked
data-scanner, hides it, and puts a “Scan a document” box in its place. The
camera, the computer vision and the PDF assembly are never on your page.
Using React, Next.js or Vue? Initialise it from an effect instead; the SDK reference has the snippet.
<script src="https://instantscan.io/v1/scanner.js" data-key="pk_live_your_key"></script><input type="file" name="document" data-scanner /> Step 2
Your user scans
The scanner picks the right path for the device, then runs entirely on the user's phone. You do not write any of this logic.
On a phone: it opens in place
A full-screen scanner finds the page edges live, corrects the perspective, lets the user reframe the crop, and stacks as many pages as you allow.
On a laptop: scan a QR code
Desktop webcams point at faces, not tables. So the user gets a QR code, scans it with their phone, and your page waits. No app, no pairing code.
Looks like your product
Language, accent colour, brand name, capture prompt, success screen and a document watermark. All set in the dashboard, none of it in your code.
The document does not travel further than it has to. Camera frames, edge detection, cropping, the watermark and PDF assembly all happen on the user's device. What leaves it is the finished PDF, uploaded once.
Step 3
The file arrives
By default it lands in the file input you already have, as a real file with a
change event. Your validation, your upload code and your form submit stay exactly
as they are.
In your form (always)
Even when the scan happened on a phone, the PDF is delivered back to the desktop page that asked for it. The user never leaves your product.
On your server (optional)
Add an endpoint in the dashboard and we send a signed notification for every scan, retried until you answer. It is the one to rely on if the user might close the tab.
Advanced
Need more control? Drive it from your server.
You can skip all of the above. Everything the launcher does is a thin layer over one short-lived object, the session, which your backend can open itself with a secret key. That is how you render your own QR code, pick a scanner profile per request, or run a flow with no browser on your side. None of it is needed for the two-line integration.
Server side · 1
Open a session
A session is created with an API key and carries everything the scan needs: which tenant it belongs to, whether it is test or live, the metadata you want echoed back, and the customization the scanner should render with.
With a secret key, from your server
The safe default. sk_ keys are trusted, so the session can name any allowed origin
and you can read the result back later. Required for QR flows, because there is no browser
involved on your side at all.
With a publishable key, from the browser
No backend work at all. pk_ keys may only create sessions, and only when the
request's Origin is on your allowlist — so pasting one into your HTML is safe
by design rather than by obscurity.
Metering happens here. A scanned document costs one credit — test or live,
any number of pages. Opening a session is free; the credit is spent when the document lands.
If the balance is below one credit, opening fails with
402 insufficient_credits and no session exists.
Server side · 2
Choose how the user reaches the scanner
The hosted scanner loads on our origin, authenticated by the session's client token — a compact signed token with a TTL of minutes that is single-use for the upload. It fetches its configuration, then runs entirely on the user's device.
A. Drop-in file input
When: You already have a file input and you want it to become a scanner.
This is the two-line integration. The launcher hides the real input, renders a scan box, and injects the finished PDF back into the input with a change event.
Nothing about your form's submit path changes.
B. Programmatic modal
When: You are in React, Vue or Svelte with a controlled input, or the scan is not tied to a form at all.
Call Scanner.init({ publicKey }).open(). It opens the scanner, waits for the scan, downloads the result and resolves with a File.
You decide what happens to the file.
C. Your own QR code
When: You want the hand-off on your own screen, or there is no browser on your side at all.
Create the session on your server and render its scan_url as a QR code. The user scans it, their phone opens the same hosted scanner, and your backend receives the webhook.
The desktop page just polls the session.
Server side · 3
Receive the document
There is one event — the upload completing — and four ways to hear about it. Pick by how much you trust the browser to still be there.
1. Signed webhook (canonical)
We POST scan.completed to your endpoint and retry with backoff until you answer
2xx. The only mechanism that works when the user closed the tab, or when the scan happened
on a phone you have no connection to.
2. postMessage to your page
The scanner posts scanner:complete to window.parent with the page
count and a signed result URL. Instant, but only exists while the iframe does.
3. Polling the session
GET /v1/sessions/:id with a secret key returns the status and, once complete,
a signed result URL. This is how a desktop page waits on a QR hand-off.
4. File-input injection
The SDK consumes the postMessage, downloads the PDF and writes it into your real file
input via DataTransfer. Your form cannot tell the difference.
POST /webhooks/scanner HTTP/1.1
Content-Type: application/json
X-Scanner-Signature: t=1735689600,v1=8f2b…
{
"type": "scan.completed",
"session_id": "ss_9Qw1p2",
"metadata": { "application_id": "app_1042" },
"result": {
"url": "https://api.instantscan.io/v1/files/res_7Xk?exp=…&sig=…",
"page_count": 3,
"bytes": 412778,
"content_type": "application/pdf"
},
"created_at": "2026-01-01T00:00:00.000Z"
}
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = new Map(header.split(",").map((p) => p.split("=") as [string, string]));
const timestamp = parts.get("t");
const signature = parts.get("v1");
if (!timestamp || !signature) return false;
// Reject anything older than five minutes: a valid signature on a replayed
// body is still a replay.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
const res = await fetch(`https://api.instantscan.io/v1/sessions/${sessionId}`, {
headers: { Authorization: "Bearer sk_test_demo" },
});
const session = await res.json();
// session.status: created | opened | scanning | completed | delivered | failed | expired
if (session.status === "completed") {
const pdf = await fetch(session.result.url);
}
Lifecycle
Every status a session can be in
Statuses only move forward. Analytics counts them exactly as they are recorded, so an abandoned scan shows up as abandoned rather than being quietly dropped.
| Status | What it means |
|---|---|
created | The session exists and its client token is valid. Opening a session is free; no credit is spent yet. |
opened | The hosted scanner fetched its config with the client token. |
scanning | The user is capturing pages. |
completed | The PDF was uploaded and stored, and one credit was spent. It is downloadable from a signed URL. |
delivered | A webhook was accepted with a 2xx response. |
failed | The scan or the upload failed. The reason is in your analytics and the delivery log. |
expired | Fifteen minutes passed without a completed upload. The token no longer works. |
Read the quickstart, or just try it
The docs walk through a working integration with the seeded test keys, end to end, including verifying a webhook signature.