Appearance
Track QR scans
Put a QR code on printed mail so you can retarget its destination after the mail has gone out, and so scans pass through a URL you control.
Where the numbers end up
With trackingEnabled: true every letter prints its own code and the scans show up under Analys in the web app. There is no public API for scan data yet, so an integration that needs the numbers in its own systems should also instrument its landing page. Details below.
Permissions needed: campaigns:create, campaigns:read, plus qr-codes:read and qr-codes:update if you manage the code afterwards.
Attach at create time
The reliable way. The code is persisted in the same transaction as the campaign, so it exists before rendering begins.
bash
curl -X POST "$DRSE_BASE_URL/api/v1/campaigns" \
-H "x-api-key: $DRSE_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"name": "Summer promo",
"templateId": "550e8400-e29b-41d4-a716-446655440002",
"listId": "550e8400-e29b-41d4-a716-446655440001",
"qrCode": {
"url": "https://example.com/summer",
"description": "Summer landing page",
"trackingEnabled": true
}
}'ts
const res = await fetch(`${BASE}/api/v1/campaigns`, {
method: "POST",
headers: {
"x-api-key": KEY,
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Summer promo",
templateId: "550e8400-e29b-41d4-a716-446655440002",
listId: "550e8400-e29b-41d4-a716-446655440001",
qrCode: {
url: "https://example.com/summer",
description: "Summer landing page",
trackingEnabled: true,
},
}),
});
const { data } = await res.json();
// Store these — GET /qr-codes/:id will not return trackingUrl again.
const { id: qrCodeId, slug, trackingUrl } = data.qrCode;The create response includes the code:
json
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Summer promo",
"status": "pending",
"progress": 0,
"listId": "550e8400-e29b-41d4-a716-446655440001",
"listName": "Summer list",
"templateId": "018f2c7a-0000-7000-8000-000000000000",
"publishedAt": null,
"createdAt": "2026-07-25T10:00:00.000Z",
"updatedAt": "2026-07-25T10:00:00.000Z",
"qrCode": {
"id": "550e8400-e29b-41d4-a716-446655440003",
"url": "https://example.com/summer",
"slug": "summer-promo-a1b2",
"description": "Summer landing page",
"trackingEnabled": true,
"campaignId": "550e8400-e29b-41d4-a716-446655440000",
"trackingUrl": "https://api.utskick.dr.se/api/r/summer-promo-a1b2"
}
}
}Persist id and slug now
trackingUrl is returned here and nowhere else — GET /api/v1/qr-codes/:id omits it. Save id (to update the code) and slug (to rebuild the URL as {baseUrl}/api/r/{slug}).
Verify before mailing
Confirm the redirect resolves. curl is classified as a bot and excluded from the Analys figures, so this check does not skew measurement — but scanning a proof with a real phone counts, so do that before the mail goes out.
bash
curl -i "$DRSE_BASE_URL/api/r/summer-promo-a1b2"
# HTTP/1.1 302 Found
# Location: https://example.com/summerts
const res = await fetch(`${BASE}/api/r/${slug}`, { redirect: "manual" });
console.assert(res.status === 302);
console.assert(res.headers.get("location") === "https://example.com/summer");A 404 QR code not found means the slug is wrong or the code was deleted.
Attaching to an existing campaign
Possible, but time-sensitive:
ts
const res = await fetch(`${BASE}/api/v1/qr-codes`, {
method: "POST",
headers: { "x-api-key": KEY, "Content-Type": "application/json" },
body: JSON.stringify({
campaignId,
url: "https://example.com/summer",
trackingEnabled: true,
}),
});Too late once rendering has begun
The QR image is composed into the document during processing. A code attached to a campaign already completed will not appear on anything printed — you get a record with no printed counterpart. Attach at create time.
A second code on the same campaign is 409 Campaign already has a QR code.
Retargeting live mail
The slug never changes and the redirect is uncacheable, so updating url re-points every code already printed.
bash
curl -X PATCH "$DRSE_BASE_URL/api/v1/qr-codes/550e8400-e29b-41d4-a716-446655440003" \
-H "x-api-key: $DRSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/summer-extended" }'ts
const res = 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/summer-extended" }),
});
const { data } = await res.json(); // no trackingUrl in this responseThis is the most useful thing the QR feature does: a landing page that breaks, expires or moves stays fixable after the mail is in the post.
It works regardless of trackingEnabled — the printed code encodes the redirect either way, so both settings are retargetable.
Unknown fields are rejected
The update body is strict — a typo like trackingEnable returns 400, not a silent no-op. Better a loud failure than believing you changed something you did not.
Reading the code back
ts
const res = await fetch(`${BASE}/api/v1/qr-codes/${qrCodeId}`, {
headers: { "x-api-key": KEY },
});
const { data } = await res.json();
const trackingUrl = `${BASE}/api/r/${data.slug}`; // compose it yourselfWhere the numbers are
In the web app, under Analys: open the campaign to see scans, unique responders, response rate, estimated open rate, cost per response and a daily series. Device split, geography by post town, response-time curve and time-of-day patterns unlock once at least 10 different letters have been scanned — see Analytics for the rules.
No scan API yet — keep instrumenting your landing page
Nothing in /api/v1 returns scan counts today. If your own systems need the numbers, point the code at a URL you control and instrument that page:
QR → /api/r/{slug}/{token} → https://yoursite.example/lp?c=summer-2026
└── your own analyticsThat also gives you attribution through to conversion rather than a bare scan.
What is recorded
trackingEnabled | Printed code encodes | Redirect works | Stored per scan |
|---|---|---|---|
true | {baseUrl}/api/r/{slug}/{token}, one token per letter | Yes | Campaign, letter token, timestamp, device class, daily-rotating visitor hash |
false | {baseUrl}/api/r/{slug} | Yes | Nothing |
The raw IP address and user agent are never stored — only a hash salted with a secret and the current date, which is what deduplicates one phone scanning twice the same day. Tokens carry the recipient's post code and town, never the name or street. Scans are deleted after 18 months.
A scan is not a visitor
Link previewers and security scanners are classified as bots and excluded; repeat scans are deduplicated per day. Read the numbers as responses to a campaign, not as a list of who responded — the platform cannot produce that list.
The redirect sets Cache-Control: no-store and X-Robots-Tag: noindex, nofollow, so scans are never swallowed by a cache and tracking URLs stay out of search results. If recording fails, the reader is still redirected — measurement never blocks delivery.
Do not delete
ts
// Almost never the right call:
await fetch(`${BASE}/api/v1/qr-codes/${qrCodeId}`, { method: "DELETE", ... });Deleting breaks printed mail permanently
The printed slug starts returning 404 forever, and recorded scans are removed. There is no undo — a replacement code gets a new slug, and the paper already mailed is dead. To change a destination, PATCH url. Most integrations should not hold qr-codes:delete at all.
PDF campaigns cannot have one
qrCode is rejected in PDF mode with 400 QR codes are not supported in PDF mode. Draw the code into your PDF yourself — but note it cannot then be tracked or retargeted. If either matters, use a template campaign. See PDF campaigns.
Next
- QR codes — the full model
- Analytics — what the Analys tab shows
- Field reference: QR codes
- Public endpoints — redirect behaviour