Appearance
Campaign from a CSV or Excel file
Upload a spreadsheet and send in one call. The rows are imported into a new list and the campaign starts.
Permissions needed: templates:read, campaigns:create, campaigns:read.
Multipart only — a file part cannot be expressed in JSON.
The file
csv
Full name,Address,Zip,City,Segment
Alice Andersson,1 First St,111 11,Stockholm,pro
Bo Bergström,2 Second Rd,222 22,Göteborg,standardRow 1 is treated as headers and is never imported.
The mapping
Columns are selected by 0-based index. csvHeader is a label only.
json
{
"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" }
]
}Note there is no listId — unlike the standalone import, the list is created for you.
name, street, postCode and postTown must all be mapped. Full model: Recipient files.
The call
bash
curl -X POST "$DRSE_BASE_URL/api/v1/campaigns" \
-H "x-api-key: $DRSE_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-F "name=Segment mailing — July" \
-F "templateId=550e8400-e29b-41d4-a716-446655440002" \
-F "recipientsFile=@customers.csv;type=text/csv" \
-F "fileFormat=csv" \
-F 'mapping={
"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" }
]
}'ts
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("name", "Segment mailing — July");
form.append("templateId", "550e8400-e29b-41d4-a716-446655440002");
form.append(
"recipientsFile",
new File([await readFile("customers.csv")], "customers.csv", {
type: "text/csv",
}),
);
form.append("fileFormat", "csv");
form.append(
"mapping",
JSON.stringify({
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",
},
],
}),
);
const res = await fetch(`${BASE}/api/v1/campaigns`, {
method: "POST",
headers: {
"x-api-key": KEY,
"Idempotency-Key": idempotencyKey,
// Do NOT set Content-Type — fetch adds the multipart boundary itself.
},
body: form,
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
console.log(body.data.import); // { imported: 2, skipped: 0 }Never set Content-Type by hand on a multipart request
The boundary is generated with the body. Setting multipart/form-data yourself omits it and the request fails to parse.
201 Created
json
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Segment mailing — July",
"status": "pending",
"progress": 0,
"listId": "550e8400-e29b-41d4-a716-446655440001",
"listName": "Segment mailing — July",
"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": 248, "skipped": 2 }
}
}Always check import
skipped counts rows deliberately dropped — a blank required field, or a missing custom field whose policy is skip. They are not errors and do not fail the call. A campaign that mails 248 of 250 letters looks identical to a complete one unless you compare the numbers.
ts
const expected = countDataRows(csv);
const { imported, skipped } = body.data.import;
if (imported !== expected) {
console.warn(`Expected ${expected}, imported ${imported} (${skipped} skipped)`);
}Always send fileFormat
Detection uses the filename and MIME type, so a generic application/octet-stream or an odd filename fails with 400 Could not detect the recipients file format — pass fileFormat: "csv" or "excel". Passing it explicitly removes the guesswork.
Excel
Same call with fileFormat=excel and the file part:
bash
-F "recipientsFile=@customers.xlsx;type=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" \
-F "fileFormat=excel" \Only the first worksheet is read, and row 1 is the header. The size cap is 10 MB rather than 5 MB.
Missing-value policies
Set behaviour per custom-field column while the schema is being defined:
json
{ "csvHeader": "Segment", "columnIndex": 4, "target": "customField",
"customFieldKey": "segment", "customFieldLabel": "Segment",
"customFieldMissingValuePolicy": { "kind": "default", "value": "standard" } }| Policy | If the cell is empty |
|---|---|
{ "kind": "skip" } | Drop the row (default) |
{ "kind": "fail" } | Reject the import with 422 |
{ "kind": "default", "value": "..." } | Substitute the value |
Failure modes
| Status | code / message | Fix |
|---|---|---|
| 400 | Could not detect the recipients file format... | Pass fileFormat |
| 400 | validation_failed | mapping missing or invalid; a required target unmapped; mapping sent without a file |
| 404 | Template not found | Wrong templateId |
| 413 | File too large. Maximum 5MB. / Maximum 10MB. | Split the file, or import into a list in batches |
| 413 | Request body too large. Maximum 40MB. | The whole multipart body |
| 422 | invalid_mapping | Duplicate custom-field keys, or a customField column with no key |
| 422 | recipient_validation_failed | A row failed. Nothing was created |
| 422 | list_empty | Every row was skipped — almost always a wrong columnIndex |
list_empty here, 200 there
The same fully-skipped file sent to POST /api/v1/recipients/import/csv returns 200 with imported: 0. A campaign cannot exist without a recipient, so here it is a hard error. If you hit it, your column indexes are almost certainly off by one.
Testing a mapping without mailing
Import into a throwaway list first, check the counts, then send:
ts
// 1. Create a scratch list, import, inspect.
const list = await createList("mapping-test");
const result = await importCsv(list.id, csv, mapping);
console.log(result); // { imported: 250, skipped: 0 } — mapping is right
// 2. Now send for real, either with this list or with recipientsFile.Costs two extra calls and rules out the most expensive class of mistake. See Import into a list.
Next
- Recipient files — mapping in depth
- Import into a list
- Polling & failures