POST /api/v1/lists/{listId}/contacts/bulk
Envía hasta 500 contactos en una sola llamada, con todo lo que sabes de ellos, incluida la prueba de consentimiento de una base de datos migrada. Cada línea se inserta o actualiza por email, exactamente igual que en Añadir o actualizar un contacto.
Cada entrada:
| Campo | Obligatorio | Descripción |
|---|---|---|
email | sí | La clave de la inserción o actualización, junto con la lista |
firstName | no | Hasta 100 caracteres |
lastName | no | Hasta 100 caracteres |
tags | no | Nombres, que se crean en la organización si todavía no existen |
status | no | pending, subscribed, unsubscribed, bounced, complained: solo en la creación |
optinAt | no | Fecha ISO de la suscripción |
optinIp | no | IP de la suscripción |
confirmedAt | no | Fecha ISO de la confirmación del doble opt-in |
confirmedIp | no | IP de la confirmación |
doubleOptIn | — | Rechazado: una importación nunca envía emails a nadie, la línea se rechaza |
subscribed, ni para una etiqueta añadida por la importación. Las personas que se suscribieron hace años en otra herramienta no deben recibir un «¡Bienvenido, gracias por suscribirte!» porque cambiaste de proveedor. El status que declaras es simplemente con el que se crea el contacto.
Por la misma razón, una entrada de un lote rechaza doubleOptIn: la línea se rechaza y se nombra en el informe, en lugar de aceptarse sin que nunca se envíe una confirmación. Las suscripciones pasan por POST /api/v1/lists/{listId}/contacts, un contacto a la vez: ese endpoint es el que envía emails e inscribe.
Los campos de consentimiento se escriben, nunca se vacían. Un campo que omites conserva lo que ya está guardado: es la única parte de una base de datos migrada que no se puede reconstruir después. Y un contacto existente conserva su estado: declarar
subscribed para alguien que se dio de baja no cambia nada, y el informe sigue contando la línea como actualizada.El informe
La respuesta es un200 cuyo data es el informe del lote:
json
{
"success": true,
"data": {
"total": 500,
"created": 468,
"updated": 31,
"rejected": 1,
"errors": [{"index": 342, "email": "pas-un-email", "reason": "email: Invalid email address"}]
}
}| Campo | Significado |
|---|---|
total | Líneas enviadas: siempre created + updated + rejected |
created | Contactos que no existían en la lista |
updated | Contactos que ya estaban, completados sin cambiar nunca su estado |
rejected | Líneas rechazadas, detalladas una a una en errors |
errors | {index, email, reason}: index es la posición en el lote que enviaste |
El bucle del agente
index es lo que hace que el informe sea accionable: apunta a la línea del array que enviaste tú, así que un fallo se reenvía tal cual, no como un reenvío completo.
- Envía un bloque de 500.
- Lee
errors. - Reconstruye un lote solo con los índices que fallaron —
errors.map(e => batch[e.index])—, corrígelos y vuelve a enviarlos. - Repite hasta que
rejectedsea0y pasa al siguiente bloque.
js
const send = async (batch) => {
const response = await fetch(`${base}/api/v1/lists/${listId}/contacts/bulk`, {
method: 'POST',
headers: {'x-api-key': key, 'content-type': 'application/json'},
body: JSON.stringify({contacts: batch}),
})
return (await response.json()).data
}
for (let offset = 0; offset < all.length; offset += 500) {
const batch = all.slice(offset, offset + 500)
const report = await send(batch)
const failed = report.errors.map((error) => ({...batch[error.index], ...fix(error)}))
if (failed.length > 0) await send(failed)
}47 rejected te dice cuáles son las 47.
| Estado | Cuándo |
|---|---|
200 | Lote procesado: lee el informe, algunas líneas pueden haberse rechazado |
400 | El propio cuerpo está mal formado (contacts ausente o no es un array) |
403 | El propietario de la clave es member: importar requiere admin |
404 | Lista desconocida, o una lista de otra organización |
413 | Más de 500 entradas: no se escribió nada, no se truncó nada |
Autorización
| Rol en la organización | Este endpoint |
|---|---|
OWNER | Permitido |
ADMIN | Permitido |
MEMBER | Rechazado — 403 |
Cuerpo de la solicitud
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
contacts | object[] | sí | Array de entradas, 500 como máximo |
Ejemplo
bash
curl -X POST -H "x-api-key: $AGENTMAIL_API_KEY" -H "content-type: application/json" \
-d '{"contacts":[
{"email":"camille@example.fr","firstName":"Camille","lastName":"Roux","tags":["vip"],
"status":"subscribed","optinAt":"2024-03-12T09:14:00Z","optinIp":"81.250.14.7"},
{"email":"theo@example.com","status":"unsubscribed"}
]}' \
"https://www.agentsmail.io/api/v1/lists/$LIST_ID/contacts/bulk"Respuesta
200 OK: la llamada se realizó correctamente.401 Unauthorized: clave ausente o no válida, o su propietario salió de la organización.403 Forbidden: el propietario de la clave esmemberde la organización, noadmin.404 Not Found: no existe ese recurso, o pertenece a otra organización. La API nunca confirma que un id existe a un llamante que no tiene derecho sobre él.413 Payload Too Large: más de 500 contactos en una llamada; el cuerpo indica ellimit.429 Too Many Requests: más de 120 solicitudes en un minuto para esta clave.
Campos de la respuesta
| Campo | Tipo | Descripción |
|---|---|---|
success | boolean | Indica si la operación se realizó correctamente |
data.total | number | Líneas enviadas: siempre created + updated + rejected |
data.created | number | Contactos que no existían en la lista |
data.updated | number | Contactos que ya estaban, completados sin que su estado cambie nunca |
data.rejected | number | Líneas rechazadas, detalladas una a una en errors |
data.errors | array | {index, email, reason}: index es la posición en el lote que enviaste |
Ejemplo de respuesta
json
{
"success": true,
"data": {
"total": 500,
"created": 468,
"updated": 31,
"rejected": 1,
"errors": [
{
"index": 342,
"email": "not-an-email",
"reason": "email: Invalid email address"
}
]
}
}Notas
- Una lista eliminada responde
404, y se rechaza el lote entero. Una lista eliminada en la interfaz ya no acepta nada: envía el lote a una lista activa.