Skip to content

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/summer
ts
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 response

This 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 yourself

Where 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 analytics

That also gives you attribution through to conversion rather than a bare scan.

What is recorded ​

trackingEnabledPrinted code encodesRedirect worksStored per scan
true{baseUrl}/api/r/{slug}/{token}, one token per letterYesCampaign, letter token, timestamp, device class, daily-rotating visitor hash
false{baseUrl}/api/r/{slug}YesNothing

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 ​