Cómo un sistema propio le escribe a los contactos por WhatsApp a través de Atiende, se entera de lo que pasa, lee los contactos y las conversaciones, y edita la ficha de un contacto.
En todos los ejemplos, https://atiende.example es la dirección de la instalación.
1. Conectarse
- Un admin crea la integración en Configuración → Integraciones.
- Atiende muestra dos valores una sola vez:
- El token (
atd_…), para llamar a la API. - El secreto, para comprobar la firma de los eventos.
- El token (
- Si se pierde alguno, el admin genera otro. El anterior deja de servir en el acto.
Cada llamada lleva el token:
Authorization: Bearer atd_…
Sin token, con uno que no existe o con la integración apagada, la respuesta es 401.
Los permisos se marcan en la misma integración, y sin el que hace falta la respuesta es 403:
| Permiso | Para qué |
|---|---|
| Puede mandar mensajes | POST /api/externos/mensajes |
| Puede leer contactos y conversaciones | Las secciones 4 y 5 |
| Puede editar contactos | PATCH /api/externos/contactos/:id |
Los eventos no piden permiso: llegan a la URL que tenga la integración. Leer y editar vienen apagados, también en las integraciones creadas antes de que existieran.
Versiones: cada instalación corre la versión de Atiende que fija su despliegue. Leer y editar (secciones 4 a 6) llegan con la versión 1.20.0; lo demás está desde la 1.19.0.
2. Mandar un mensaje
POST /api/externos/mensajes, con cuerpo JSON.
| Campo | Tipo | Qué es |
|---|---|---|
destino |
texto | Obligatorio. El número con indicativo: +573001234567. |
plantilla |
texto | El nombre de una plantilla aprobada, como aparece en Plantillas. |
idioma |
texto | Solo si hay varias plantillas con ese nombre: es, en_US… |
variables |
objeto | Los valores de la plantilla: { "1": "Marta", "2": "martes" }. |
texto |
texto | Texto libre, hasta 4096 caracteres. Solo si el contacto escribió en las últimas 24 h. |
nombre |
texto | El nombre del contacto, si Atiende todavía no lo conoce. |
clave |
texto | Obligatorio. La clave de idempotencia, hasta 200 caracteres. |
Va plantilla o texto, nunca los dos.
POST /api/externos/mensajes
Authorization: Bearer atd_…
Content-Type: application/json
{
"destino": "+573001234567",
"plantilla": "recordatorio_cita",
"variables": { "1": "Marta", "2": "martes 21 a las 9:00" },
"clave": "cita-8812-recordatorio"
}
Respuesta 201:
{
"id": "5f0c…",
"conversacion_id": "a91e…",
"clave": "cita-8812-recordatorio",
"estado": "en_cola",
"codigo_error": null,
"creado": "2026-10-15T14:03:11.000Z"
}
La clave evita envíos dobles. Si se repite una clave que esta integración ya usó, Atiende no manda nada y responde 200 con el mensaje que salió con ella. Ante un error de red se puede reintentar con la misma clave sin miedo.
Los estados posibles:
en_colaenviadoentregadoleidofallido, concodigo_error, el código del canal
Justo después de enviar, el estado es casi siempre en_cola. Los cambios llegan por el evento mensaje.estado, o se consultan con GET /api/externos/mensajes/:id.
Los errores vienen como { "error": { "code": "…", "message": "…" } }. El mensaje dice qué corregir.
| Código HTTP | Cuándo |
|---|---|
400 |
Falta un campo, la plantilla no existe o no está aprobada, faltan variables, o el número no se entiende. |
401 |
El token no es válido. |
403 |
La integración no tiene permiso para enviar. |
409 |
Texto libre a alguien que no escribió en las últimas 24 h: hace falta una plantilla. |
429 |
La integración ya mandó su tope de mensajes en la última hora. |
En la consola, el mensaje queda en el hilo del contacto como «Enviado por nombre de la integración».
Si el contacto contesta, la conversación entra a la bandeja como cualquier otra, para que la atienda una persona.
3. Consultar un mensaje
GET /api/externos/mensajes/:id devuelve el mismo objeto que el envío. Solo responde por los mensajes que mandó la propia integración; los demás dan 404.
4. Leer contactos
GET /api/externos/contactos devuelve una página de contactos.
| Parámetro | Para qué |
|---|---|
telefono |
Busca uno por su número, con indicativo: +573001234567. |
desde |
Solo los que cambiaron desde esa fecha, incluida, en ISO 8601: 2026-10-17T14:00:00Z. |
cursor |
El siguiente de la página anterior. |
limite |
Cuántos por página, de 1 a 100. Sin él, 50. |
GET /api/externos/contactos?telefono=%2B573001234567
Authorization: Bearer atd_…
{
"contactos": [
{
"id": "c41d…",
"telefono": "+573001234567",
"nombre": "Marta Gómez",
"tipo": "Cliente",
"campos": { "Ciudad": "Bogotá", "Id del CRM": "C-88" },
"etiquetas": ["VIP"],
"conversacion_id": "a91e…",
"creado": "2026-09-20T10:12:00.000Z",
"actualizado": "2026-10-17T14:03:11.000Z"
}
],
"siguiente": null
}
telefonoviene vacío si el contacto escribió con nombre de usuario de WhatsApp y no compartió su número.conversacion_idviene vacío si nunca hubo una conversación con él.GET /api/externos/contactos/:iddevuelve uno solo, con la misma forma.
Las páginas van por fecha de cambio, de lo más viejo a lo más nuevo. Mientras siguiente no sea null, hay más: se piden con cursor=<siguiente>, sin repetir los otros parámetros.
Para sincronizar, guarda el actualizado más reciente que viste y la próxima vez pide desde esa fecha. Un contacto que cambia mientras se recorre vuelve a salir más adelante; reconócelo por su id. Cambiar el nombre, el tipo, los campos o las etiquetas cuenta como cambio, se haga desde la consola, desde un formulario de WhatsApp o desde la API.
5. Leer conversaciones y sus mensajes
GET /api/externos/conversaciones devuelve una página de conversaciones, con los mismos desde, cursor y limite de los contactos, y además:
| Parámetro | Para qué |
|---|---|
estado |
bot, pendiente, abierta o resuelta. |
contacto_id |
Las de un contacto. Cada contacto tiene una sola conversación. |
{
"conversaciones": [
{
"id": "a91e…",
"contacto": { "id": "c41d…", "telefono": "+573001234567", "nombre": "Marta Gómez" },
"estado": "abierta",
"area": "Ventas",
"agente": "Ana Torres",
"ventana_abierta": true,
"ultimo_mensaje": "2026-10-17T14:05:40.000Z",
"caso": {
"id": "5d2b…",
"abierto": "2026-10-17T13:58:02.000Z",
"asunto": "Cotización",
"columna": "Cotizando",
"resuelto": null,
"tipo_cierre": null
},
"actualizado": "2026-10-17T14:05:40.000Z"
}
],
"siguiente": null
}
- Una conversación nunca se cierra: el contacto que vuelve meses después sigue en la misma. Cada vez que hay que atenderlo se abre un caso, y
casoes el de ahora o, si está resuelta, el último. botquiere decir que el contacto todavía está en el menú de bienvenida.ventana_abiertadice si el contacto escribió en las últimas 24 horas, que es cuando se le puede mandar texto libre.tipo_cierretraenombreyresultado(resolved,unresolvedono_contact) cuando el caso se cerró con un tipo.GET /api/externos/conversaciones/:iddevuelve una sola.
GET /api/externos/conversaciones/:id/mensajes devuelve lo que se habló con el contacto, por la fecha en que se escribió, con desde, cursor y limite:
{
"mensajes": [
{
"id": "7c1f…",
"direccion": "entrante",
"remitente": "contacto",
"autor": null,
"texto": "Adjunto la cédula",
"archivos": [
{
"id": "…",
"tipo": "image/jpeg",
"nombre": null,
"tamano": 183442,
"url": "https://atiende.example/api/externos/archivos/…"
}
],
"formulario": null,
"estado": null,
"fecha": "2026-10-17T14:05:40.000Z"
},
{
"id": "8d20…",
"direccion": "saliente",
"remitente": "agente",
"autor": "Ana Torres",
"texto": "Recibida, gracias",
"archivos": [],
"formulario": null,
"estado": "entregado",
"fecha": "2026-10-17T14:06:12.000Z"
}
],
"siguiente": null
}
remitenteescontacto,agente,bot,campanaointegracion, yautordice cuál, si se sabe.estadoes la entrega de lo que salió, con los valores de la sección 2. En lo que entró viene vacío.- Las notas internas del equipo y los avisos del hilo no salen: nunca llegaron al contacto.
- Los archivos se descargan como en el evento
mensaje.recibido.
6. Editar un contacto
PATCH /api/externos/contactos/:id, con cuerpo JSON. Todo es opcional, pero tiene que venir algo.
| Campo | Tipo | Qué es |
|---|---|---|
nombre |
texto | Hasta 120 caracteres. Vacío o null lo borra. |
tipo |
texto | El nombre de un tipo de contacto que exista, sin distinguir mayúsculas. null lo quita. |
campos |
objeto | Clave y valor. Se mezcla con los que hay: la clave que viene se pone o se cambia, sin distinguir mayúsculas, y con null se quita. Las que no vienen se quedan. |
etiquetas |
objeto | { "agregar": [ … ], "quitar": [ … ] }, por nombre. Una etiqueta que no existe se crea. |
PATCH /api/externos/contactos/c41d…
Authorization: Bearer atd_…
Content-Type: application/json
{
"nombre": "Marta Gómez",
"campos": { "Id del CRM": "C-88", "Ciudad": "Bogotá", "Plan anterior": null },
"etiquetas": { "agregar": ["VIP"] }
}
Responde 200 con el contacto como quedó, igual que GET /api/externos/contactos/:id. Un tipo que no existe da 400 y no cambia nada.
En la consola, la ficha dice «nombre de la integración editó la ficha», y quien la tenga abierta ve el cambio al instante.
Los límites de la ficha: hasta 30 campos, cada clave de hasta 60 caracteres y cada valor de hasta 500; cada etiqueta, de hasta 40.
7. Eventos
Si la integración tiene URL de eventos, Atiende le manda un POST con JSON por cada evento que tenga marcado.
POST https://mi-sistema.example/atiende
Content-Type: application/json
X-Atiende-Evento: mensaje.recibido
X-Atiende-Entrega: 3b6f…
X-Atiende-Signature: t=1760536991,v1=Qk9…
{
"id": "3b6f…",
"evento": "mensaje.recibido",
"fecha": "2026-10-15T14:03:11.000Z",
"datos": { … }
}
Responder: cualquier 2xx en menos de 10 segundos cuenta como entregado. El cuerpo de la respuesta no se lee.
Reintentos:
- si la respuesta no es
2xxo tarda más de 10 segundos, Atiende reintenta a los 30 s, a los 2 min y a los 10 min; - después no lo vuelve a intentar, y el admin ve la incidencia «Eventos sin entregar»;
- un reintento lleva el mismo
id, así que hay que usarlo para no procesar dos veces el mismo evento.
mensaje.recibido
El contacto escribió.
{
"mensaje_id": "…",
"conversacion_id": "…",
"contacto": { "telefono": "+573001234567", "nombre": "Marta Gómez" },
"texto": "Adjunto la cédula",
"recibido": "2026-10-15T14:05:40.000Z",
"formulario": null,
"archivos": [
{
"id": "…",
"tipo": "image/jpeg",
"nombre": null,
"tamano": 183442,
"url": "https://atiende.example/api/externos/archivos/…"
}
]
}
formulario: si el contacto envió un formulario de WhatsApp, trae{ "respuestas": { "campo": "valor" } }.- Los archivos se descargan con
GETa suurly el mismo token. Atiende los copia del proveedor después de recibir el mensaje: si la descarga responde409, el archivo todavía se está copiando, y hay que intentar de nuevo en unos segundos.
mensaje.estado
Cambió la entrega de un mensaje que mandó esta misma integración. No llega por los mensajes de otros.
{
"mensaje_id": "…",
"conversacion_id": "…",
"clave": "cita-8812-recordatorio",
"estado": "entregado",
"codigo_error": null,
"actualizado": "2026-10-15T14:03:15.000Z"
}
conversacion.resuelta
Se cerró un caso, a mano o por inactividad.
{
"conversacion_id": "…",
"caso_id": "…",
"contacto": { "telefono": "+573001234567", "nombre": "Marta Gómez" },
"resuelta": "2026-10-15T15:20:00.000Z",
"automatica": false,
"resuelta_por": "Ana Torres",
"tipo_cierre": { "nombre": "Documentos completos", "resultado": "resolved" },
"nota": null
}
resultadopuede serresolved,unresolvedono_contact.- Si lo cerró la tarea de inactividad,
automaticaestrueyresuelta_porllega vacío.
El evento de prueba
Probar en la consola manda un evento con "evento": "prueba", en el momento y sin reintentos. Sirve para revisar la URL y la firma.
8. Comprobar la firma
X-Atiende-Signature tiene la forma t=<segundos>,v1=<firma>.
- Cómo se calcula la firma: HMAC-SHA256 del texto
atiende/webhook:<t>:<cuerpo>, con el secreto de la integración y en base64url sin relleno. - El cuerpo se toma tal como llegó, sin volverlo a serializar.
- La fecha cuenta: hay que rechazar los eventos cuyo
ttenga más de 5 minutos de diferencia con la hora actual.
En Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function esDeAtiende(secreto, cabecera, cuerpo, ahora = Date.now()) {
const partes = Object.fromEntries(cabecera.split(',').map((parte) => parte.trim().split('=')))
const t = Number(partes.t)
if (!partes.v1 || !Number.isSafeInteger(t)) return false
if (Math.abs(Math.floor(ahora / 1000) - t) > 300) return false
const esperada = createHmac('sha256', secreto)
.update(`atiende/webhook:${t}:${cuerpo}`)
.digest('base64url')
const a = Buffer.from(esperada)
const b = Buffer.from(partes.v1)
return a.length === b.length && timingSafeEqual(a, b)
}
9. Zapier, Make y n8n
No hace falta una app: los tres hablan con Atiende con sus piezas de webhook y de HTTP.
Para que algo pase cuando un contacto escribe (o cuando cambia la entrega de un mensaje, o se resuelve una conversación):
- En la herramienta, crea un disparador de webhook:
- Zapier: «Webhooks by Zapier», con el evento «Catch Hook»;
- Make: el módulo «Webhooks», con «Custom webhook»;
- n8n: el nodo «Webhook», con el método
POST.
- Copia la dirección que te da y pégala en la URL de los eventos de la integración, en Atiende.
- Marca los eventos que quieres y pulsa Probar para comprobar que llega. El evento de prueba no trae los campos de uno real: para que la herramienta los aprenda, escribe al número desde un teléfono de prueba y deja que llegue un
mensaje.recibido. - Usa los campos de
datosen los pasos que siguen:datos.contacto.telefono,datos.texto… Si a la misma dirección llegan varios eventos, filtra porevento.
Para que la herramienta le haga algo a Atiende (mandar un mensaje, buscar un contacto, cambiar su ficha):
- Usa su paso de HTTP:
- Zapier: «Webhooks by Zapier», con «Custom Request»;
- Make: el módulo «HTTP», con «Make a request»;
- n8n: el nodo «HTTP Request».
- Pon la dirección de la llamada, por ejemplo
https://atiende.example/api/externos/mensajes, y el método. - Agrega la cabecera
AuthorizationconBearer atd_…, yContent-Typeconapplication/json. - Escribe el cuerpo JSON de la sección que corresponda.
Comprobar la firma en estas herramientas es opcional, pero sin ella cualquiera que conozca la dirección del webhook puede mandarle eventos falsos. En n8n se hace con un nodo «Code» y el ejemplo de la sección 8; en Zapier y Make, con un paso de código si el plan lo permite. Si no, trata la dirección del webhook como un secreto.
Planes: Webhooks by Zapier solo está en los planes de pago de Zapier.
10. Límites y cuidados
- Tope por hora: cada integración tiene uno para mandar, 100 por defecto, que el admin puede cambiar o quitar. Protege la calidad del número: si muchos contactos bloquean al negocio, Meta la baja.
- Leer no tiene tope por hora, pero cada página trae hasta 100 elementos. Para sincronizar, pide
desdela última vez en vez de recorrer todo cada vez. - Plantillas: fuera de la ventana de 24 h solo salen plantillas aprobadas, y cada una tiene costo con el canal.
- Baja: Atiende todavía no registra que un contacto no quiere más mensajes. Si el sistema la maneja, no debe mandar a quien se dio de baja.
- Cambios en la API: solo se agregan campos. Un sistema que integre debe ignorar los campos que no conozca.