Skip to content

Import into a list ​

Build a reusable audience from a spreadsheet, then send to it repeatedly.

Permissions needed: lists:create, lists:read, recipients:import.

1. Create the list ​

bash
curl -X POST "$DRSE_BASE_URL/api/v1/lists" \
  -H "x-api-key: $DRSE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Customers — Sweden" }'
ts
const res = await fetch(`${BASE}/api/v1/lists`, {
  method: "POST",
  headers: { "x-api-key": KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Customers — Sweden" }),
});
const { data: list } = await res.json();

201 Created — note schemaLockedAt: null: the custom-field schema is not fixed yet.

json
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440001",
    "name": "Customers — Sweden",
    "userId": "user_2abc",
    "schemaLockedAt": null,
    "lastActivityAt": "2026-07-25T09:00:00.000Z",
    "createdAt": "2026-07-25T09:00:00.000Z",
    "updatedAt": "2026-07-25T09:00:00.000Z"
  }
}

Reuse the list, don't recreate it

Nothing stops two lists sharing a name. Store the id after the first run.

2. Check the schema ​

Skip for a new list; essential before importing into an existing one.

bash
curl -s -H "x-api-key: $DRSE_API_KEY" \
  "$DRSE_BASE_URL/api/v1/lists/550e8400-e29b-41d4-a716-446655440001/schema"
ts
const res = await fetch(`${BASE}/api/v1/lists/${listId}/schema`, {
  headers: { "x-api-key": KEY },
});
const { data: schema } = await res.json();

if (schema.status === "locked") {
  const allowed = new Set(schema.fields.map((f) => f.fieldKey));
  const mapped = mapping.columnMapping
    .filter((m) => m.target === "customField")
    .map((m) => m.customFieldKey);

  const rejected = mapped.filter((k) => !allowed.has(k));
  if (rejected.length) {
    throw new Error(`These keys are not in the list schema: ${rejected.join(", ")}`);
  }
}

An unlocked list accepts any custom fields — the first import defines them. A locked list accepts only its existing keys; anything else is 422 invalid_mapping.

3. Import the file ​

The config here does include listId (unlike campaign create).

bash
curl -X POST "$DRSE_BASE_URL/api/v1/recipients/import/csv" \
  -H "x-api-key: $DRSE_API_KEY" \
  -F "file=@customers.csv" \
  -F 'config={
    "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",
        "customFieldMissingValuePolicy": { "kind": "default", "value": "standard" } }
    ]
  }'
ts
import { readFile } from "node:fs/promises";

const form = new FormData();
form.append(
  "file",
  new File([await readFile("customers.csv")], "customers.csv", {
    type: "text/csv",
  }),
);
form.append(
  "config",
  JSON.stringify({
    listId,
    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",
        customFieldMissingValuePolicy: { kind: "default", value: "standard" },
      },
    ],
  }),
);

const res = await fetch(`${BASE}/api/v1/recipients/import/csv`, {
  method: "POST",
  headers: { "x-api-key": KEY },
  body: form,
});

const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
console.log(body.data); // { imported: 248, skipped: 2 }

200 OK — 200, not 201: an import is a bulk action, not one resource.

json
{ "data": { "imported": 248, "skipped": 2 } }

For Excel, use /import/excel with a .xlsx file; the cap rises to 10 MB and only the first worksheet is read.

4. Verify before you send ​

A successful import can import nothing

{ "imported": 0, "skipped": 500 } is a 200. Always compare against what you expected.

ts
const expected = 250;
const { imported, skipped } = body.data;

if (imported !== expected) {
  console.warn(`Expected ${expected}, imported ${imported}, skipped ${skipped}`);
}
if (imported === 0) {
  throw new Error("Nothing imported — check your columnIndex values");
}

Confirm the stored data independently:

ts
const res = await fetch(
  `${BASE}/api/v1/recipients/by-list/${listId}?limit=5`,
  { headers: { "x-api-key": KEY } },
);
const { data, meta } = await res.json();
console.log(`${meta.count} in list`);
console.log(data[0]); // eyeball one record — is the city really in postTown?

Reading one record catches a swapped columnIndex that the counts alone would hide.

5. Send to the list ​

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": "Customer mailing — July",
    "templateId": "550e8400-e29b-41d4-a716-446655440002",
    "listId": "550e8400-e29b-41d4-a716-446655440001"
  }'

See Campaign from a saved list.

Importing again later ​

Imports are additive — they never replace. Re-importing the same file doubles every row, and duplicates mean duplicate letters.

ts
// Before re-importing, know what is already there.
const before = await countRecipients(listId);
await importCsv(listId, file, mapping);
const after = await countRecipients(listId);
console.log(`Added ${after - before}`);

There is no delete endpoint, so a list polluted with duplicates cannot be cleaned via the API — create a fresh list instead. Safer patterns:

  • import only new rows since the last run, or
  • import into a fresh list per send, or
  • keep the audience in your own system and use inline recipients.

Splitting a large file ​

Over the size cap, split and import in sequence into the same list:

ts
for (const chunk of chunks) {
  const result = await importCsv(listId, chunk, mapping);
  console.log(`chunk: ${result.imported} imported, ${result.skipped} skipped`);
}

The first chunk locks the schema; later chunks must conform to it.

Failure modes ​

Statuscode / messageFix
400CSV file is required / Excel file is requiredThe part must be named file
400Invalid file mime or extensionWrong type for the endpoint
400Import configuration is required / Invalid import configuration JSONconfig missing or not valid JSON
400validation_failedA required target unmapped, or fixedCountry empty with countrySource: "fixed"
404List not foundWrong listId, or a list in another account
413File too large. Maximum 5MB. / Maximum 10MB.Split the file
422invalid_mappingA key not in the locked schema, duplicate keys, or a customField column with no key
422recipient_validation_failedA row failed. Nothing imported

A fully-skipped import leaves an unlocked list still unlocked, so you can retry with a corrected mapping.

Next ​