Saltar al contenido

Desarrolladores

La API de Atiende

Conecta tu CRM, tu sistema de citas o tus automatizaciones: manda mensajes por WhatsApp, entérate de lo que pasa con eventos firmados, lee contactos y conversaciones, y mantén la ficha al día.

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

  1. Un admin crea la integración en Configuración → Integraciones.
  2. Atiende muestra dos valores una sola vez:
    • El token (atd_…), para llamar a la API.
    • El secreto, para comprobar la firma de los eventos.
  3. 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:

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
}

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
}

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
}

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:

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/…"
    }
  ]
}

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
}

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>.

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):

  1. 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.
  2. Copia la dirección que te da y pégala en la URL de los eventos de la integración, en Atiende.
  3. 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.
  4. Usa los campos de datos en los pasos que siguen: datos.contacto.telefono, datos.texto… Si a la misma dirección llegan varios eventos, filtra por evento.

Para que la herramienta le haga algo a Atiende (mandar un mensaje, buscar un contacto, cambiar su ficha):

  1. 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».
  2. Pon la dirección de la llamada, por ejemplo https://atiende.example/api/externos/mensajes, y el método.
  3. Agrega la cabecera Authorization con Bearer atd_…, y Content-Type con application/json.
  4. 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