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
skippedy enskipped_suppressed, aparece enerrorsconreason: "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_reachedy lo explica enerror. 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.createdocontact.updated, cada contacto importado genera un evento. - Una columna mapeada en
tagsañade etiquetas (nombres separados por coma o punto y coma; las que no existen se crean). Una columna mapeada enexternal_idguarda el ID de la persona en tu sistema; la fila cuyoexternal_idya 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_triggeredcuenta cuántas se iniciaron.
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
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)
filearchivoobligatorioEl CSV, hasta 20 MB.mappingobject (JSON)obligatorioQué columna del encabezado va a cada campo. Puede enviarse como texto JSON.emailstringobligatorioColumna de las direcciones de correo.first_namestringColumna del nombre.last_namestringColumna del apellido.external_idstringColumna con el ID de la persona en tu sistema.tagsstringColumna con nombres de etiquetas, separados por coma o punto y coma.unsubscribedstringColumna que indica baja (valores comosim/não,true/false).propertiesobjectHasta 100 pareschave da propriedade → coluna. Las propiedades deben existir.
consent_sourcestringobligatorioDe 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) uother.consent_notestringCuenta de dónde vinieron, hasta 500 caracteres. Obligatorio cuandoconsent_sourceesother.consent_confirmedbooleanobligatorioDebe sertrue: 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_idstringSegmento estático donde colocar todos los contactos importados.topicsobject[] (JSON)Elecciones de temas para todos los contactos importados:[{"id": "…", "subscription": "opt_in"}].trigger_automationsbooleanInicia las automatizaciones que iniciarían los contactos importados si se crearan por la API. Predeterminado:false.
Errores
- 422
validation_errorArchivo ausente, demasiado grande o que no es CSV; archivo vacío; una columna delmappingque no está en el encabezado; propiedad desconocida; faltaconsent_sourceoconsent_confirmed. - 422
validation_errorEl segmento es dinámico: solo los segmentos estáticos reciben contactos importados. - 404
not_foundEl segmento o algún tema no existe. - 429
rate_limit_exceededYa hay 3 importaciones en curso.
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
}
#Listar importaciones
GET/contacts/imports
Parámetros de consulta
limitintegerDe 1 a 100. Predeterminado: 20.afterstringCursor de la página siguiente.beforestringCursor de la página anterior.
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": "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}
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
idstringobligatorioID de la importación.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
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": "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"
}