Most desktop machines have a webcam pointed at a face, not a table. For any flow that runs on a laptop, the phone is the scanner — and QR is how you get the user there.
You do not need a server for this. The embed SDK creates the session with your publishable key,
renders the QR, and polls GET /v1/sessions/:id/status?t=… until the phone finishes. The flow
below is what happens under the hood — and what you would wire yourself if you skip the SDK.
The shape of it
desktop browser your server our API the phone
─────────────── ─────────── ─────── ─────────
click "Scan" ──────▶ POST /v1/sessions ─────▶ create session
◀──── { id, scan_url }
◀──── render QR
scans QR
◀────── opens scan_url
camera, PDF
◀────── upload
◀──── scan.completed (webhook)
poll GET /v1/sessions/:id ──▶ status: completed
◀──── show the PDF
The webhook is the delivery mechanism. The polling is only so the desktop page can update — if you skip the polling, the flow still works, the user just does not see it happen.
1. Create the session on your server
Use a secret key. A publishable key would need an Origin header, and there is no browser making
this call.
curl -X POST https://api.instantscan.io/v1/sessions \
-H "Authorization: Bearer sk_test_demo" \
-H "Content-Type: application/json" \
-d '{
"metadata": { "application_id": "app_1042", "channel": "qr" }
}'
{
"id": "ss_9Qw1p2Kt",
"client_token": "eyJzaWQiOiJzc185UXcxcDJLdCIsInRpZCI6…",
"scan_url": "https://scan.instantscan.io/?t=eyJzaWQiOiJzc185UXcxcDJLdCIsInRpZCI6…",
"expires_at": "2026-01-01T00:15:00.000Z"
}
Return id, scan_url and expires_at to your page. client_token is already inside scan_url;
you never need to handle it separately.
2. Render the QR code
You have two options, and the first is better.
Point an <img> at our QR endpoint. It renders a low-density SVG from a short signed hand-off
code (?c=…) rather than the full client token, so the grid stays coarse and scans reliably even on
a cheap phone camera. This is what the embed SDK uses.
<img
src="https://api.instantscan.io/v1/sessions/ss_9Qw1p2Kt/qr?t=<client_token>"
width="240"
alt="Scan with your phone"
/>
Or encode scan_url yourself. Any QR library works — the payload is just the URL — but note that
scan_url carries the full token and so produces a denser code.
import QRCode from "qrcode";
const dataUrl = await QRCode.toDataURL(session.scan_url, { margin: 1, width: 240 });
document.querySelector("#qr").src = dataUrl;
The hand-off code is unguessable (it is a truncated HMAC over the session id) and resolves to the
same session at GET /v1/scanner/config?c=…, which hands the scanner back its client token. The code
is not a weaker credential — expiry and single-use are still enforced against the session — it is
just shorter.
Two things to show next to it, because both prevent support tickets:
- A countdown. Sessions expire, by default 15 minutes after creation. Show the remaining time
from
expires_atand offer a “generate a new code” button when it runs out. - The URL as text. Some corporate phones have QR scanning disabled by policy. A short link the user can type is a cheap escape hatch.
3. Wait for the result
On your server: the webhook
This is the part that must not be skipped. The phone’s upload triggers a signed scan.completed
delivery to your endpoint, which is the only path that works if the desktop tab was closed. See
Webhooks.
On the page: polling
Poll your own endpoint, which reads the session with your secret key. Never expose a secret key to the browser to do this.
// Your backend
app.get("/applications/:id/scan-status", async (req, res) => {
const sessionId = await lookupSessionId(req.params.id);
const upstream = await fetch(`https://api.instantscan.io/v1/sessions/${sessionId}`, {
headers: { Authorization: `Bearer ${process.env.SCANNER_SECRET_KEY}` },
});
const session = await upstream.json();
// Do not forward result.url to the browser unless that is what you want:
// it is a signed, publicly fetchable link to the document.
res.json({ status: session.status, pages: session.result?.page_count ?? 0 });
});
// Your page — every 2s, giving up when the session expires.
const deadline = new Date(session.expires_at).getTime();
const timer = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(timer);
return showExpired();
}
const { status } = await fetch(`/applications/${id}/scan-status`).then((r) => r.json());
if (status === "completed" || status === "delivered") {
clearInterval(timer);
showDone();
}
}, 2000);
Two seconds is a good interval. The API rate-limits per tenant, and a one-second poll across many concurrent users adds up faster than you expect.
Statuses you will see
| Status | Meaning in a QR flow |
|---|---|
created | QR shown, phone has not opened it yet. |
opened | The phone loaded the scanner. Good signal to change the desktop copy to “Scanning on your phone…”. |
completed | The PDF is uploaded and downloadable. |
delivered | Your webhook returned 2xx. |
expired | 15 minutes passed. Create a new session; the old token is dead. |
Practical notes
- One session, one document. The client token is single-use for upload. To scan a second document, create a second session — and note that a second scanned document costs a second credit.
- Do not reuse a
scan_urlacross users. Anyone holding it can scan into that session until it expires. - Rotating the code is free until it is used. A “refresh” button that regenerates the session on every click costs nothing on its own — you are only charged for the documents that actually get scanned.
- The phone needs no login. The signed token in the URL is the entire authorisation, which is exactly why its TTL is short.
- The phone tab closes itself. After the upload, tapping Done attempts to close the tab the QR opened. Browsers only allow this when the tab holds a single history entry, so the scanner adds none during the flow; when a browser refuses anyway (Safari is inconsistent), the success screen shows a “you can close this tab” message instead. Either way the desktop already has the result by webhook — closing the tab is a courtesy, not part of delivery.