Appearance
Campaigns
A campaign renders one physical mail per recipient and sends it to print — the only endpoint with real, physical output. Read Campaigns before your first call.
Create a campaign
POST
/api/v1/campaignsOne call carries both the content and the recipients. Requires campaigns:create.
Headers
| Header | Required | Notes |
|---|---|---|
x-api-key | yes | Your API key |
Content-Type | yes | application/json or multipart/form-data. Anything else is 415 |
Idempotency-Key | recommended | 1–255 characters. A retry with the same key returns the original campaign instead of printing a second batch. See Idempotency |
Choosing a content source
Provide exactly one:
| Source | How | Notes |
|---|---|---|
templateId | JSON or multipart | A template you authored in the web app. The recommended path. |
pdf | multipart file part only | Your own print-ready A4 PDF, 1–2 pages |
Raw editor content is not part of the API
The endpoint also accepts a content field holding a rich-text document, but it is not supported for API integrations and API keys will be rejected for it in a future release. Physical mail cannot be proof-read after it is printed, and a document assembled over the API has never been seen by a human before it is mailed. Use templateId, whose output you previewed in the web app, or supply a pdf you rendered yourself.
A PDF is supported precisely because you produced it and can inspect it — the visual correctness of a PDF is yours to guarantee.
Choosing a recipient source
Provide exactly one:
| Source | How | Notes |
|---|---|---|
listId | JSON or multipart | An existing, non-empty list |
recipients | JSON or multipart | 1–5000 rows inline. A list is created for you, named after the campaign |
recipientsFile | multipart file part only | CSV (≤5 MB) or Excel (≤10 MB), plus mapping. A list is created for you |
Body fields
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 1–200 characters. Also becomes the implicit list's name |
format | "letter" | "postcard" | no | Default letter. postcard is web-app only for now — an API-key request receives 403 forbidden. See Postcards |
cardDesign | string | no | A ready-made Christmas card design (gran, stjarna, rod, sno) instead of your own artwork. Postcards only — sent with any other format the request is rejected. Web-app only for now, like postcard itself |
templateId | uuid | one content source | A template of yours |
listId | uuid | one recipient source | A list of yours, must have ≥1 recipient |
recipients | array | one recipient source | 1–5000 rows, fields as in Add a recipient minus listId |
recipientPolicies | object | no | fieldKey → missing-value policy. Only valid with recipients. Max 16 entries |
mapping | object | with recipientsFile | Column mapping. See Recipient files |
fileFormat | "csv" | "excel" | no | Only valid with recipientsFile. Detected from the filename and MIME type when omitted |
returnAddress | string | no | ≤1000 characters. Falls back to the template's, then to the platform default |
noReturnAddress | boolean | no | Letters only. Print no sender address in the envelope's left window; a return-address logo, if sent, is still printed there on its own. Without it an empty returnAddress means the platform default. Mail that cannot be delivered then cannot be returned to you |
qrCode | object | no | { url, description?, trackingEnabled }. Overrides the template's QR configuration. See QR codes |
payWithCredits | boolean | no | Web app only. An API-key call always spends credits, so the flag is accepted and ignored there |
variantBContent | string | no | A/B test. A second letter's content (same AST format as content), printed for a deterministic half of the list. Logo, images, return address and QR are shared. Not in PDF mode |
variantBBackContent | string | no | Variant B's back page; falls back to backContent when omitted. Only with variantBContent |
hasBackPage | boolean | no | PDF mode only. Render page 2 of your PDF as the back of the mail |
pdfQrPlacement | object | with qrCode in PDF mode | PDF mode only. { page: "front" | "back" | "both", x, y, size } in millimetres from the sheet's top-left; size 25–80. Where the code is printed on your artwork, with a 3 mm white margin around it. "both" prints the same code at the same spot on each side. "back" and "both" need hasBackPage. Keep it clear of the envelope windows, where the addresses print |
skipTemplateLogo | boolean | no | Only valid with templateId. Do not inherit the template's logo |
skipTemplateReturnLogo | boolean | no | Only valid with templateId. Do not inherit the template's return-address logo |
In a multipart request every scalar above is sent as a form field; structured fields (recipients, mapping, qrCode, pdfQrPlacement, recipientPolicies) are sent as JSON strings.
Multipart file parts
| Part | Limit | Notes |
|---|---|---|
pdf | 20 MB | application/pdf, must be a real PDF. Content source |
recipientsFile | 5 MB CSV / 10 MB Excel | Recipient source; requires mapping |
logo | 5 MB | PNG or JPEG. Overrides the template's logo |
returnAddressLogo | 5 MB | PNG or JPEG |
images_0 … images_4 | 5 MB each | PNG or JPEG, max 5, each described by imagesManifest |
imagesManifest | — | JSON { "new": [{ page, x, y, w, h, layer, fit, ordinal }] }, one entry per images_N in order |
The whole multipart body is capped at 40 MB.
Example
bash
curl -X POST https://api.utskick.dr.se/api/v1/campaigns \
-H "x-api-key: drse_your_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"
}'ts
const res = await fetch("https://api.utskick.dr.se/api/v1/campaigns", {
method: "POST",
headers: {
"x-api-key": process.env.DRSE_API_KEY!,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Summer promo",
templateId: "550e8400-e29b-41d4-a716-446655440002",
listId: "550e8400-e29b-41d4-a716-446655440001",
}),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.message);
console.log(body.data.id, body.data.status); // → "<uuid>" "pending"Success
201 Created — or 200 OK when an Idempotency-Key replays an earlier create.
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": "550e8400-e29b-41d4-a716-446655440002",
"source": "api",
"format": "letter",
"publishedAt": null,
"deliveredAt": 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/promo",
"slug": "summer-promo-a1b2",
"description": "Landing page",
"trackingEnabled": true,
"campaignId": "550e8400-e29b-41d4-a716-446655440000",
"trackingUrl": "https://api.utskick.dr.se/api/r/summer-promo-a1b2"
},
"import": { "imported": 250, "skipped": 3 }
}
}| Field | Notes |
|---|---|
status | pending → processing → completed | failed |
progress | 0–100 |
listId | Null once the list is removed by retention; the campaign is kept as a record |
listName | Null in the same situation |
templateId | An internal, immutable snapshot of the content — not the templateId you sent, and not retrievable via the templates endpoints |
format | letter (A4 in an envelope) or postcard (210×99 mm card). See Postcards |
cardDesign | The ready-made Christmas design the card was ordered with, or null for a postcard whose artwork was uploaded |
failureReason | Why status is failed, in plain words (e.g. the phone number could not be rented); null in every other status |
qrCode | The campaign's QR code, or null. The only place besides POST /api/v1/qr-codes that includes trackingUrl |
import | Only present when this call created the recipients (inline or file). Absent for listId, and absent on an idempotent replay |
Failures
| Status | code | When |
|---|---|---|
| 400 | validation_failed | Field validation failed (details carries formErrors/fieldErrors); body was not valid JSON (Invalid JSON body); Idempotency-Key was not 1–255 characters; the recipients-file format could not be detected; a file part was the wrong type or over its per-part limit |
| 401 | unauthorized | Key missing, invalid, expired, disabled or exhausted |
| 403 | forbidden | Key lacks campaigns:create |
| 404 | not_found | List not found (the listId is not yours) or Template not found (the templateId is not yours, or is an internal campaign snapshot) |
| 409 | conflict | List no longer exists — the list was deleted while the request was in flight |
| 413 | payload_too_large | Request body too large. Maximum 40MB., File too large. Maximum 5MB. (CSV) or File too large. Maximum 10MB. (Excel) |
| 415 | unsupported_media_type | Content-Type must be application/json or multipart/form-data |
| 422 | invalid_mapping | Invalid column mapping — details.issues explains which columns |
| 422 | recipient_validation_failed | Recipient validation failed — details.issues lists the offending rows |
| 422 | list_empty | List has no recipients, or the file produced no importable rows |
| 429 | rate_limited | Over a rate limit — key, account or IP. Honour Retry-After |
| 503 | service_unavailable | Phone numbers are not available right now — telephony is off on this deployment |
Nothing is half-created
Every recipient row is validated before anything is written. On a 422 there is no list, no recipient and no campaign — retry the whole call with corrected data.
List campaigns
GET
/api/v1/campaignsCursor-paginated, newest first. Requires campaigns:read.
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
cursor | uuid | — | An id from a previous page's meta |
limit | integer | 20 | 1–100 |
direction | next | prev | next | Page direction |
format | letter | postcard | — | Only campaigns of one format; both when omitted |
cardDesign | any | none | — | Splits postcards: any keeps only the ready-made Christmas cards, none only the ones whose artwork was uploaded. Both when omitted |
search does nothing here
A search parameter is accepted without error but has no effect on this endpoint. It filters only recipients and templates.
Example
bash
curl -H "x-api-key: drse_your_api_key" \
"https://api.utskick.dr.se/api/v1/campaigns?limit=2"ts
const res = await fetch("https://api.utskick.dr.se/api/v1/campaigns?limit=2", {
headers: { "x-api-key": process.env.DRSE_API_KEY! },
});
const { data, meta } = await res.json();200 OK
json
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Summer promo",
"status": "completed",
"progress": 100,
"listId": "550e8400-e29b-41d4-a716-446655440001",
"listName": "Summer list",
"templateId": "550e8400-e29b-41d4-a716-446655440002",
"source": "api",
"format": "letter",
"publishedAt": "2026-07-25T10:04:12.000Z",
"deliveredAt": "2026-07-25T10:06:48.000Z",
"createdAt": "2026-07-25T10:00:00.000Z",
"updatedAt": "2026-07-25T10:06:48.000Z"
}
],
"meta": { "count": 42, "nextCursor": "550e8400-e29b-41d4-a716-446655440000", "prevCursor": null }
}count is the total across all pages, not the page size.
Failures
| Status | code | When |
|---|---|---|
| 400 | validation_failed | limit out of 1–100, cursor not a UUID, direction not next/prev, format not letter/postcard, or cardDesign not any/none |
| 401 | unauthorized | Key problem |
| 403 | forbidden | Key lacks campaigns:read |
Count campaigns by status
GET
/api/v1/campaigns/status-countsRequires campaigns:read. Takes no parameters.
bash
curl -H "x-api-key: drse_your_api_key" \
https://api.utskick.dr.se/api/v1/campaigns/status-countsts
const res = await fetch(
"https://api.utskick.dr.se/api/v1/campaigns/status-counts",
{ headers: { "x-api-key": process.env.DRSE_API_KEY! } },
);
const { data } = await res.json();200 OK — only statuses with at least one campaign appear. Treat a missing key as zero.
json
{ "data": { "pending": 1, "processing": 2, "completed": 38, "failed": 1 } }Failures
| Status | code | When |
|---|---|---|
| 401 | unauthorized | Key problem |
| 403 | forbidden | Key lacks campaigns:read |
Retrieve a campaign
GET
/api/v1/campaigns/:idThe polling primitive. Requires campaigns:read.
bash
curl -H "x-api-key: drse_your_api_key" \
https://api.utskick.dr.se/api/v1/campaigns/550e8400-e29b-41d4-a716-446655440000ts
const id = "550e8400-e29b-41d4-a716-446655440000";
const res = await fetch(`https://api.utskick.dr.se/api/v1/campaigns/${id}`, {
headers: { "x-api-key": process.env.DRSE_API_KEY! },
});
const { data } = await res.json();
console.log(data.status, data.progress);200 OK — the same object as one row of the list endpoint. Note there is no qrCode field here; fetch it from GET /api/v1/qr-codes/:id using the id returned at create time.
deliveredAt is when the print partner accepted the campaign's archive, and it is the point after which the mail cannot be recalled. It stays null until then — including for a while after status turns completed, because completion means every letter was rendered and packaged, not that it was handed over. Poll status for progress; read deliveredAt to know a campaign has become irreversible.
json
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Summer promo",
"status": "processing",
"progress": 64,
"listId": "550e8400-e29b-41d4-a716-446655440001",
"listName": "Summer list",
"templateId": "550e8400-e29b-41d4-a716-446655440002",
"source": "api",
"format": "letter",
"publishedAt": "2026-07-25T10:04:12.000Z",
"deliveredAt": null,
"createdAt": "2026-07-25T10:00:00.000Z",
"updatedAt": "2026-07-25T10:05:30.000Z"
}
}Failures
| Status | code | When |
|---|---|---|
| 400 | validation_failed | id is not a UUID (Campaign ID must be a valid UUID) |
| 401 | unauthorized | Key problem |
| 403 | forbidden | Key lacks campaigns:read |
| 404 | not_found | Campaign not found — unknown id, or a campaign in another account |
See Polling & failures for a complete polling loop with backoff.