Appearance
Recipients
A recipient is one physical address. The five standard fields are always required; anything else is a custom field, and the first recipient added to a list freezes which custom fields that list accepts — see Lists & recipients.
List all recipients
GET
/api/v1/recipientsEvery recipient in the account, across all lists. Cursor-paginated, newest first. Requires recipients:read.
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
cursor | uuid | — | An id from a previous page |
limit | integer | 20 | 1–100 |
direction | next | prev | next | Page direction |
search | string | — | ≤200 characters. Case-insensitive substring match on the recipient name only |
bash
curl -H "x-api-key: drse_your_api_key" \
"https://api.utskick.dr.se/api/v1/recipients?limit=1&search=alice"ts
const params = new URLSearchParams({ limit: "1", search: "alice" });
const res = await fetch(
`https://api.utskick.dr.se/api/v1/recipients?${params}`,
{ headers: { "x-api-key": process.env.DRSE_API_KEY! } },
);
const { data, meta } = await res.json();200 OK
json
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440004",
"name": "Alice Andersson",
"street": "1 First St",
"postCode": "111 11",
"postTown": "Stockholm",
"country": "SE",
"customFields": { "vip": { "label": "VIP", "value": "yes" } },
"listId": "550e8400-e29b-41d4-a716-446655440001",
"userId": "user_2abc",
"createdAt": "2026-07-25T09:10:00.000Z",
"updatedAt": "2026-07-25T09:10:00.000Z"
}
],
"meta": { "count": 250, "nextCursor": "550e8400-e29b-41d4-a716-446655440004", "prevCursor": null }
}count respects search: it is the number of matching recipients, not the account total.
Failures
| Status | code | When |
|---|---|---|
| 400 | validation_failed | limit out of range, cursor not a UUID, direction invalid, or search over 200 characters |
| 401 | unauthorized | Key problem |
| 403 | forbidden | Key lacks recipients:read |
List the recipients of one list
GET
/api/v1/recipients/by-list/:listIdSame shape and parameters as above, scoped to one list. Requires recipients:read.
bash
curl -H "x-api-key: drse_your_api_key" \
"https://api.utskick.dr.se/api/v1/recipients/by-list/550e8400-e29b-41d4-a716-446655440001?limit=50"ts
const listId = "550e8400-e29b-41d4-a716-446655440001";
const res = await fetch(
`https://api.utskick.dr.se/api/v1/recipients/by-list/${listId}?limit=50`,
{ headers: { "x-api-key": process.env.DRSE_API_KEY! } },
);
const { data, meta } = await res.json();200 OK — identical to the account-wide endpoint; meta.count is the number of matching recipients in this list.
Failures
| Status | code | When |
|---|---|---|
| 400 | validation_failed | listId not a UUID, or an invalid query parameter |
| 401 | unauthorized | Key problem |
| 403 | forbidden | Key lacks recipients:read |
| 404 | not_found | List not found — unknown id, or a list in another account |
Add one recipient
POST
/api/v1/recipientsRequires recipients:create. For more than a handful of rows use an import instead.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
listId | uuid | yes | Target list, must be yours |
name | string | yes | 1–200 characters |
street | string | yes | 1–255 characters |
postCode | string | yes | 1–20 characters |
postTown | string | yes | 1–100 characters |
country | string | yes | 1–100 characters |
customFields | object | no | fieldKey → { label, value }. Max 16 entries. Keys are alphanumeric + underscore, ≤64 chars; values ≤512 chars |
customFieldPolicies | object | no | fieldKey → { kind, value? } where kind is skip, fail or default. Only honoured on the first add to an unlocked list, where it defines the schema. Ignored afterwards |
bash
curl -X POST https://api.utskick.dr.se/api/v1/recipients \
-H "x-api-key: drse_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"listId": "550e8400-e29b-41d4-a716-446655440001",
"name": "Alice Andersson",
"street": "1 First St",
"postCode": "111 11",
"postTown": "Stockholm",
"country": "SE",
"customFields": { "vip": { "label": "VIP", "value": "yes" } }
}'ts
const res = await fetch("https://api.utskick.dr.se/api/v1/recipients", {
method: "POST",
headers: {
"x-api-key": process.env.DRSE_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
listId: "550e8400-e29b-41d4-a716-446655440001",
name: "Alice Andersson",
street: "1 First St",
postCode: "111 11",
postTown: "Stockholm",
country: "SE",
customFields: { vip: { label: "VIP", value: "yes" } },
}),
});
const body = await res.json();
if (res.status === 422) console.error(body.error.details.issues);201 Created — the stored recipient, with any schema defaults filled in.
json
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440004",
"name": "Alice Andersson",
"street": "1 First St",
"postCode": "111 11",
"postTown": "Stockholm",
"country": "SE",
"customFields": { "vip": { "label": "VIP", "value": "yes" } },
"listId": "550e8400-e29b-41d4-a716-446655440001",
"userId": "user_2abc",
"createdAt": "2026-07-25T09:10:00.000Z",
"updatedAt": "2026-07-25T09:10:00.000Z"
}
}Failures
| Status | code | When |
|---|---|---|
| 400 | validation_failed | A required field is missing or over its length limit; a custom-field key is not alphanumeric; more than 16 custom fields |
| 401 | unauthorized | Key problem |
| 403 | forbidden | Key lacks recipients:create |
| 404 | not_found | List not found |
| 422 | recipient_validation_failed | Message Schema validation failed. details.issues explains: a custom field not in the locked schema (unexpected_field), or a schema field this recipient omits with no default (missing_field) |
Empty means absent
A custom field sent as "" is treated as missing, exactly as an import treats an empty cell. If the locked schema has no default for it, the add is rejected rather than silently storing a blank.
Import recipients from a CSV file
POST
/api/v1/recipients/import/csvBulk-import into an existing list. Requires recipients:import. multipart/form-data only.
Parts
| Part | Type | Notes |
|---|---|---|
file | file | The CSV. Max 5 MB. Must look like a CSV by extension or MIME type |
config | JSON string | Target list and column mapping, below |
config
| Field | Type | Required | Notes |
|---|---|---|---|
listId | uuid | yes | Target list, must be yours |
columnMapping | array | yes | One entry per column you care about |
countrySource | "csv" | "fixed" | yes | Read country per row, or apply one value to all |
fixedCountry | string | when countrySource is fixed | Non-empty |
Each columnMapping entry:
| Field | Type | Notes |
|---|---|---|
csvHeader | string | The column's header text (labelling only) |
columnIndex | integer | 0-based position in the row. This is what actually selects the column |
target | enum | name, street, postCode, postTown, country, customField or skip |
customFieldKey | string | Required when target is customField |
customFieldLabel | string | Human label; defaults to the key |
customFieldMissingValuePolicy | object | { kind, value? }. Only meaningful on the first import into an unlocked list |
name, street, postCode and postTown must all be mapped. The header row is detected and skipped automatically.
bash
curl -X POST https://api.utskick.dr.se/api/v1/recipients/import/csv \
-H "x-api-key: drse_your_api_key" \
-F "file=@recipients.csv" \
-F 'config={
"listId": "550e8400-e29b-41d4-a716-446655440001",
"countrySource": "fixed",
"fixedCountry": "SE",
"columnMapping": [
{ "csvHeader": "Name", "columnIndex": 0, "target": "name" },
{ "csvHeader": "Street", "columnIndex": 1, "target": "street" },
{ "csvHeader": "Post code", "columnIndex": 2, "target": "postCode" },
{ "csvHeader": "Post town", "columnIndex": 3, "target": "postTown" }
]
}'ts
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append(
"file",
new File([await readFile("recipients.csv")], "recipients.csv", {
type: "text/csv",
}),
);
form.append(
"config",
JSON.stringify({
listId: "550e8400-e29b-41d4-a716-446655440001",
countrySource: "fixed",
fixedCountry: "SE",
columnMapping: [
{ csvHeader: "Name", columnIndex: 0, target: "name" },
{ csvHeader: "Street", columnIndex: 1, target: "street" },
{ csvHeader: "Post code", columnIndex: 2, target: "postCode" },
{ csvHeader: "Post town", columnIndex: 3, target: "postTown" },
],
}),
);
const res = await fetch(
"https://api.utskick.dr.se/api/v1/recipients/import/csv",
{ method: "POST", headers: { "x-api-key": process.env.DRSE_API_KEY! }, body: form },
);
const { data } = await res.json();
console.log(`${data.imported} imported, ${data.skipped} skipped`);200 OK — note 200, not 201, because an import is a bulk action rather than the creation of one resource.
json
{ "data": { "imported": 247, "skipped": 3 } }| Field | Notes |
|---|---|
imported | Rows written |
skipped | Rows deliberately dropped — a required standard field was blank, or a custom field was missing with missingBehavior: "skip". Skipped rows are not errors |
A fully-skipped import returns { "imported": 0, "skipped": N } with 200, and leaves an unlocked list unlocked.
Failures
| Status | code | When |
|---|---|---|
| 400 | validation_failed | CSV file is required; Invalid file mime or extension; Import configuration is required; Invalid import configuration JSON; or the config failed validation (details carries the field errors) |
| 401 | unauthorized | Key problem |
| 403 | forbidden | Key lacks recipients:import |
| 404 | not_found | List not found |
| 413 | payload_too_large | File too large. Maximum 5MB. |
| 422 | invalid_mapping | Invalid column mapping — duplicate custom-field keys, a custom mapping with no key, or a key absent from the locked schema. details.issues names each |
| 422 | recipient_validation_failed | Schema validation failed — at least one row failed. Nothing is imported. details.issues lists the rows |
Import recipients from an Excel file
POST
/api/v1/recipients/import/excelIdentical to the CSV import, with three differences. Requires recipients:import.
| CSV | Excel | |
|---|---|---|
| Size limit | 5 MB | 10 MB |
| Accepted | .csv | .xlsx, .xls |
| Missing-file message | CSV file is required | Excel file is required |
Only the first worksheet is read, and row 1 is treated as the header. columnIndex is the 0-based column position in that sheet.
bash
curl -X POST https://api.utskick.dr.se/api/v1/recipients/import/excel \
-H "x-api-key: drse_your_api_key" \
-F "file=@recipients.xlsx" \
-F 'config={
"listId": "550e8400-e29b-41d4-a716-446655440001",
"countrySource": "csv",
"columnMapping": [
{ "csvHeader": "Name", "columnIndex": 0, "target": "name" },
{ "csvHeader": "Street", "columnIndex": 1, "target": "street" },
{ "csvHeader": "Post code", "columnIndex": 2, "target": "postCode" },
{ "csvHeader": "Post town", "columnIndex": 3, "target": "postTown" },
{ "csvHeader": "Country", "columnIndex": 4, "target": "country" }
]
}'ts
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append(
"file",
new File([await readFile("recipients.xlsx")], "recipients.xlsx", {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}),
);
form.append(
"config",
JSON.stringify({
listId: "550e8400-e29b-41d4-a716-446655440001",
countrySource: "csv",
columnMapping: [
{ csvHeader: "Name", columnIndex: 0, target: "name" },
{ csvHeader: "Street", columnIndex: 1, target: "street" },
{ csvHeader: "Post code", columnIndex: 2, target: "postCode" },
{ csvHeader: "Post town", columnIndex: 3, target: "postTown" },
{ csvHeader: "Country", columnIndex: 4, target: "country" },
],
}),
);
const res = await fetch(
"https://api.utskick.dr.se/api/v1/recipients/import/excel",
{ method: "POST", headers: { "x-api-key": process.env.DRSE_API_KEY! }, body: form },
);
const { data } = await res.json();200 OK
json
{ "data": { "imported": 300, "skipped": 0 } }Failures
Same as the CSV import, except 413 reports File too large. Maximum 10MB. and a missing part reports Excel file is required.
A deeper walkthrough of mapping, skipping and row numbering: Recipient files.