Sign in Create account
Audience

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 skipped and in skipped_suppressed, is listed in errors with reason: "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_reached and explains why in error. 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.created or contact.updated webhooks, each imported contact generates an event.
  • A column mapped to tags adds tags (names separated by commas or semicolons; the ones that do not exist are created). A column mapped to external_id saves the person's ID in your system; a row whose external_id already 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_triggered counts how many were started.
customers.csv
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

Full-access key

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)

  • filefilerequired
    The CSV, up to 20 MB.
  • mappingobject (JSON)required
    Which header column goes to each field. It can be sent as JSON text.
    • emailstringrequired
      Column with the email addresses.
    • first_namestring
      Column with the first name.
    • last_namestring
      Column with the last name.
    • external_idstring
      Column with the person's ID in your system.
    • tagsstring
      Column with tag names, separated by commas or semicolons.
    • unsubscribedstring
      Column that marks an unsubscribe (values such as sim/não, true/false).
    • propertiesobject
      Up to 100 property key → column pairs. The properties must already exist.
  • consent_sourcestringrequired
    Where 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) or other.
  • consent_notestring
    Where they came from, up to 500 characters. Required when consent_source is other.
  • consent_confirmedbooleanrequired
    Must be true: 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_idstring
    Static segment to put all imported contacts in.
  • topicsobject[] (JSON)
    Topic choices for all imported contacts: [{"id": "…", "subscription": "opt_in"}].
  • trigger_automationsboolean
    Starts the automations the imported contacts would start if they were created through the API. Default: false.

Errors

  • 422validation_error
    Missing file, file too large or not a CSV; empty file; a mapping column that is not in the header; unknown property; missing consent_source or consent_confirmed.
  • 422validation_error
    The segment is dynamic: only static segments receive imported contacts.
  • 404not_found
    The segment or a topic does not exist.
  • 429rate_limit_exceeded
    There are already 3 imports running.
POST /contacts/imports

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);
201Response
{
  "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

Full-access key

Query parameters

  • limitinteger
    From 1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
GET /contacts/imports

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);
200Response
{
  "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}

Full-access key

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

  • idstringrequired
    Import ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /contacts/imports/{id}

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);
200Response
{
  "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"
}