Skip to content

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/campaigns
requires campaigns:create

One call carries both the content and the recipients. Requires campaigns:create.

Headers ​

HeaderRequiredNotes
x-api-keyyesYour API key
Content-Typeyesapplication/json or multipart/form-data. Anything else is 415
Idempotency-Keyrecommended1–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:

SourceHowNotes
templateIdJSON or multipartA template you authored in the web app. The recommended path.
pdfmultipart file part onlyYour 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:

SourceHowNotes
listIdJSON or multipartAn existing, non-empty list
recipientsJSON or multipart1–5000 rows inline. A list is created for you, named after the campaign
recipientsFilemultipart file part onlyCSV (≤5 MB) or Excel (≤10 MB), plus mapping. A list is created for you

Body fields ​

FieldTypeRequiredNotes
namestringyes1–200 characters. Also becomes the implicit list's name
format"letter" | "postcard"noDefault letter. postcard is web-app only for now — an API-key request receives 403 forbidden. See Postcards
cardDesignstringnoA 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
templateIduuidone content sourceA template of yours
listIduuidone recipient sourceA list of yours, must have ≥1 recipient
recipientsarrayone recipient source1–5000 rows, fields as in Add a recipient minus listId
recipientPoliciesobjectnofieldKey → missing-value policy. Only valid with recipients. Max 16 entries
mappingobjectwith recipientsFileColumn mapping. See Recipient files
fileFormat"csv" | "excel"noOnly valid with recipientsFile. Detected from the filename and MIME type when omitted
returnAddressstringno≤1000 characters. Falls back to the template's, then to the platform default
noReturnAddressbooleannoLetters 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
qrCodeobjectno{ url, description?, trackingEnabled }. Overrides the template's QR configuration. See QR codes
payWithCreditsbooleannoWeb app only. An API-key call always spends credits, so the flag is accepted and ignored there
variantBContentstringnoA/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
variantBBackContentstringnoVariant B's back page; falls back to backContent when omitted. Only with variantBContent
hasBackPagebooleannoPDF mode only. Render page 2 of your PDF as the back of the mail
pdfQrPlacementobjectwith qrCode in PDF modePDF 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
skipTemplateLogobooleannoOnly valid with templateId. Do not inherit the template's logo
skipTemplateReturnLogobooleannoOnly 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 ​

PartLimitNotes
pdf20 MBapplication/pdf, must be a real PDF. Content source
recipientsFile5 MB CSV / 10 MB ExcelRecipient source; requires mapping
logo5 MBPNG or JPEG. Overrides the template's logo
returnAddressLogo5 MBPNG or JPEG
images_0 … images_45 MB eachPNG 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 }
  }
}
FieldNotes
statuspending → processing → completed | failed
progress0–100
listIdNull once the list is removed by retention; the campaign is kept as a record
listNameNull in the same situation
templateIdAn internal, immutable snapshot of the content — not the templateId you sent, and not retrievable via the templates endpoints
formatletter (A4 in an envelope) or postcard (210×99 mm card). See Postcards
cardDesignThe ready-made Christmas design the card was ordered with, or null for a postcard whose artwork was uploaded
failureReasonWhy status is failed, in plain words (e.g. the phone number could not be rented); null in every other status
qrCodeThe campaign's QR code, or null. The only place besides POST /api/v1/qr-codes that includes trackingUrl
importOnly present when this call created the recipients (inline or file). Absent for listId, and absent on an idempotent replay

Failures ​

StatuscodeWhen
400validation_failedField 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
401unauthorizedKey missing, invalid, expired, disabled or exhausted
403forbiddenKey lacks campaigns:create
404not_foundList not found (the listId is not yours) or Template not found (the templateId is not yours, or is an internal campaign snapshot)
409conflictList no longer exists — the list was deleted while the request was in flight
413payload_too_largeRequest body too large. Maximum 40MB., File too large. Maximum 5MB. (CSV) or File too large. Maximum 10MB. (Excel)
415unsupported_media_typeContent-Type must be application/json or multipart/form-data
422invalid_mappingInvalid column mapping — details.issues explains which columns
422recipient_validation_failedRecipient validation failed — details.issues lists the offending rows
422list_emptyList has no recipients, or the file produced no importable rows
429rate_limitedOver a rate limit — key, account or IP. Honour Retry-After
503service_unavailablePhone 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/campaigns
requires campaigns:read

Cursor-paginated, newest first. Requires campaigns:read.

Query parameters ​

ParameterTypeDefaultNotes
cursoruuid—An id from a previous page's meta
limitinteger201–100
directionnext | prevnextPage direction
formatletter | postcard—Only campaigns of one format; both when omitted
cardDesignany | 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 ​

StatuscodeWhen
400validation_failedlimit out of 1–100, cursor not a UUID, direction not next/prev, format not letter/postcard, or cardDesign not any/none
401unauthorizedKey problem
403forbiddenKey lacks campaigns:read

Count campaigns by status ​

GET/api/v1/campaigns/status-counts
requires campaigns:read

Requires campaigns:read. Takes no parameters.

bash
curl -H "x-api-key: drse_your_api_key" \
  https://api.utskick.dr.se/api/v1/campaigns/status-counts
ts
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 ​

StatuscodeWhen
401unauthorizedKey problem
403forbiddenKey lacks campaigns:read

Retrieve a campaign ​

GET/api/v1/campaigns/:id
requires campaigns:read

The 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-446655440000
ts
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 ​

StatuscodeWhen
400validation_failedid is not a UUID (Campaign ID must be a valid UUID)
401unauthorizedKey problem
403forbiddenKey lacks campaigns:read
404not_foundCampaign not found — unknown id, or a campaign in another account

See Polling & failures for a complete polling loop with backoff.