QR codes for campaign tracking and print attribution
The campaign builder is this page's method, automated
OpenQR's campaign flow does the whole job on one screen: name the campaign, paste one shared destination, list the placements, and it creates a labelled dynamic code per placement, tags each one (utm_campaign from the campaign name, utm_medium=qr, utm_source from the placement label), and keeps the set together with per-placement scan counts. The builder lives at /dashboard/campaigns/new, and the walkthrough with screenshots is our QR code campaigns guide. The method below explains why each piece exists, and stays the path for sets where every variant needs a fully bespoke URL.
The test is the same one that decides asset tagging: does each printed piece need a different destination, or the same destination with a different label attached to it? A single flyer run with one call to action is one code. The moment you print more than one creative, more than one placement, or more than one channel for the same offer, you need a code per variant, because a shared code cannot tell you which piece of paper someone was holding when they scanned.
The scan count in OpenQR tells you the code was reached. It does not tell you what the person did after landing, and it should not have to: that is your website's job, provided the destination says which variant sent them. Each variant's URL should carry its own utm_source, utm_medium, utm_campaign and utm_content, so the same visit shows up correctly split in Google Analytics, Matomo, or whatever you already run.
A real pair of destinations for a spring sale, split across two leaflet variants, looks like this:
Leaflet A: https://example.com/spring-sale?utm_source=leaflet&utm_medium=print&utm_campaign=spring-sale&utm_content=variant-a
Leaflet B: https://example.com/spring-sale?utm_source=leaflet&utm_medium=print&utm_campaign=spring-sale&utm_content=variant-b
Bus stop: https://example.com/spring-sale?utm_source=bus-stop&utm_medium=print&utm_campaign=spring-sale&utm_content=posterKeep utm_campaign identical across every variant of one campaign and vary only utm_content (or utm_source when the channel itself differs). That is what lets your analytics roll all the variants up into one campaign report while still breaking them apart when you need to. The campaign builder handles this for the standard shape: every placement you list gets the shared destination tagged automatically, and a variant needing its own landing page can be given one without leaving the set. When every variant needs a fully bespoke URL, paste the tagged list into the bulk generator's paste mode with a label per row (“Leaflet A”, “Bus Stop Poster”) and generate the batch as dynamic codes.
Every code, on every plan, shows a headline scan count, a daily series and a country breakdown over a 7-day window. That is enough to answer “did anyone scan this at all” and “is it still getting scanned a week in”. It is not enough to answer “which time of day worked” or “did people come back”, because that needs dimensions Free does not expose.
| Dimension | Free | Pro |
|---|---|---|
| Headline scan count | Yes | Yes |
| Daily series | Yes | Yes |
| Country breakdown | Yes | Yes |
| History window | 7 days | 90 days |
| Device, referrer, region and town/city | No | Yes |
| Connection class (mobile vs datacenter, a bot-traffic tell) | No | Yes |
| Weekday x hour day-part heatmap | No | Yes |
The heatmap is the actual answer to "which variant worked"
A bare scan count tells you a variant got attention. The day-part heatmap tells you whether that attention clustered around when the leaflet actually landed on doormats, or a poster's commute-hour placement, which is the difference between a creative that worked and a creative that just happened to be near more people. That comparison is a Pro feature, because it is the dimension that turns a scan count into a decision.
Static codes are free, unlimited and never expire, but a static code cannot be repointed and carries no scan count, so it is not useful for attribution once the campaign is live. Dynamic codes are the ones that give you a scan log per variant, and the free plan includes one. A single-variant drop fits inside that. A leaflet, a bus stop poster and a mail drop already needs Pro. Anything past that, including simply re-running last season's variants alongside this season's, needs Pro (£9/month), and it is worth deciding that before the print order goes in rather than discovering it when the second code will not save.
Running a print attribution test, start to finish
Write one destination URL per variant
Same landing page, same utm_campaign, a different utm_content or utm_source per variant so your own analytics can split them. The campaign builder applies this scheme for you from the placement labels.
Create one code per variant, as one campaign
Name the campaign, paste the shared destination, add one placement per variant; a shared code across variants is the one mistake that makes the whole test unreadable.
Keep the set together
The campaign keeps the variants grouped on one page with per-placement scans, instead of mixed in with everything else you've printed.
Print, place, and note what changed
Placement and timing move scan counts as much as the creative does; write down what else was different so you don't credit the wrong variable.
Read scans against placement, not against ambition
A code with few scans may mean a weak creative, or it may mean it went somewhere fewer people walked past. Check placement before you retire a variant.
Export or pull the numbers once the run has had time to land
CSV from the dashboard for a one-off report, or the API for anything you'll want to repeat next campaign.
A scan means somebody pointed a phone at that specific piece of paper and it decoded. It does not mean they read the flyer, liked the offer, or bought anything: that part of the funnel is your landing page's and your analytics' job, which is exactly what the UTM tag is for. And a variant with almost no scans is not automatically a weak creative. It might be a strong creative in a place nobody walks past. Before writing off a variant, check where it went as well as what it said.
No scanner is ever identified, and that's what makes this shareable
Scan data is aggregated as it is written: there is no per-scan event to re-identify, no cookie, no device ID, nothing linking two scans to one person. Geography is never named below five scans in a window (k-anonymity, k=5), so a city only appears once enough people in it have scanned. That is what lets you hand a campaign report to a client or a colleague without a privacy conversation first, and it's the same reason OpenQR runs with no consent banner: there is no personal data being collected to consent to.
One client with three variants is a five-minute job in the dashboard. Ten clients, each with their own seasonal campaigns, is not something you want to click through by hand. Every number above (scans, the daily series, and the detailed breakdowns on Pro) is available as a CSV export from the dashboard for a one-off report, and through the REST API and MCP server for anything you want to pull automatically into a client dashboard or a reporting script. The API is not paywalled behind Pro: the plan boundary is on the analytics detail returned, not on whether you can reach it at all, so a Free-plan integration gets exactly the same headline numbers a Free-plan dashboard user sees.
Make the whole set at once
The bulk tool opens seeded with the shape of this job: example rows in your vocabulary, and the filename pattern that suits it. Free, unlimited and with no account for static codes.
Open the bulk generator