QR codes for campaign tracking and print attribution

OpenQR
A stack of freshly cut blank leaflets beside the blade of a paper guillotine.

QR codes for campaign tracking and print attribution

A print run does not tell you what worked. Two thousand leaflets go out with a headline and a photo, a bus stop poster runs the same offer in a different format, and a month later someone asks which one paid for itself. Without a code that is unique to each printed piece, the honest answer is a shrug. Give every variant its own dynamic QR code and the question stops being a guess: each one has its own scan count, its own timing, and its own UTM tag showing up in your own analytics, not just ours.

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 job is one code per variant, not one code reprinted everywhere

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.

  • Two headlines on the same leaflet, split between two print runs (an A/B test on paper)
  • The same offer on a leaflet, a bus stop poster and a shop window sticker (a channel test)
  • A direct mail drop repeated across three postcodes with the same creative
  • A trade show handout compared against the same offer mailed to the same list
  • A seasonal campaign re-run months later, kept as its own variant so it does not blend into last time's numbers

UTM parameters carry the attribution into your own analytics too

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=poster

Keep 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.

Comparing variants side by side, and what plan that needs

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.

DimensionFreePro
Headline scan countYesYes
Daily seriesYesYes
Country breakdownYesYes
History window7 days90 days
Device, referrer, region and town/cityNoYes
Connection class (mobile vs datacenter, a bot-traffic tell)NoYes
Weekday x hour day-part heatmapNoYes

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.

A campaign with more than one variant needs Pro, so decide that up front

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

  1. 1

    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.

  2. 2

    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.

  3. 3

    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.

  4. 4

    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.

  5. 5

    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.

  6. 6

    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.

What a QR scan does not prove

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.

Running campaign tracking at agency or multi-client scale

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.

Yes. Create the pair as one campaign: same landing page as the shared destination, one placement per design, and the builder tags and groups them for you. Print each design's code on its own run and compare the placement scan counts once both have been in circulation for the same length of time.

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