API Reference
API Reference
GitHub
Subscribers

Bulk Import

Import subscribers from JSON and CSV

Two routes take a batch of subscribers. They differ only in how you hand over the rows — the behaviour after that is identical, down to the response.

Import means upsert, not insert

A row whose address already exists in the project updates that subscriber rather than being skipped. The stored id, created_at and subscribed_at are kept, and a row that leaves status empty keeps the status the subscriber already had — so re-importing a list does not resurrect people who unsubscribed since the last run.

That is the behaviour you want for a nightly sync and the behaviour to be careful with for a one-off file: a blank name column overwrites the name you have.

Both routes are project-scoped and need the subscribers:write permission.

JSON

POST /api/v1/subscribers/import

Between 1 and 10,000 subscribers per call.

curl -X POST http://localhost:3000/api/v1/subscribers/import \
  -H "Authorization: Bearer myk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "subscribers": [
      {
        "email": "alice@example.com",
        "name": "Alice",
        "timezone": "Europe/London",
        "language": "en",
        "custom_fields": {"company": "Acme", "plan": "pro"}
      },
      { "email": "bob@example.com", "name": "Bob", "language": "fr" }
    ]
  }'

Only email is required per row. status must be one of subscribed, unsubscribed, bounced or complained, and custom_fields holds at most 50 keys. Addresses are trimmed and lowercased on the way in, so Alice@Example.com and alice@example.com are one subscriber rather than two.

CSV

POST /api/v1/subscribers/import/csv

Send the CSV as the raw request body

This is not a file upload. There is no multipart form, no file field, and no column-mapping parameter — post the CSV text itself and let the header row name the columns.

curl -X POST http://localhost:3000/api/v1/subscribers/import/csv \
  -H "Authorization: Bearer myk_..." \
  -H "Content-Type: text/csv" \
  --data-binary @subscribers.csv
email,name,language,company,plan
alice@example.com,Alice,en,Acme,pro
bob@example.com,Bob,fr,Globex,free

Five header names are recognised, matched case-insensitively after trimming:

HeaderGoes to
emailThe address. Required — a file without this column is refused
nameThe display name
statusOne of the four statuses, lowercased
timezoneIANA zone name
languageLocale code, lowercased

Every other column becomes a custom field, keyed by its header. That is the whole mapping mechanism: rename the column and you rename the field. An empty cell is skipped rather than stored as an empty string, so a sparse column does not litter every subscriber with blanks.

The same 10,000-row ceiling applies, counted over data rows.

A malformed file is refused whole, before anything is written:

ResponseCause
csv is empty or unreadableNo header row could be read
csv header must include an email columnNo column named email
csv parse error at line NUnbalanced quoting or a ragged row
csv has no data rowsHeader only
csv exceeds the 10000 row import limitToo many rows

What comes back

Both routes answer 200 with the same report:

{
  "created": 1240,
  "updated": 61,
  "skipped": 2,
  "errors": [
    { "index": 88,  "email": "not-an-address", "error": "invalid email" },
    { "index": 903, "email": "dana@example.com", "error": "unknown status pending" }
  ]
}

A bad row is reported and passed over — it does not sink the rest of the file, because one typo in a ten thousand row export should not cost you the other nine thousand nine hundred and ninety nine. skipped is exactly the length of errors, and index is the row’s position in what you sent, counting from zero.

The plan limit is checked once, up front

The whole batch is measured against the project’s subscriber cap before any row is written. Over it, the call is refused with 429 and nothing is imported — you do not get a partially applied file to reconcile.