Appearance
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
| Status | code / message | Fix |
|---|---|---|
| 400 | CSV file is required / Excel file is required | The part must be named file |
| 400 | Invalid file mime or extension | Wrong type for the endpoint |
| 400 | Import configuration is required / Invalid import configuration JSON | config missing or not valid JSON |
| 400 | validation_failed | A required target unmapped, or fixedCountry empty with countrySource: "fixed" |
| 404 | List not found | Wrong listId, or a list in another account |
| 413 | File too large. Maximum 5MB. / Maximum 10MB. | Split the file |
| 422 | invalid_mapping | A key not in the locked schema, duplicate keys, or a customField column with no key |
| 422 | recipient_validation_failed | A row failed. Nothing imported |
A fully-skipped import leaves an unlocked list still unlocked, so you can retry with a corrected mapping.
Next
- Recipient files — the mapping model
- Lists & recipients — schema locking, retention
- Campaign from a saved list