Appearance
Recipient files
CSV and Excel files are loaded by describing your columns with a mapping. The same mapping model is used by the standalone import endpoints and by recipientsFile on campaign create.
Mapping is by column index
The platform does not guess from headers — you state which column position holds which field.
json
{
"listId": "550e8400-e29b-41d4-a716-446655440001",
"countrySource": "fixed",
"fixedCountry": "SE",
"columnMapping": [
{ "csvHeader": "Full name", "columnIndex": 0, "target": "name" },
{ "csvHeader": "Address", "columnIndex": 1, "target": "street" },
{ "csvHeader": "Zip", "columnIndex": 2, "target": "postCode" },
{ "csvHeader": "City", "columnIndex": 3, "target": "postTown" },
{ "csvHeader": "Segment", "columnIndex": 4, "target": "customField",
"customFieldKey": "segment", "customFieldLabel": "Segment" },
{ "csvHeader": "Internal", "columnIndex": 5, "target": "skip" }
]
}| Field | Notes |
|---|---|
columnIndex | 0-based, and what actually selects the column |
csvHeader | Labelling only. A mismatch with the real header changes nothing |
target | name, street, postCode, postTown, country, customField, skip |
customFieldKey | Required when target is customField |
customFieldLabel | Human label; defaults to the key |
csvHeader is documentation, columnIndex is behaviour
If your export changes column order, updating csvHeader alone silently imports the wrong data into the wrong fields. columnIndex is what to keep correct.
name, street, postCode and postTown must all be mapped, or the config is rejected with 400. Unmentioned columns are ignored; skip only documents intent.
Split name columns
The addressee comes in one of two forms — one full-name column, or a first/last pair:
json
{ "csvHeader": "Förnamn", "columnIndex": 0, "target": "firstName" },
{ "csvHeader": "Efternamn", "columnIndex": 1, "target": "lastName" }A middleName column may sit between them. The parts join with a single space, first, middle, last regardless of column order — a file that leads with the surname column still prints "Anna Andersson". A row missing one part uses the others; empty parts never join as a stray space.
Each part is also kept on the recipient as it came, as the built-in fields fornamn, mellannamn and efternamn — so a letter's is "Garcia Alfageme", not a guess from the joined name. Every list may carry them, whatever custom fields it is locked to; a custom field of your own with the same key takes precedence.
Use name only for a column that holds the full addressee by itself, and do not combine it with firstName/lastName — the two forms are alternatives. The name requirement is satisfied by either.
Split street columns
street may also be mapped by several columns. SPAR exports — the standard source for purchased Swedish address lists — split the postal address over Utdelningsadress1, Utdelningsadress2 and CareOf, any of which may be empty on a given row. Map them all: non-empty parts join in column order, comma-separated, so a plain row prints "Storgatan 1" and a c/o row prints "c/o Eriksson, Storgatan 1". A row whose mapped street columns are all empty is skipped as missing its street.
Country: per row or fixed
countrySource | Behaviour |
|---|---|
"csv" | Read from the column mapped to country |
"fixed" | Apply fixedCountry to every row. fixedCountry must be non-empty |
Use fixed for a single-country send — one less column to keep clean.
The header row is handled for you
Row 1 is treated as headers and never imported, for CSV and Excel alike. Do not skip it yourself with an offset. For Excel, only the first worksheet is read.
Row numbering in errors
details.issues[].row is a 1-based index over data rows, with the header excluded. Row 1 is the first row of real data — spreadsheet row 2.
json
{ "row": 3, "field": "vip", "issue": "missing_field", "message": "..." }That is the 3rd data row, i.e. row 4 in your spreadsheet.
Skipped versus failed
The most important distinction when importing.
| Skipped | Failed | |
|---|---|---|
| Meaning | Row deliberately not imported | Row is invalid |
| Effect | Other rows still import | Nothing imports |
| Reported as | { "imported": N, "skipped": M }, 200 | 422 with details.issues |
A row is skipped when a required standard field is blank, or a custom field is missing with policy skip.
A row fails when it carries a custom field absent from the locked schema, or omits a schema field with policy fail and no default.
json
{ "data": { "imported": 247, "skipped": 3 } }Always read skipped
A 200 with "imported": 0, "skipped": 500 is a successful response that imported nothing. Compare imported against the rows you expected and alert on the difference — a mis-set columnIndex typically presents exactly this way.
Validation happens before any write
Files are validated in full first, then inserted. A file with one bad row imports nothing — there is no partial state to reconcile, and you can safely correct and resend the whole file.
Importing into an existing list
Read the schema first:
ts
const res = await fetch(`${BASE}/api/v1/lists/${listId}/schema`, {
headers: { "x-api-key": KEY },
});
const { data } = await res.json();
if (data.status === "locked") {
const allowed = new Set(data.fields.map((f) => f.fieldKey));
// Every customFieldKey you map must be in `allowed`.
}Mapping a key the locked schema does not have is 422 invalid_mapping:
json
{
"error": {
"code": "invalid_mapping",
"message": "Invalid column mapping",
"details": {
"issues": [
{ "row": 0, "field": "segment", "issue": "unexpected_field",
"message": "Custom field key \"segment\" is not in the list schema" }
]
}
}
}row: 0 marks a mapping-level problem rather than a data row.
Mapping issues also cover duplicate custom-field keys (duplicate_key) and a customField column with no key (missing_required).
Formats and limits
| CSV | Excel | |
|---|---|---|
| Extensions | .csv | .xlsx, .xls |
| Size | 5 MB | 10 MB |
| Endpoint | POST /api/v1/recipients/import/csv | POST /api/v1/recipients/import/excel |
Over the limit is 413 with File too large. Maximum 5MB. or File too large. Maximum 10MB.. Split the file and import in batches — imports are additive.
The format is detected from the filename and MIME type. On campaign create, where that can be ambiguous, pass fileFormat: "csv" | "excel" explicitly. Otherwise:
Could not detect the recipients file format — pass fileFormat: "csv" or "excel" (400)
Import, or send directly?
| Import then send | recipientsFile on campaign create | |
|---|---|---|
| Endpoints | Two calls | One call |
| List | You name and reuse it | Created for you, named after the campaign |
| Fully-skipped file | 200, imported 0 | 422 list_empty — a campaign needs a recipient |
| Best for | Audiences you send to repeatedly | One-off sends |
Next
- Import into a list
- Campaign from a CSV or Excel file
- Lists & recipients — the schema model