Appearance
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.
trackingEnabled | Printed code encodes | Redirect works | Scan recorded |
|---|---|---|---|
true | {baseUrl}/api/r/{slug}/{token} — one token per letter | Yes | Yes — anonymised, see below |
false | {baseUrl}/api/r/{slug} — one code for the campaign | Yes | No |
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
1. In the campaign create call — recommended
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
| Response | trackingUrl |
|---|---|
POST /api/v1/qr-codes | Yes |
qrCode inside the campaign create response | Yes |
GET /api/v1/qr-codes/:id | No |
PATCH /api/v1/qr-codes/:id | No |
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 analyticsThere 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/promoA 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
- Track QR scans — end-to-end walkthrough
- Analytics — what the Analys tab shows and withholds
- Field reference: QR codes
- Public endpoints — the redirect's exact behaviour