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, with what the row carries. A field the row leaves empty keeps what the subscriber has: a blank name, timezone, language or status changes nothing, so re-importing a list does not resurrect people who unsubscribed since the last run. custom_fields are laid over the stored ones key by key, so a file carrying one column updates that field and leaves the others alone.

That is the behaviour you want for a nightly sync. To clear a field, edit the subscriber instead.

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, timezone an IANA zone name such as Europe/London, and custom_fields holds at most 50 keys. Each row is checked on its own, and a row that fails is reported in the response rather than refusing the call. 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": "Status must be one of: subscribed, unsubscribed, bounced, complained" }
  ]
}

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.