Skip to content

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,standard

Row 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" } }
PolicyIf 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 ​

Statuscode / messageFix
400Could not detect the recipients file format...Pass fileFormat
400validation_failedmapping missing or invalid; a required target unmapped; mapping sent without a file
404Template not foundWrong templateId
413File too large. Maximum 5MB. / Maximum 10MB.Split the file, or import into a list in batches
413Request body too large. Maximum 40MB.The whole multipart body
422invalid_mappingDuplicate custom-field keys, or a customField column with no key
422recipient_validation_failedA row failed. Nothing was created
422list_emptyEvery 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 ​