Skip to content

QR codes ​

A QR code belongs to exactly one campaign. Every code is printed as a redirect through this API, so its destination stays changeable after the mail is posted.

How a QR code resolves ​

  printed code          this API                     your site
  ────────────          ────────                     ─────────
  /api/r/summer-a1b2 ──► look up destination ──302──► https://example.com/promo
                         (+ record the scan when
                          trackingEnabled)

The printed code always encodes a redirect through this API, never your destination. The redirect reads the destination per request and the slug never changes, so updating url re-points every code already in the post.

trackingEnabledPrinted code encodesRedirect worksScan recorded
true{baseUrl}/api/r/{slug}/{token} — one token per letterYesYes — anonymised, see below
false{baseUrl}/api/r/{slug} — one code for the campaignYesNo

trackingEnabled decides attribution, not retargetability

Both settings print a redirect URL and both are retargetable through url. With tracking on, every letter gets its own 12-character token so the platform can count how many different letters were scanned; with it off, every letter carries the same campaign code and nothing is written.

What a scan stores ​

A scan row holds the campaign, the letter's token, a timestamp, a device class (mobile / desktop / bot / unknown) and a daily-rotating visitor hash — the client address and user agent hashed together with a secret and the current date, so one phone scanning twice the same day counts once. The raw IP address and user agent are never stored, and the token carries only the recipient's post code and post town, not the name or street. Scans are deleted after 18 months.

A scan is not a person

Link previewers and security scanners are classified as bot and excluded from the numbers; people scanning twice are deduplicated per day. Even so, read the counts as responses to a campaign, not as a list of who responded — through this API the platform cannot produce that list.

One exception, and it is not in this API

In the web app a customer may certify, for one mailing, that it goes to companies rather than private individuals. That mailing's letters snapshot the recipient's name onto their token, and its report names the companies that scanned. Certifying is a human act, so it has no API-key surface: a campaign created with an API key is always anonymous.

Three ways a campaign gets a QR code ​

json
{
  "name": "Summer promo",
  "templateId": "550e8400-...",
  "listId": "550e8400-...",
  "qrCode": {
    "url": "https://example.com/promo",
    "description": "Landing page",
    "trackingEnabled": true
  }
}

Persisted in the same transaction as the campaign, so it exists before rendering starts. The create response returns it, including trackingUrl.

2. Inherited from the template ​

If the template has a default QR destination, campaigns inherit it automatically. Sending qrCode overrides it entirely.

3. Attached afterwards ​

POST /api/v1/qr-codes with a campaignId.

Attach before rendering starts

The QR image is composed into the document during processing. Attaching a code to a completed campaign changes nothing that was printed — you get a QR record with no printed counterpart. Prefer method 1.

A campaign may have at most one QR code. A second attempt is 409 Campaign already has a QR code.

The slug is permanent ​

json
{ "slug": "summer-promo-a1b2", "trackingUrl": "https://api.utskick.dr.se/api/r/summer-promo-a1b2" }

The campaign name slugified, plus a 4-character random suffix. Globally unique and never changeable — it is printed on paper, which is also why the redirect stays un-versioned. Hence PATCH accepts url but not slug: retarget a printed code, never rename it.

Tokens are derived, not stored secrets ​

The per-letter token is base32(HMAC-SHA256(secret, campaignId:recipientId))[:12], minted when rendering starts. Re-running a campaign therefore yields the same tokens, and a token cannot be guessed from another. An unknown token still redirects — a printed letter must always work — it is just recorded without attribution.

trackingUrl is only returned twice ​

ResponsetrackingUrl
POST /api/v1/qr-codesYes
qrCode inside the campaign create responseYes
GET /api/v1/qr-codes/:idNo
PATCH /api/v1/qr-codes/:idNo

Compose it yourself when you need it:

ts
const trackingUrl = `${BASE}/api/r/${qrCode.slug}`;

Retargeting a live campaign ​

The slug is fixed and the redirect uncacheable, so updating url re-points every printed code:

ts
await fetch(`${BASE}/api/v1/qr-codes/${qrCodeId}`, {
  method: "PATCH",
  headers: { "x-api-key": KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ url: "https://example.com/autumn" }),
});

Works whatever trackingEnabled is set to. A mistyped or expired landing page stays fixable after the mail has gone out — the most useful property of the feature.

PATCH rejects unknown fields

The update body is strict: a typo like trackingEnable is a 400, not a silently ignored field. An ignored typo would leave you believing you had changed something you had not.

Deleting is destructive ​

Deleting breaks printed mail permanently

DELETE /api/v1/qr-codes/:id makes the printed slug resolve to a 404 for the life of the mail and removes its recorded scans. No undo — a replacement gets a new slug, and the old print is dead.

To change a destination, update url. Reserve delete for codes never printed; most integrations should not hold qr-codes:delete at all.

Where the numbers are ​

Scans surface in the web app under Analys: scans, unique responders, response rate, estimated open rate, cost per response, a daily series and — once at least 10 different letters have been scanned — device split, geography by post town, response-time curve and time-of-day patterns. See Analytics for what is shown, what is withheld and why.

There is no public API for scan data yet, so an integration that needs response numbers in its own systems should still point the code at a URL it controls and instrument that landing page:

QR → /api/r/{slug}/{token} → https://yoursite.example/lp?c=summer-2026
                                                          └── your own analytics

There is also no endpoint for the QR image — it is composed into the printed document and not exposed.

Reading the redirect yourself ​

GET /api/r/:slug is public and returns a 302. Use it to confirm a code resolves before mailing:

bash
curl -i https://api.utskick.dr.se/api/r/summer-promo-a1b2
# HTTP/1.1 302 Found
# Location: https://example.com/promo

A curl request is classified as bot and excluded from the Analys figures, so checking the redirect this way does not skew measurement. Scanning a printed proof with a real phone does count — do that before the campaign goes out, or use the plain /api/r/{slug} form, which is recorded without a letter attached.

Next ​