CSV import
A CSV import works like a Mailchimp import: upload, read the dry run, commit. The file goes straight to the imports bucket in your own account through a presigned link, and it’s deleted after 7 days. It never passes through our account: when it lands, your bucket tells EventBridge, your stack forwards that notice (the bucket and file name only) to us, and our importer reads the file from your bucket in memory.
Prepare the file
Section titled “Prepare the file”The first row names the columns. Only email is required.
| Column | Contents |
|---|---|
email |
The address |
consent_source, consent_at |
Where and when the contact agreed. Needed for subscribed rows, unless you attest to consent for the whole file. |
status |
subscribed (the default), unsubscribed, bounced, complained or cleaned |
tags |
Tags, separated by ; |
field.<name> |
A custom field, for example field.FNAME |
The file can use commas inside quotes, quotes doubled inside quotes, LF or CRLF line endings, and a byte order mark. For example:
cat > contacts.csv <<'EOF'email,field.FNAME,field.plan,tags,consent_source,consent_at,statusada.l@example.com,Ada,pro,customer;beta,signup-form,2026-03-01T10:00:00Z,charles@example.com,Charles,free,,signup-form,2026-04-12T08:30:00Z,mary@example.com,Mary,,,,,unsubscribedno-consent@example.com,Nobody,,,,,not-an-address,,,,,,EOFStart the import and upload
Section titled “Start the import and upload”curl -X POST "$BASE/imports/csv" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{}'The response has an uploadUrl, a presigned link to your own imports bucket, and uploadUrlExpiresAt, when it stops working (at most an hour). Upload the file to it:
curl -X PUT -T contacts.csv -H 'Content-Type: text/csv' "<uploadUrl>"If every subscribed row consented the same way and your file has no consent columns, attest to it when you start the import instead: {"consentAttestation":{"source":"customer list, signed up at checkout"}}. Rows without their own consent get it, with method attested, and at is the time you started the import unless you give one ("at": "2026-03-01T00:00:00Z").
Read the dry run and commit
Section titled “Read the dry run and commit”The upload starts the dry run, usually within seconds.
curl "$BASE/imports/<im_...>" -H "Authorization: Bearer $KEY"When state is dry_run_ready, read the summary. read counts the rows in the file, not counting the header; toImport is the contacts that will be written; and skipped.invalid_row counts every rejected row. errors gives each rejected row’s reason by number, without its values, for the first 1,000. In the file above, row 5 has no consent and row 6 isn’t an address. A row whose address has non-ASCII characters before the @ is rejected because Amazon SES can’t send to it. If the header has no email column, no row can be read: every row is rejected, and errors says why. If the rest looks right, commit:
curl -X POST "$BASE/imports/<im_...>/commit" -H "Authorization: Bearer $KEY" -H 'Idempotency-Key: csv-commit-1'Check it worked: the import’s state becomes completed, and the contacts are listed.
curl "$BASE/imports/<im_...>" -H "Authorization: Bearer $KEY"curl "$BASE/contacts?tag=beta" -H "Authorization: Bearer $KEY"The same rules as other imports apply: an address in the file twice comes across once with the most restrictive status, suppressed contacts stay suppressed, and erased addresses are never imported. To fix a mistake before you commit, cancel with DELETE $BASE/imports/<id> and start again.