Skip to content

Create a contact import

Upload a CSV and import its rows as contacts, in the background.

http
POST /v1/contacts/imports

Requires a Full access API key.

This is asynchronous

The import does not run during the request. Create returns an id immediately with status queued; the file is processed on a queue, and you poll Retrieve a contact import for its status and counts. A large file that would time out an HTTP request finishes in the background instead.

Body parameters

This endpoint takes multipart/form-data, not JSON. The object and array fields (column_map, segments, topics) must be sent as JSON-encoded strings inside the form — a -F 'column_map={…}', not a nested object.

FieldTypeRequiredDescription
filefileYesThe CSV, sent as a form field named file. Must have a header row. .csv or .txt, up to 25 MB.
column_mapstring (JSON)NoMaps CSV headers to contact fields — see below. Omit it to slug-match automatically.
on_conflictstringNoupsert (default) or skip — what to do when a row's email already exists.
segmentsstring (JSON)NoArray of { "id" }. Every imported contact is added to these segments.
topicsstring (JSON)NoArray of { "id", "subscription" } (opt_in/opt_out). Applied to every imported contact. See topics.

The column map

column_map names, for each contact field, the exact CSV header that holds it:

json
{
  "email": "Email",
  "first_name": "First Name",
  "last_name": "Last Name",
  "unsubscribed": "Unsubscribed",
  "properties": {
    "plan": { "column": "Plan Tier", "type": "string" }
  }
}

properties maps each of your contact property keys to the CSV column that fills it.

Without a column map, headers are matched by slug: a First Name column maps to first_name, Email to email, and so on. A well-formed CSV needs no map at all.

on_conflict

A row whose email already belongs to a contact on your team is handled by on_conflict:

  • upsert (default) — update the existing contact from the row (counts as updated).
  • skip — leave the existing contact exactly as it is (counts as skipped).

Example

bash
curl -X POST 'https://api.sendgrail.com/v1/contacts/imports' \
  -H "Authorization: Bearer $SENDGRAIL_API_KEY" \
  -F 'file=@contacts.csv;type=text/csv' \
  -F 'column_map={"email":"Email","first_name":"First Name","properties":{"plan":{"column":"Plan","type":"string"}}}' \
  -F 'on_conflict=upsert' \
  -F 'segments=[{"id":"78e7a5c6-9a91-4c63-9d1f-3b9c0b5b9ab6"}]' \
  -F 'topics=[{"id":"059ac693-2fc8-4c13-8b27-01350d638a17","subscription":"opt_in"}]'

Response

json
{
  "object": "contact_import",
  "id": "479e3145-dd38-476b-932c-529ceb705947"
}

Poll GET /v1/contacts/imports/{id} until its status is completed (or failed).

Rows that fail don't stop the import

Every row's email must be present and valid. A row with a missing or malformed email is counted as failed and skipped — the rest of the file still imports. You see the tally in the import's counts when it finishes; the import as a whole only reports failed status if the file itself could not be read (no header row, unreadable upload).

A malformed field is a 422

column_map, segments and topics must be valid JSON strings. A value that doesn't parse is rejected before anything is queued:

json
{
  "message": "The column_map field must be a JSON-encoded object or array.",
  "code": "validation_error",
  "request_id": "8f2c1e64-1b17-4f8c-9f0e-3b6c1e0f5c31"
}

The file is not kept

Once the import has been processed, the uploaded CSV is deleted — only the import record and its counts remain. There is no endpoint to download the original file back, so keep your own copy if you need one.

Transactional email on your own domain.