Import contacts from CSV
Import contacts from a CSV file, with column mapping and progress.
Bring in contacts from a spreadsheet: upload a CSV file and tell Postal which column matches each field. The import runs in the background, in batches, and you can follow its progress. Each team can have up to 3 imports running at a time.
Before sending to a new list, it is worth checking the addresses with email verification: it accepts the same CSV (source=csv) and, after the import, the segment or all contacts too.
#The file
- A CSV of up to 20 MB, with a header row. The delimiter (comma, semicolon or tab) is detected automatically, and the file can be UTF-8 (with or without a BOM) or Windows-1252, like the ones Excel exports.
- Contacts are matched by email address: those that already exist are updated, new ones are created. Empty cells never erase a value that already exists.
- An email address repeated in the file is ignored after the first time. Rows with problems (invalid email address, a value that does not match the property's type) are skipped and listed in
errors(up to 20). - Addresses on the team's suppression list do not become contacts: the row is ignored (it neither creates nor updates), counts in
skippedand inskipped_suppressed, is listed inerrorswithreason: "suppressed", and does not use the plan's contact limit. - If the team's contact limit is reached (the plan's limit plus the packs of the Contacts add-on, when there are any), the import stops, sets
limit_reachedand explains why inerror. With Keep adding contacts above the plan limit turned on under Billing (on a plan that allows overage), the import goes on and the extra contacts are charged per pack of 1,000. - With
contact.createdorcontact.updatedwebhooks, each imported contact generates an event. - A column mapped to
tagsadds tags (names separated by commas or semicolons; the ones that do not exist are created). A column mapped toexternal_idsaves the person's ID in your system; a row whoseexternal_idalready belongs to another contact is skipped. - Automations do not start for imported contacts unless the import asks for it with
trigger_automations: true. Then each new contact starts the contact created automations, anyone who joined the import's segment starts the segment added ones, and each new tag starts the tag added ones, in spaced-out batches so the queue is not flooded.automations_triggeredcounts how many were started.
email,name,last_name,plan,customer_since
[email protected],Jane,Doe,pro,2024-03-18
[email protected],John,Smith,free,2025-11-02
#Import a CSV
POST/contacts/imports
Send it as multipart/form-data. Postal immediately checks that the columns in mapping exist in the header, stores the file and queues the import. Follow it with GET /contacts/imports/{id}.
Request body (multipart/form-data)
filefilerequiredThe CSV, up to 20 MB.mappingobject (JSON)requiredWhich header column goes to each field. It can be sent as JSON text.emailstringrequiredColumn with the email addresses.first_namestringColumn with the first name.last_namestringColumn with the last name.external_idstringColumn with the person's ID in your system.tagsstringColumn with tag names, separated by commas or semicolons.unsubscribedstringColumn that marks an unsubscribe (values such assim/não,true/false).propertiesobjectUp to 100property key → columnpairs. The properties must already exist.
consent_sourcestringrequiredWhere the contacts came from:website_form(a form on your site),product_signup(customers or users of your product),in_person(an event or in person) orother.consent_notestringWhere they came from, up to 500 characters. Required whenconsent_sourceisother.consent_confirmedbooleanrequiredMust betrue: confirms that the contacts asked to receive your emails and that the list was not bought, rented or scraped from the internet. Postal keeps the confirmation with its date and IP address.segment_idstringStatic segment to put all imported contacts in.topicsobject[] (JSON)Topic choices for all imported contacts:[{"id": "…", "subscription": "opt_in"}].trigger_automationsbooleanStarts the automations the imported contacts would start if they were created through the API. Default:false.
Errors
- 422
validation_errorMissing file, file too large or not a CSV; empty file; amappingcolumn that is not in the header; unknown property; missingconsent_sourceorconsent_confirmed. - 422
validation_errorThe segment is dynamic: only static segments receive imported contacts. - 404
not_foundThe segment or a topic does not exist. - 429
rate_limit_exceededThere are already 3 imports running.
cURL
curl -X POST https://api.postal.so/contacts/imports \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-F "[email protected]" \
-F 'mapping={"email": "email", "first_name": "name", "last_name": "last_name", "properties": {"plan": "plan", "customer_since": "customer_since"}}' \
-F "consent_source=website_form" -F "consent_confirmed=true" -F "segment_id=019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a" \
-F 'topics=[{"id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b", "subscription": "opt_in"}]'
Node.js
import { readFile } from 'node:fs/promises';
const form = new FormData();
form.append('file', new Blob([await readFile('customers.csv')]), 'customers.csv');
form.append('mapping', JSON.stringify({
email: 'email',
first_name: 'name',
last_name: 'last_name',
properties: { plan: 'plan', customer_since: 'customer_since' },
}));
form.append('consent_source', 'website_form');
form.append('consent_confirmed', 'true');
form.append('segment_id', '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a');
const response = await fetch('https://api.postal.so/contacts/imports', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.POSTAL_API_KEY}` },
body: form,
});
const contactImport = await response.json();
Python
import json
import os
import requests
with open("customers.csv", "rb") as csv_file:
response = requests.post(
"https://api.postal.so/contacts/imports",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
files={"file": ("customers.csv", csv_file, "text/csv")},
data={
"mapping": json.dumps({
"email": "email",
"first_name": "name",
"last_name": "last_name",
"properties": {"plan": "plan", "customer_since": "customer_since"},
}),
"consent_source": "website_form",
"consent_confirmed": "true",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
},
timeout=60,
)
contact_import = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('contacts/imports', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'multipart' => [
['name' => 'file', 'contents' => fopen('customers.csv', 'r'), 'filename' => 'customers.csv'],
['name' => 'mapping', 'contents' => json_encode([
'email' => 'email',
'first_name' => 'name',
'last_name' => 'last_name',
'properties' => ['plan' => 'plan', 'customer_since' => 'customer_since'],
])],
['name' => 'consent_source', 'contents' => 'website_form'],
['name' => 'consent_confirmed', 'contents' => 'true'],
['name' => 'segment_id', 'contents' => '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a'],
],
]);
$contactImport = json_decode($response->getBody(), true);
{
"object": "contact_import",
"id": "019a1e10-6e7f-7a90-9b0c-1d2e3f4a5b6c",
"filename": "customers.csv",
"status": "pending",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"total_rows": null,
"rows": 0,
"created": 0,
"updated": 0,
"skipped": 0,
"skipped_suppressed": 0,
"errors": [],
"limit_reached": false,
"trigger_automations": false,
"automations_triggered": 0,
"consent_source": "website_form",
"consent_note": null,
"consent_confirmed_at": "2026-10-24T14:10:00.000000Z",
"error": null,
"created_at": "2026-10-24T14:10:00.000000Z",
"started_at": null,
"completed_at": null
}
#List imports
GET/contacts/imports
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.
cURL
curl https://api.postal.so/contacts/imports \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts/imports', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/contacts/imports",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('contacts/imports', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "contact_import",
"id": "019a1e10-6e7f-7a90-9b0c-1d2e3f4a5b6c",
"filename": "customers.csv",
"status": "completed",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"total_rows": 1500,
"rows": 1500,
"created": 1320,
"updated": 172,
"skipped": 8,
"skipped_suppressed": 3,
"errors": [
{
"row": 37,
"email": "mary@example",
"message": "The email is not valid."
},
{
"row": 52,
"email": "[email protected]",
"message": "The address is in your suppression list.",
"reason": "suppressed"
}
],
"limit_reached": false,
"trigger_automations": false,
"automations_triggered": 0,
"consent_source": "website_form",
"consent_note": null,
"consent_confirmed_at": "2026-10-24T14:10:00.000000Z",
"error": null,
"created_at": "2026-10-24T14:10:00.000000Z",
"started_at": "2026-10-24T14:10:01.000000Z",
"completed_at": "2026-10-24T14:10:19.000000Z"
}
]
}
#Get an import
GET/contacts/imports/{id}
Shows the progress: rows rows read out of total_rows, how many contacts were created (created), updated (updated) and skipped (skipped, of which skipped_suppressed are addresses on the suppression list), and the first errors by row. status: pending, processing, completed or failed (with error).
Path parameters
idstringrequiredImport ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/contacts/imports/019a1e10-6e7f-7a90-9b0c-1d2e3f4a5b6c \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/contacts/imports/019a1e10-6e7f-7a90-9b0c-1d2e3f4a5b6c', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/contacts/imports/019a1e10-6e7f-7a90-9b0c-1d2e3f4a5b6c",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('contacts/imports/019a1e10-6e7f-7a90-9b0c-1d2e3f4a5b6c', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "contact_import",
"id": "019a1e10-6e7f-7a90-9b0c-1d2e3f4a5b6c",
"filename": "customers.csv",
"status": "completed",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"total_rows": 1500,
"rows": 1500,
"created": 1320,
"updated": 172,
"skipped": 8,
"skipped_suppressed": 3,
"errors": [
{
"row": 37,
"email": "mary@example",
"message": "The email is not valid."
},
{
"row": 52,
"email": "[email protected]",
"message": "The address is in your suppression list.",
"reason": "suppressed"
}
],
"limit_reached": false,
"trigger_automations": false,
"automations_triggered": 0,
"consent_source": "website_form",
"consent_note": null,
"consent_confirmed_at": "2026-10-24T14:10:00.000000Z",
"error": null,
"created_at": "2026-10-24T14:10:00.000000Z",
"started_at": "2026-10-24T14:10:01.000000Z",
"completed_at": "2026-10-24T14:10:19.000000Z"
}