Appearance
Quickstart
From nothing to a campaign in the print queue. Five steps, roughly ten minutes.
1. Create an API key
Keys are created in the web app, not over the API.
- Open the web app and go to API in the sidebar.
- Choose Create key, name it, and grant these permissions:
| Resource | Actions |
|---|---|
templates | read |
campaigns | read, create |
- Copy the key. It is shown once. It starts with
drse_.
bash
export DRSE_API_KEY="drse_your_api_key"
export DRSE_BASE_URL="https://api.utskick.dr.se" # ← the production APIConfirm the key works:
bash
curl -s -o /dev/null -w "%{http_code}\n" \
-H "x-api-key: $DRSE_API_KEY" \
"$DRSE_BASE_URL/api/v1/campaigns"
# → 200ts
const BASE = process.env.DRSE_BASE_URL!;
const KEY = process.env.DRSE_API_KEY!;
const res = await fetch(`${BASE}/api/v1/campaigns`, {
headers: { "x-api-key": KEY },
});
console.log(res.status); // 200401 means the key is wrong. 403 means it is missing campaigns:read. See Authentication.
2. Design a template in the web app
This is the one manual step, and it cannot be automated — physical output has to be checked by eye before it is trusted. See Templates.
- Go to Templates → Create.
- Lay out your mail. The editor marks the zones that must stay clear for the address block and postage.
- Use placeholders (recipient name, street, and so on) so each letter is personalised.
- Save.
3. Find the template id
bash
curl -s -H "x-api-key: $DRSE_API_KEY" \
"$DRSE_BASE_URL/api/v1/templates?limit=10"ts
const res = await fetch(`${BASE}/api/v1/templates?limit=10`, {
headers: { "x-api-key": KEY },
});
const { data } = await res.json();
const templateId = data[0].id;
console.log(templateId);json
{
"data": [
{ "id": "550e8400-e29b-41d4-a716-446655440002", "name": "Summer postcard", "kind": "user", "hasBackPage": false,
"qrCodeUrl": null, "qrCodeDescription": null, "qrCodeTrackingEnabled": false,
"createdAt": "2026-07-20T08:00:00.000Z", "updatedAt": "2026-07-20T08:00:00.000Z" }
],
"meta": { "count": 1, "nextCursor": null, "prevCursor": null }
}Reference: GET /api/v1/templates.
4. Create the campaign
This mails real letters
Step 4 spends paper and postage.
The recipients travel with the request, so there is no list to set up. Note the Idempotency-Key: without it, a timeout you retry could print twice.
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": "Quickstart test",
"templateId": "550e8400-e29b-41d4-a716-446655440002",
"recipients": [
{
"name": "Your Name",
"street": "Your Street 1",
"postCode": "111 11",
"postTown": "Stockholm",
"country": "SE"
}
]
}'ts
const res = await fetch(`${BASE}/api/v1/campaigns`, {
method: "POST",
headers: {
"x-api-key": KEY,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Quickstart test",
templateId,
recipients: [
{
name: "Your Name",
street: "Your Street 1",
postCode: "111 11",
postTown: "Stockholm",
country: "SE",
},
],
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
const campaignId = body.data.id;201 Created
json
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Quickstart test",
"status": "pending",
"progress": 0,
"listId": "550e8400-e29b-41d4-a716-446655440001",
"listName": "Quickstart test",
"templateId": "018f2c7a-0000-7000-8000-000000000000",
"publishedAt": null,
"createdAt": "2026-07-25T10:00:00.000Z",
"updatedAt": "2026-07-25T10:00:00.000Z",
"qrCode": null,
"import": { "imported": 1, "skipped": 0 }
}
}Two things to notice:
listIdis a list the platform created for you, named after the campaign.templateIdin the response is not the template you sent. It is an internal, frozen snapshot of the content, so later edits to your template cannot change mail already sent.
Reference: POST /api/v1/campaigns.
5. Poll until it finishes
bash
curl -s -H "x-api-key: $DRSE_API_KEY" \
"$DRSE_BASE_URL/api/v1/campaigns/550e8400-e29b-41d4-a716-446655440000"ts
async function waitForCampaign(id: string) {
let delay = 2000;
for (;;) {
const res = await fetch(`${BASE}/api/v1/campaigns/${id}`, {
headers: { "x-api-key": KEY },
});
const { data } = await res.json();
console.log(`${data.status} ${data.progress}%`);
if (data.status === "completed") return data;
if (data.status === "failed") throw new Error("Campaign failed");
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 2, 30_000); // back off, cap at 30s
}
}
await waitForCampaign(campaignId);status moves pending → processing → completed | failed, with progress from 0 to 100.
json
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Quickstart test",
"status": "completed",
"progress": 100,
"listId": "550e8400-e29b-41d4-a716-446655440001",
"listName": "Quickstart test",
"templateId": "018f2c7a-0000-7000-8000-000000000000",
"publishedAt": "2026-07-25T10:04:12.000Z",
"createdAt": "2026-07-25T10:00:00.000Z",
"updatedAt": "2026-07-25T10:04:12.000Z"
}
}A letter is on its way.
When something goes wrong
| You see | Meaning | Fix |
|---|---|---|
401 unauthorized | Key missing, wrong, expired or disabled | Check the header spelling: x-api-key |
403 forbidden | Key lacks a permission | Grant it on the web app's API page |
404 Template not found | Wrong id, or a template in another account | Re-list your templates |
422 recipient_validation_failed | A row failed. Nothing was created | Read details.issues — each names a row and field |
422 list_empty | The audience resolved to zero recipients | Check the list is not empty |
429 rate_limited | Over a rate limit | Wait Retry-After seconds, then retry |
Full catalog: Errors.
Next
- Reuse an audience → Campaign from a saved list
- Send your own PDF → Campaign from a PDF
- Load a spreadsheet → Campaign from a CSV or Excel file
- Add a QR code you can repoint later → Track QR scans
- Before real volume → Production checklist