Iniciar sesión Crear cuenta
Audiencia

Importación de contactos

Importa contactos desde un CSV, con mapeo de columnas y progreso.

Trae contactos desde una hoja de cálculo: envía un archivo CSV e indica qué columna corresponde a cada campo. La importación se ejecuta en segundo plano, por lotes, y puedes seguir el progreso. Cada equipo puede tener hasta 3 importaciones en curso.

Antes de enviar a una lista nueva, conviene comprobar las direcciones con la verificación de correos: acepta el mismo CSV (source=csv) y, después de la importación, también el segmento o todos los contactos.

#El archivo

  • CSV de hasta 20 MB, con una fila de encabezado. El separador (coma, punto y coma o tabulación) se detecta solo, y el archivo puede estar en UTF-8 (con o sin BOM) o Windows-1252, como los que exporta Excel.
  • Los contactos se identifican por la dirección de correo: los que ya existen se actualizan, los nuevos se crean. Las celdas vacías nunca borran un valor que ya existe.
  • Una dirección de correo repetida en el archivo se ignora después de la primera vez. Las filas con problemas (dirección no válida, valor que no coincide con el tipo de la propiedad) se omiten y aparecen en errors (hasta 20).
  • Las direcciones que están en la lista de supresión del equipo no se convierten en contactos: la fila se ignora (ni crea ni actualiza), cuenta en skipped y en skipped_suppressed, aparece en errors con reason: "suppressed" y no consume el límite de contactos del plan.
  • Si se alcanza el límite de contactos del equipo (el del plan más el de los paquetes del Adicional de contactos, cuando los hay), la importación se detiene, marca limit_reached y lo explica en error. Con Continuar añadiendo contactos por encima del límite del plan activado en Facturación (plan que permite excedente), la importación sigue y los contactos adicionales se cobran por paquete de 1.000.
  • Con webhooks de contact.created o contact.updated, cada contacto importado genera un evento.
  • Una columna mapeada en tags añade etiquetas (nombres separados por coma o punto y coma; las que no existen se crean). Una columna mapeada en external_id guarda el ID de la persona en tu sistema; la fila cuyo external_id ya pertenece a otro contacto se omite.
  • Las automatizaciones no se inician para los contactos importados, a menos que la importación lo pida con trigger_automations: true. En ese caso, cada contacto nuevo inicia las automatizaciones de contacto creado, quien entró en el segmento de la importación inicia las de segmento añadido, y cada etiqueta nueva inicia las de etiqueta añadida, en lotes espaciados para no saturar la cola. automations_triggered cuenta cuántas se iniciaron.
customers.csv
email;name;last_name;plan;customer_since
[email protected];Ana;Souza;pro;18/03/2024
[email protected];Bruno;Lima;free;02/11/2025

#Importar un CSV

POST/contacts/imports

Clave de acceso total

Envíalo como multipart/form-data. Postal comprueba al instante que las columnas del mapping existan en el encabezado, guarda el archivo y pone la importación en la cola. Sigue el progreso con GET /contacts/imports/{id}.

Cuerpo de la solicitud (multipart/form-data)

  • filearchivoobligatorio
    El CSV, hasta 20 MB.
  • mappingobject (JSON)obligatorio
    Qué columna del encabezado va a cada campo. Puede enviarse como texto JSON.
    • emailstringobligatorio
      Columna de las direcciones de correo.
    • first_namestring
      Columna del nombre.
    • last_namestring
      Columna del apellido.
    • external_idstring
      Columna con el ID de la persona en tu sistema.
    • tagsstring
      Columna con nombres de etiquetas, separados por coma o punto y coma.
    • unsubscribedstring
      Columna que indica baja (valores como sim/não, true/false).
    • propertiesobject
      Hasta 100 pares chave da propriedade → coluna. Las propiedades deben existir.
  • consent_sourcestringobligatorio
    De dónde vinieron los contactos: website_form (formulario del sitio), product_signup (clientes o usuarios de tu producto), in_person (evento o atención presencial) u other.
  • consent_notestring
    Cuenta de dónde vinieron, hasta 500 caracteres. Obligatorio cuando consent_source es other.
  • consent_confirmedbooleanobligatorio
    Debe ser true: confirma que los contactos pidieron recibir tus correos y que la lista no fue comprada, alquilada ni recopilada de internet. Postal guarda la confirmación con la fecha y la IP.
  • segment_idstring
    Segmento estático donde colocar todos los contactos importados.
  • topicsobject[] (JSON)
    Elecciones de temas para todos los contactos importados: [{"id": "…", "subscription": "opt_in"}].
  • trigger_automationsboolean
    Inicia las automatizaciones que iniciarían los contactos importados si se crearan por la API. Predeterminado: false.

Errores

  • 422validation_error
    Archivo ausente, demasiado grande o que no es CSV; archivo vacío; una columna del mapping que no está en el encabezado; propiedad desconocida; falta consent_source o consent_confirmed.
  • 422validation_error
    El segmento es dinámico: solo los segmentos estáticos reciben contactos importados.
  • 404not_found
    El segmento o algún tema no existe.
  • 429rate_limit_exceeded
    Ya hay 3 importaciones en curso.
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);
201Respuesta
{
  "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
}

#Listar importaciones

GET/contacts/imports

Clave de acceso total

Parámetros de consulta

  • limitinteger
    De 1 a 100. Predeterminado: 20.
  • afterstring
    Cursor de la página siguiente.
  • beforestring
    Cursor de la página anterior.
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);
200Respuesta
{
  "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": "joao@exemplo",
          "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"
    }
  ]
}

#Obtener una importación

GET/contacts/imports/{id}

Clave de acceso total

Muestra el progreso: rows filas leídas de total_rows, cuántos contactos se crearon (created), actualizaron (updated) y omitieron (skipped, de los cuales skipped_suppressed son direcciones de la lista de supresión), y los primeros errores por fila. status: pending, processing, completed o failed (con error).

Parámetros de ruta

  • idstringobligatorio
    ID de la importación.

Errores

  • 404not_found
    El recurso no existe o es de otro equipo.
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);
200Respuesta
{
  "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": "joao@exemplo",
      "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"
}