Appearance
Public endpoints
Two endpoints take no credentials. Both live outside /api/v1 on purpose.
Health check
GET
/apiNo authentication, no permission. Use it for liveness probes and to confirm your base URL is right before debugging a key.
bash
curl https://api.utskick.dr.se/apits
const res = await fetch("https://api.utskick.dr.se/api");
const { data } = await res.json(); // { status: "ok" }200 OK
json
{ "data": { "status": "ok" } }Failures
| Status | code | When |
|---|---|---|
| 503 | service_unavailable | Service Unavailable — the instance is shutting down and is draining requests |
Distinguishing a bad URL from a bad key
If this returns 200 but your /api/v1/... calls return 401, the problem is the key, not the address. If this itself fails, your base URL or the service is at fault.
QR code redirect
GET
/api/r/:slugThe address encoded into printed QR codes when tracking is enabled. It is deliberately un-versioned: the slug is printed on paper and must keep resolving indefinitely, so it can never move to /v2.
You do not normally call this — the reader's phone does. It is documented so you understand what is printed, and so you can verify a code before mailing.
:slug is the slug from a QR code, e.g. summer-promo-a1b2.
bash
# -i to see the redirect rather than following it
curl -i https://api.utskick.dr.se/api/r/summer-promo-a1b2ts
const res = await fetch("https://api.utskick.dr.se/api/r/summer-promo-a1b2", {
redirect: "manual",
});
console.log(res.status); // 302
console.log(res.headers.get("location")); // your destination URL302 Found with the destination in Location. There is no JSON body.
http
HTTP/1.1 302 Found
Location: https://example.com/promo
Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0
X-Robots-Tag: noindex, nofollowBehaviour worth knowing:
- Caching is fully disabled, so every scan reaches the API and can be counted, and so a later
urlchange takes effect immediately. X-Robots-Tag: noindex, nofollowkeeps tracking links out of search engines.- When the code has
trackingEnabled: true, the scan is recorded with a timestamp, the client IP (fromx-forwarded-fororx-real-ip) and the user agent. When it isfalse, the redirect still works but nothing is recorded. - A failure to record never blocks the redirect. The reader always reaches your URL even if logging fails.
- The destination is read from storage per request, so a
urlchange throughPATCH /api/v1/qr-codes/:idapplies to codes already printed. - Recorded scans are not currently readable — no endpoint returns them and no web app screen shows them.
Failures
| Status | code | When |
|---|---|---|
| 404 | not_found | QR code not found — unknown slug, or the QR code was deleted |
Deleting a QR code breaks printed mail
This is the endpoint printed on paper. If the QR code is deleted, every already- mailed copy leads to a 404 permanently. To retarget a campaign, update the code's url — see Update a QR code.
Per-letter QR code redirect
GET
/api/r/:slug/:tokenThe same redirect as above, for the QR code printed on one specific letter. When a campaign has scan tracking enabled, every letter is printed with its own token after the campaign slug, so a scan can be attributed to the letter without the letter carrying anything about the recipient.
| Behaviour | Notes |
|---|---|
| Destination | Identical to /api/r/:slug — the campaign's current url. Updating the QR code's url re-points every printed letter at once. |
| Unknown token | Still redirects (a printed letter must always work); the scan is recorded without letter attribution. |
| What is stored | Timestamp, the letter's token, a device class (mobile / desktop / bot) and a salted hash that rotates daily. No IP address, no user agent. |
| Where it shows up | Aggregated in the web app under Analys — never per recipient, and detailed insights only once at least 10 different letters have been scanned. |
Failures
| Status | Code | When |
|---|---|---|
| 404 | not_found | The slug does not exist. |
Stripe webhook
POST
/api/webhooks/stripeReceives payment events from Stripe. Not for direct use: the request must carry a valid stripe-signature header — an HMAC over the raw body with the endpoint's signing secret — and anything else is rejected with 400 and no detail. Payment confirmation releases the paid campaign to rendering; all other event types are acknowledged and ignored.
There is nothing to integrate against here. Campaign status is the observable effect, via GET /api/v1/campaigns/:id.
Landing-page tracking
GET
/api/t.jsThe tracking snippet. Paste one script tag on the landing page a campaign's QR code points at:
html
<script src="https://api.utskick.dr.se/api/t.js" data-campaign="summer-promo-a1b2"></script>data-campaign is the campaign's QR slug — already printed on the letters, so it is public by nature. On load the snippet reports a visit; call drse.lead() when a form is sent to report a lead. The redirect appends the letter's token to the landing URL as ?dr=…, which the snippet picks up (and keeps in sessionStorage for the rest of the visit) so visits and leads count per letter, the same way scans do. No cookies are set.
POST
/api/analytics/eventsWhere the snippet reports to. Body is JSON (sent as text/plain to stay a simple CORS request): { "campaign": "<slug>", "kind": "visit" | "lead", "token"?: "<dr token>", "path"?: "<pathname>" }. Always answers 204 — an unknown campaign key, a forged token or a malformed body is dropped silently, never reported to the page. The token is only kept when it belongs to that campaign; the path is reduced to a pathname; the client is stored as a daily-rotating hash, never an address or user agent. Bots are ignored.
Numbers appear under Analys in the web app (Besök, Leads). See Track landing-page visits.
46elks voice webhooks
POST
/api/webhooks/elks/voice-startCalled by 46elks when someone dials a phone number rented for a campaign (the telephony add-on). Not for direct use: the request must carry the shared secret as HTTP basic auth — it is embedded in the webhook URL the platform registers with 46elks — and anything else is a 404 with no detail. The response tells 46elks to connect the call to the customer's own number; a call to a number no longer rented is hung up.
POST
/api/webhooks/elks/hangupCalled by 46elks when such a call ends. Same authentication. Records the call's outcome (answered / busy / failed, duration) against the campaign, with the caller's number stored only as a keyed hash. Idempotent per call id.
Campaign owners see the result under Analys in the web app; there is no public API for call data. See Telephony.
What is not public
/api/auth/* handles web-app sessions and is not part of the developer API. Keys are created and revoked on the web app's API page — there is no programmatic key management, and key-management routes reject API keys with 403 Forbidden: API keys cannot access this resource.