# QR codes on packaging that you can change after printing

> After this guide, your packaging carries a QR code that opens your app or website, counts each scan under the qr channel, and can point somewhere new after the print run without changing the code.

- Time: About 20 minutes, plus a printed proof
- You need: A Strait secret key in your server's environment as SECRET_KEY, and the size the code will be printed at
- Works on: Any server. Code for curl and Node 18+. Scans work with Android and iPhone camera apps.

Source: https://straitlink.in/docs/recipes/qr-codes-on-packaging/

The QR code holds the short link, not the destination. So you print once and change where the code goes later with one API call. Strait draws the code in your workspace's [QR style](https://straitlink.in/docs/qr-codes/) and adds `utm_source=qr` to the URL in it, so scans show up as their own channel.

## 1. Make a link for the pack

```bash
curl https://strait.link/v1/links \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "slug": "kettle-box", "longUrl": "https://shop.example.com/register?utm_source=packaging&utm_campaign=kettle-box-2026" }'
```

Use a short slug. The URL's length sets how many squares the code needs, and fewer squares print bigger at the same size.

**You should see:** `"shortUrl": "https://hilltop.strait.link/kettle-box"` and `"campaign": "kettle-box-2026"`.

## 2. Download a print file

Ask for SVG, which stays sharp at any print size, with a margin of 4 modules, the quiet zone the QR standard asks for (the default is 2):

```bash
curl "https://strait.link/v1/links/kettle-box/qr?format=svg&margin=4" \
  -H "Authorization: Bearer $SECRET_KEY" \
  --fail -D - -o kettle-box-qr.svg
```

```js title="Node: kettle-box-qr.mjs"
import { writeFile } from 'node:fs/promises';
import { StraitClient } from '@straitlink/node';

const strait = new StraitClient({ apiKey: process.env.SECRET_KEY, baseUrl: 'https://strait.link' });

await strait.createLink({
  slug: 'kettle-box',
  longUrl: 'https://shop.example.com/register?utm_source=packaging&utm_campaign=kettle-box-2026',
});
const qr = await strait.getLinkQr('kettle-box', { format: 'svg', margin: 4 });
await writeFile('kettle-box-qr.svg', qr.body);
console.log(qr.contentType, qr.body.length, 'bytes');
```

The Node script makes the link too, so run it instead of step 1, not after it. For a PNG, ask for `format=png&size=2048`: 2048 pixels print 17.3 cm wide at 300 dpi.

**You should see:** `X-Strait-Qr-Url: https://hilltop.strait.link/kettle-box?utm_source=qr` and `X-Strait-Qr-Logo: off` in the headers, and an SVG file that starts `<svg … viewBox="0 0 41 41"`. The Node script prints `image/svg+xml; charset=utf-8` and the file's size.

## 3. Size it for the print

The SVG's `viewBox` is the code's width in modules, margin included: 41 here, 33 for the code and 4 on each side. Divide the printed width by it to get the size of one square. At 25 mm wide, each square is about 0.6 mm.

With your app icon in the middle (`logo=true`, or the setting in **Settings → QR codes**), the code switches from error correction level M to H, so it still scans with the middle covered. H holds less data per square, so the same URL needs more squares: download the file again and recheck the `viewBox`.

Print a proof at the real size on the real material and scan it with the camera app on an Android phone and an iPhone, from the distance a person holds the pack.

**You should see:** both phones open `https://hilltop.strait.link/kettle-box?utm_source=qr` from the proof, and your registration page or app opens.

## 4. Change where the code goes, after printing

```bash
curl -X PATCH https://strait.link/v1/links/kettle-box \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "longUrl": "https://shop.example.com/kettle/recipes?utm_source=packaging&utm_campaign=kettle-box-2026" }'
```

In Node: `await strait.updateLink('kettle-box', { longUrl: 'https://shop.example.com/kettle/recipes?…' })`.

**You should see:** the new `longUrl` in the answer, a scan of the printed code opening the new page, and a fresh download from step 2 that is byte for byte the same file.

## 5. Count the scans

```bash
curl "https://strait.link/v1/analytics?link=kettle-box&group=channel" \
  -H "Authorization: Bearer $SECRET_KEY"
```

Scans count under `qr`: the `utm_source=qr` in the code is on the tapped URL, and that wins over the destination's `utm_source=packaging`. Your website analytics still sees `utm_source=packaging`, because that tag is on the destination. A second scan from the same phone soon after the first counts as a tap but isn't billed.

**You should see:** `"rows": [{ "key": "qr", "taps": 2, "billableTaps": 1, … }]` after two scans from one phone.

## Common errors

- **`400` "the QR colours are too close to scan reliably".** The code must be darker than its background, with a contrast of at least 3:1. Plain black on white always passes.
- **`X-Strait-Qr-Logo: no_icon`.** The workspace has no app icon yet. Add one in **Settings → How your link pages look**, or print the code without it.
- **Scans count as `packaging`, not `qr`.** The file was downloaded with `track=false`, so the code holds the bare short link. That's fine if you want the destination's label instead.

## Next steps

- [Branded QR codes and the QR API](https://straitlink.in/docs/qr-codes/): colours, the app icon, and every query parameter.
- [Get a branded QR code (API reference)](https://straitlink.in/docs/api/get-link-qr/): the rate limit and code in six languages.
