Step 1 of 5Make a link for the pack

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.

On this page 7
  1. 1. Make a link for the pack
  2. 2. Download a print file
  3. 3. Size it for the print
  4. 4. Change where the code goes, after printing
  5. 5. Count the scans
  6. Common errors
  7. Next steps
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.

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 and adds utm_source=qr to the URL in it, so scans show up as their own channel.

Terminal
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):

Terminal
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
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

Terminal
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

Terminal
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