Skip to content

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.

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:

Terminal window
cat > contacts.csv <<'EOF'
email,field.FNAME,field.plan,tags,consent_source,consent_at,status
ada.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,,,,,unsubscribed
no-consent@example.com,Nobody,,,,,
not-an-address,,,,,,
EOF
Terminal window
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:

Terminal window
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").

The upload starts the dry run, usually within seconds.

Terminal window
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:

Terminal window
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.

Terminal window
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.