Appearance
Create a contact import
Upload a CSV and import its rows as contacts, in the background.
http
POST /v1/contacts/importsRequires 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.
| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | The CSV, sent as a form field named file. Must have a header row. .csv or .txt, up to 25 MB. |
column_map | string (JSON) | No | Maps CSV headers to contact fields — see below. Omit it to slug-match automatically. |
on_conflict | string | No | upsert (default) or skip — what to do when a row's email already exists. |
segments | string (JSON) | No | Array of { "id" }. Every imported contact is added to these segments. |
topics | string (JSON) | No | Array 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 asupdated).skip— leave the existing contact exactly as it is (counts asskipped).
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.