Skip to content
instantscan.io docs

QR hand-off

Let a desktop user scan with their phone. Create the session server-side, render its scan_url as a QR code, and receive the result by webhook while the page polls.

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_at and 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

StatusMeaning in a QR flow
createdQR shown, phone has not opened it yet.
openedThe phone loaded the scanner. Good signal to change the desktop copy to “Scanning on your phone…”.
completedThe PDF is uploaded and downloadable.
deliveredYour webhook returned 2xx.
expired15 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_url across 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.