# Building a decode-verify loop

> A decode-verify loop renders the QR code exactly as it will ship, rasterises it to a bitmap, decodes the bitmap with a real decoder such as zxing, and compares the result against the input. If they differ, the code does not ship. UseQR exposes this as GET /api/v1/verify, returning scannable, decoded, matchesInput, contrast and issues.

Source: https://useqr.app/docs/developers/building-a-decode-verify-loop · Last reviewed 2026-08-21 · UseQR is free forever, MIT licensed, no signup.

---

## The loop

[Why you should verify](/docs/developers/why-verify-that-your-qr-code-decodes) makes the
case; this page is the implementation. Four steps, in order, with no shortcuts:

1. **Render** the code with every styling decision applied — module shape, colours,
   gradient, logo, quiet zone. Verifying an unstyled version proves nothing about the
   styled one.
2. **Rasterise** to a bitmap at a realistic size. Decoders read pixels, not SVG paths.
   512 px is a reasonable default; smaller is a stricter test.
3. **Decode** with a real decoder. Not your own matrix reader — an independent engine
   like [ZXing](/glossary/zxing) that behaves like the phones in the field.
4. **Compare** the decoded string against the input, byte for byte. "Something decoded"
   is not a pass; *the right thing* decoded is.

In code, using the same libraries UseQR uses internally:

```js
import { readBarcodes } from "zxing-wasm/reader";

async function verify(svg, rasterise, expected) {
  const png = await rasterise(svg, 512);          // e.g. resvg or canvas
  const results = await readBarcodes(png, { formats: ["QRCode"], tryHarder: true });
  const decoded = results[0]?.text ?? null;
  return { scannable: decoded !== null, matchesInput: decoded === expected };
}
```

## Beyond pass/fail: score the margin

A bare decode is a weak signal — a code can pass at the edge of readability and die at
the first drop of ink spread. The verify report should carry margin indicators. UseQR's
report shape:

```json
{
  "scannable": true,
  "decoded": "https://example.com",
  "matchesInput": true,
  "version": 2,
  "ecLevel": "M",
  "contrast": 12.6,
  "issues": []
}
```

The margin checks behind `issues`, with their thresholds:

- **Contrast** — the foreground/background contrast ratio; below **2.5:1** many
  scanners struggle, so it is flagged. (Print wants far more:
  [≥ 40% luminance difference](/docs/design/qr-code-contrast-ratio-minimum).)
- **Logo coverage** — a logo above **25%** coverage without
  [error correction](/docs/spec/error-correction-levels-explained) level H is flagged
  before it fails in the field.
- **Mismatch** — decoded text differing from input is reported separately from a
  failed decode, because it means an encoder bug, not a styling problem.

Damage tolerance you can probe by decoding at descending raster sizes or after adding
synthetic noise — a code that survives a 256 px rasterisation has margin that a
barely-passing 1024 px code does not.

## As a service

If you would rather not assemble the pieces:

```bash
curl -sG "https://useqr.app/api/v1/verify" \
  --data-urlencode "data=https://example.com" \
  --data "color=555555" | jq .
```

Keyless, and it accepts every styling parameter the
[generate endpoint](/docs/developers/free-qr-code-api-no-key) does, so it verifies the
exact code you would ship. The MCP server exposes the same loop as a `qr_verify` tool,
which lets an [AI agent](/docs/developers/using-qr-codes-from-an-ai-agent) prove a
styled code scans before presenting it.

## In CI

The loop earns its keep as a gate. UseQR's own pipeline decode-verifies every
combination of module style, eye style, gradient and EC level on every build — a
styling regression is a failed build, not a support ticket. The single-code version is
two lines in any pipeline:

```bash
curl -sG "https://useqr.app/api/v1/verify" --data-urlencode "data=${URL}" \
  | jq -e '.scannable and .matchesInput' > /dev/null || exit 1
```

More patterns — matrix snapshots, fixture decoding — in
[QR codes in CI](/docs/developers/qr-codes-in-ci-and-automated-testing). And remember
the loop's honest limit: it proves the digital artwork decodes. Ink, laminate, glare
and camera angle still need a [printed proof](/docs/how-to/how-to-test-a-qr-code-before-printing).

## FAQ

### What is a decode-verify loop?
Render the QR code with all styling applied, rasterise it to a bitmap, decode the bitmap with a real decoder, and compare the decoded text with the input. Any mismatch or failed decode blocks the code from shipping.

### Why compare the decoded text with the input?
Because "a code decoded" and "the right code decoded" are different claims. A payload-building bug produces a perfectly scannable code containing the wrong data — comparison catches it, bare decoding does not.

### What contrast ratio does a QR code need to decode?
UseQR's verify flags anything below 2.5:1 as at-risk for digital scanning, but print needs much more margin — aim for at least a 40% luminance difference with dark modules on a light background.

### Is there an API that verifies a QR code scans?
Yes. GET https://useqr.app/api/v1/verify?data=hello renders the requested code server-side, decodes it back with zxing, and returns a JSON report with scannable, matchesInput, contrast and a list of concrete issues. No API key needed.

## Try it

- https://useqr.app/validate
- https://useqr.app/scan
- https://useqr.app/url
