# ATENCIO: conectar un sistema propio Versión del contrato: **1**. Última revisión: 10/11/2026. Versión para leer en un navegador: https://atencio.ar/docs/api Base de la API: `https://atencio.ar`. ## Cómo funciona Esta guía es para conectar un bot o sistema que ya existe (un backend propio o n8n) con un número de WhatsApp que atiende ATENCIO. Está escrita para que la siga un programador o un agente de código sin más contexto. ``` Cliente en WhatsApp │ ▲ ▼ │ Meta (Cloud API) │ ▲ ▼ │ ATENCIO ── POST con la conversación ──▶ TU SISTEMA ▲ │ └──── POST /api/externo/enviar ◀────────────┘ ``` ATENCIO recibe todo de Meta, lo guarda, lo muestra en su bandeja y te lo pasa. Vos decidís qué contestar y ATENCIO lo manda. > **Tu sistema no habla con Meta.** No recibe el webhook de Meta y no manda mensajes con un token de Meta: todo pasa por ATENCIO. Si mandás por tu cuenta, ATENCIO ve salir un mensaje que no reconoce, cree que lo escribió una persona desde el celular y pausa la conversación. Además, ese mensaje no aparece en la bandeja. A cambio, ATENCIO se encarga de esto por vos: - recibir de Meta y juntar los mensajes seguidos del cliente en un solo aviso; - guardar fotos, audios y documentos antes de que Meta los borre, y transcribir los audios; - detectar cuando alguien contesta desde el celular del negocio; - la ventana de 24 h, los estados de entrega y los errores de Meta; - la bandeja, los escalamientos a una persona y los avisos por Telegram. ## Antes de empezar 1. **El número tiene que estar conectado en ATENCIO**, en Configuración → Números. 2. **Tu sistema necesita una URL HTTPS pública** que acepte POST con cuerpo JSON. Si usás n8n, el nodo Webhook viene en GET por defecto: cambialo a POST. 3. En **Configuración → Bot → Quién contesta**, elegí el modo (ver abajo) y pegá la **URL** de tu sistema. 4. Poné un **secreto**: un texto largo al azar que inventás vos. ATENCIO lo manda en cada aviso para que tu sistema sepa que viene de ATENCIO. 5. Tocá **Generar clave nueva**. Es la clave con la que tu sistema llama a ATENCIO: empieza con `atc_` y se muestra una sola vez. Si generás otra, la anterior deja de funcionar. 6. Guardá los cambios. Hay dos valores secretos, uno para cada dirección: | Nombre | Dirección | Cabecera HTTP | Quién lo elige | |---|---|---|---| | Secreto | ATENCIO → tu sistema | `x-atencio-secreto` | Vos, en el panel | | Clave | Tu sistema → ATENCIO | `x-atencio-clave` | ATENCIO la genera | > Guardá los dos en variables de entorno de tu sistema, nunca en el código ni en el repositorio. En los ejemplos de esta página se llaman `ATENCIO_SECRETO` y `ATENCIO_CLAVE`. ## Los dos modos | | Contesta cuando quiere (`relay`) | Contesta en el momento (`externo`) | |---|---|---| | Cómo contesta tu sistema | Llama a `POST /api/externo/enviar` cuando quiere | Devuelve la respuesta en la misma llamada | | Tiempo máximo | Sin límite para contestar. Tenés 10 s para responder `200` al aviso | 30 s | | Varios mensajes seguidos | Sí, un llamado por mensaje | Sí, con una lista de acciones | | Recibe los avisos `eco` y `estado` | Sí | No | | Con la conversación pausada (la atiende una persona) | Te avisa igual, con `responder: false` | No te llama | | Palabras de escalado del panel | No se aplican (las revisa tu sistema) | ATENCIO escala antes de llamarte | | Necesita la clave `atc_` | Sí | Sólo para escalar o bajar archivos | > **Para un bot que ya existe, usá `relay`.** Un bot de verdad consulta otros sistemas, manda más de un mensaje y necesita saber cuándo intervino una persona. ## Verificar que el aviso viene de ATENCIO `POST → tu URL` Todo lo que te manda ATENCIO es un `POST` con `content-type: application/json` y, si configuraste el secreto, la cabecera `x-atencio-secreto`. **Verificalo antes de hacer cualquier otra cosa**, con una comparación en tiempo constante, y si no coincide respondé `401`. Node.js: ```js import crypto from "node:crypto"; function vieneDeAtencio(req) { const recibido = Buffer.from(req.headers["x-atencio-secreto"] ?? ""); const esperado = Buffer.from(process.env.ATENCIO_SECRETO); return recibido.length === esperado.length && crypto.timingSafeEqual(recibido, esperado); } ``` PHP: ```php $recibido = $_SERVER["HTTP_X_ATENCIO_SECRETO"] ?? ""; if (!hash_equals(getenv("ATENCIO_SECRETO"), $recibido)) { http_response_code(401); exit; } ``` Python: ```python import hmac, os def viene_de_atencio(headers) -> bool: recibido = headers.get("x-atencio-secreto", "") return hmac.compare_digest(recibido, os.environ["ATENCIO_SECRETO"]) ``` n8n: ``` Nodo Webhook HTTP Method: POST Authentication: Header Auth Credencial Header Auth: Name: x-atencio-secreto Value: ``` Todos los cuerpos traen `version` y `evento`. Mirá `evento` para saber qué llegó: | `evento` | Cuándo llega | Modos | |---|---|---| | `mensaje` | Un cliente escribió | los dos | | `eco` | Alguien contestó desde el celular o la app del negocio | sólo `relay` | | `estado` | Cambió el estado de un mensaje que salió (entregado, leído, fallido) | sólo `relay` | > Si llega un `evento` que no conocés, respondé `200` y descartalo. Si algún día cambia la forma de lo que ya existe, `version` va a subir. ## Evento mensaje `POST → tu URL` Llega cuando un cliente escribe. ATENCIO espera unos segundos (7 por defecto) después del último mensaje del cliente y te manda un solo aviso con todo. Si el cliente escribió "hola", "quería saber" y "el precio" seguidos, te llega un aviso, no tres. ```json { "version": 1, "evento": "mensaje", "negocio": { "id": 6, "nombre": "Entre Ríos Net" }, "conversacion": { "id": 812, "telefono": "5493446123456", "numero": "Principal", "contacto": "Ana", "dentroDeHorario": true, "responder": true, "escalamiento_abierto": false, "escalamiento": null }, "reenvio": false, "mensajes": [ { "id": 10401, "de": "cliente", "texto": "Hola, no tengo internet", "tipo": "text", "fecha": 1790000000000 }, { "id": 10402, "de": "negocio", "texto": "Hola Ana, ¿las luces del equipo están prendidas?", "tipo": "text", "fecha": 1790000005000 }, { "id": 10403, "de": "cliente", "texto": "", "tipo": "image", "fecha": 1790000060000, "media_url": "https://atencio.ar/api/externo/media/10403" }, { "id": 10404, "de": "cliente", "texto": "", "tipo": "audio", "fecha": 1790000065000, "media_url": "https://atencio.ar/api/externo/media/10404", "transcripcion": "ahí te mandé la foto, la de internet está en rojo" } ], "conocimiento": [{ "titulo": "Planes", "contenido": "..." }], "reglas": "texto de Configuración → Bot → Reglas propias, o null", "bienvenida": "texto o null", "fueraDeHorario": "texto o null" } ``` | Campo | Qué es | |---|---| | `conversacion.id` | El id de la conversación en ATENCIO. Es lo que usás para contestar. | | `conversacion.telefono` | El número del cliente en formato internacional, sólo dígitos, sin `+`. Con esto lo buscás en tu sistema. | | `conversacion.numero` | La etiqueta del número del negocio al que escribió, si el negocio tiene más de uno. | | `conversacion.contacto` | El nombre del cliente, si se conoce (el de su perfil de WhatsApp). Puede ser `null`. | | `conversacion.dentroDeHorario` | Según el horario cargado en el panel. | | `conversacion.responder` | **`false` significa que la atiende una persona: tu sistema guarda el mensaje y NO contesta.** | | `conversacion.escalamiento_abierto` | Hay un caso abierto en Escalamientos para esta conversación. | | `conversacion.escalamiento` | Sólo en modo relay: el caso abierto, `{"motivo": "el sistema del negocio no responde", "desde": 1790000000000}`, o `null`. Para cargarlo en tu propia lista de escalamientos. | | `reenvio` | Sólo en modo relay. `true`: este aviso no te había llegado (tu sistema estaba caído o respondió un error) y ATENCIO te lo vuelve a mandar ahora. Ver [Si tu sistema no recibe un aviso](#relay-sin-entregar). | | `mensajes` | Los últimos mensajes de los dos lados, del más viejo al más nuevo (hasta 44). Los del negocio incluyen lo que contestó tu sistema y lo que escribieron las personas. | | `mensajes[].de` | `"cliente"` o `"negocio"`. | | `mensajes[].tipo` | `text`, `image`, `audio`, `video`, `document`, `sticker`, `location`, `button`, `interactive` u otros de Meta. | | `mensajes[].texto` | El texto. En una foto o un video es el pie, y puede venir vacío. En un audio viene vacío: usá `transcripcion`. | | `mensajes[].media_url` | Sólo si trae un archivo. Se baja con la clave (ver [Bajar un archivo](#bajar-archivo)). No es pública. | | `mensajes[].transcripcion` | Sólo en audios, cuando ATENCIO pudo transcribirlos. | | `mensajes[].ubicacion` | Sólo en `location`: `{"lat": -33.0066, "lng": -58.5422, "nombre": "Plaza San Martín", "direccion": "Av. Rivadavia 100"}`. `lat` y `lng` son números y vienen siempre, también cuando el cliente eligió un lugar en vez de mandar dónde está. `nombre` y `direccion` vienen sólo con un lugar elegido; si no, `null`. El `texto` sigue trayendo `[ubicación lat, lng]`, con el nombre y la dirección delante si los hay. | | `mensajes[].no_soportado` | Sólo en `unsupported` (algo que la API de WhatsApp no deja ver): `{"tipo": "unknown", "codigo": "131060"}`, lo que dice Meta. El tipo casi siempre es `unknown`. **La ubicación en tiempo real no llega**: Meta la manda así, sin las coordenadas, y en la prueba vino con el código `131060` (otros mensajes que no se pueden ver vinieron con `131051`). Si tu bot necesita la ubicación, que pida la actual («Enviar tu ubicación actual»), no la de tiempo real. | | `mensajes[].fecha` | Milisegundos desde 1970 (epoch en ms). | | `conocimiento`, `reglas`, `bienvenida`, `fueraDeHorario` | Lo que el negocio cargó en el panel. Usalo si tu bot lo necesita; si no, ignoralo. | > El mensaje nuevo es el último con `de: "cliente"`. **Guardá el `id` del último mensaje que procesaste por conversación y no proceses dos veces el mismo**: así, si un aviso te llega repetido, no contestás dos veces. ## Evento eco `POST → tu URL · sólo relay` Alguien del negocio contestó desde el celular (coexistence) o desde la app de WhatsApp Business. ATENCIO ya pausó la conversación: los avisos siguientes van a traer `responder: false`. ```json { "version": 1, "evento": "eco", "conversacion": { "id": 812, "telefono": "5493446123456" }, "texto": "Ana, ya te mando un técnico", "tipo": "text", "fecha": 1790000100000 } ``` Si el eco trae un archivo, también viene `media_url`; si es una ubicación, `ubicacion`, como en el evento mensaje. **Qué hacer:** que tu bot deje de hablar en esa conversación. Si guarda su propio estado, que la marque como atendida por una persona. > Si la persona contesta desde la **Bandeja de ATENCIO** (no desde el celular), no llega un `eco`, pero la conversación queda pausada igual y el próximo aviso trae `responder: false`. Por eso la regla es siempre la misma: mirá `responder` en cada aviso. ## Evento estado `POST → tu URL · sólo relay` Meta avisa qué pasó con un mensaje que salió. Llegan varios por mensaje: `sent`, `delivered`, `read` o `failed`. ```json { "version": 1, "evento": "estado", "conversacion": { "telefono": "5493446123456" }, "wam_id": "wamid.HBgN...", "estado": "failed", "error_codigo": "131047", "error_texto": "Re-engagement message", "fecha": 1790000200000 } ``` `wam_id` es el mismo que te devolvió `/api/externo/enviar`. El que importa es `failed`: el mensaje no llegó aunque `/enviar` te haya contestado `200`. El más común es `131047`, que significa que pasaron más de 24 h desde el último mensaje del cliente. Los demás los podés ignorar. ## Qué contestarle a ATENCIO ### En modo relay Respondé `200` en menos de 10 s, con cualquier cuerpo, y procesá después. Lo que tengas para decirle al cliente lo mandás con [`/api/externo/enviar`](#enviar). Si respondés un error o no respondés, ATENCIO te lo vuelve a mandar cuando tu sistema ande: ver abajo. Una redirección `307` o `308` se sigue con el mismo POST; una `301`, `302` o `303` no (perdería el cuerpo): cuenta como no recibido, así que poné en Conexión la URL final. ### Si tu sistema no recibe un aviso Si tu sistema está caído, tarda más de 10 s o responde un error, la conversación queda guardada y ATENCIO te reenvía el aviso cuando tu sistema vuelve a andar, con `"reenvio": true` y el historial al día. Mientras falla, prueba con una conversación por minuto; cuando una llega, sigue con las demás de a 10 por minuto, para no volver a tumbarlo. Pasadas 24 horas no se reenvía. Si mientras tanto el cliente vuelve a escribir y ese aviso llega, ya no hace falta reenviarlo. **Puede llegar repetido**: un aviso que tardó más de 10 s pudo llegarte igual. Usá el `id` de cada mensaje para no contestar dos veces lo mismo. En **Conexión** elegís además si ATENCIO pasa esas conversaciones a una persona (de entrada, no) y qué le dice al cliente (vacío, nada). Si las pasa, el reenvío te llega igual, con `responder: false` y el caso en `conversacion.escalamiento`: así ves en tu sistema cuáles escalaron. ### En modo externo La respuesta de tu POST es lo que se le contesta al cliente. Respondé `200` con alguna de estas formas; todas valen: ```jsonc "Hola Ana, ya lo revisamos" // texto plano (también como text/plain) { "texto": "Hola Ana, ya lo revisamos" } // también valen "mensaje", "respuesta" u "output" { "tipo": "texto", "texto": "Hola Ana" } { "tipo": "escalar", "motivo": "pide hablar con un técnico", "detalle": "sin servicio desde ayer", "texto": "Te paso con una persona" } { "escalar": "pide hablar con un técnico" } { "tipo": "nada" } // no contestar nada [ { "texto": "Primer mensaje" }, { "texto": "Segundo mensaje" } ] { "acciones": [ { "texto": "..." }, { "tipo": "escalar", "motivo": "..." } ] } [ { "json": { "texto": "..." } } ] // lo que devuelve n8n, tal cual ``` - Cada `texto` sale como un mensaje de WhatsApp aparte. - `escalar` abre un caso en Escalamientos y pausa el bot en esa conversación hasta que alguien lo resuelva. Al cliente se le manda el aviso de escalado del panel, o tu `texto` si el negocio no configuró ninguno. - Si pasan más de 30 s, respondés algo distinto de 2xx, o el cuerpo viene vacío o no se entiende, **ATENCIO escala la conversación a una persona**. Nunca se queda callado. ## Mandar un mensaje `POST /api/externo/enviar` Todas las llamadas a ATENCIO llevan la cabecera `x-atencio-clave`. La clave va sólo del lado del servidor: nunca en un navegador ni en una app. Si falta o es incorrecta, la respuesta es `401 {"error": "Clave inválida"}`. cURL: ```bash curl -X POST https://atencio.ar/api/externo/enviar \ -H "x-atencio-clave: $ATENCIO_CLAVE" \ -H "content-type: application/json" \ -d '{"conversacion_id":812,"texto":"Hola Ana, ya lo revisamos"}' ``` Node.js: ```js const res = await fetch("https://atencio.ar/api/externo/enviar", { method: "POST", headers: { "x-atencio-clave": process.env.ATENCIO_CLAVE, "content-type": "application/json", }, body: JSON.stringify({ "conversacion_id": 812, "texto": "Hola Ana, ya lo revisamos" }), }); console.log(res.status, await res.json()); ``` PHP: ```php $ch = curl_init("https://atencio.ar/api/externo/enviar"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "x-atencio-clave: " . getenv("ATENCIO_CLAVE"), "content-type: application/json", ], CURLOPT_POSTFIELDS => json_encode(["conversacion_id" => 812, "texto" => "Hola Ana, ya lo revisamos"]), CURLOPT_RETURNTRANSFER => true, ]); $respuesta = json_decode(curl_exec($ch), true); $codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE); ``` Python: ```python import os, requests res = requests.post( "https://atencio.ar/api/externo/enviar", headers={"x-atencio-clave": os.environ["ATENCIO_CLAVE"]}, json={"conversacion_id": 812, "texto": "Hola Ana, ya lo revisamos"}, timeout=30, ) print(res.status_code, res.json()) ``` Para escribirle a alguien que no te escribió en las últimas 24 h (un recordatorio de turno, un aviso al otro día) hace falta una **plantilla** aprobada por Meta: en lugar de `texto`, mandá `plantilla`. Las plantillas se crean en el panel, en Plantillas, y las que tenés las devuelve [`GET /api/externo/plantillas`](#plantillas). cURL: ```bash curl -X POST https://atencio.ar/api/externo/enviar \ -H "x-atencio-clave: $ATENCIO_CLAVE" \ -H "content-type: application/json" \ -d '{"telefono":"5493446123456","plantilla":{"nombre":"recordatorio_turno","variables":["Ana","mañana a las 10:30"]}}' ``` Node.js: ```js const res = await fetch("https://atencio.ar/api/externo/enviar", { method: "POST", headers: { "x-atencio-clave": process.env.ATENCIO_CLAVE, "content-type": "application/json", }, body: JSON.stringify({ "telefono": "5493446123456", "plantilla": { "nombre": "recordatorio_turno", "variables": [ "Ana", "mañana a las 10:30" ] } }), }); console.log(res.status, await res.json()); ``` PHP: ```php $ch = curl_init("https://atencio.ar/api/externo/enviar"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "x-atencio-clave: " . getenv("ATENCIO_CLAVE"), "content-type: application/json", ], CURLOPT_POSTFIELDS => json_encode(["telefono" => "5493446123456", "plantilla" => ["nombre" => "recordatorio_turno", "variables" => ["Ana", "mañana a las 10:30"]]]), CURLOPT_RETURNTRANSFER => true, ]); $respuesta = json_decode(curl_exec($ch), true); $codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE); ``` Python: ```python import os, requests res = requests.post( "https://atencio.ar/api/externo/enviar", headers={"x-atencio-clave": os.environ["ATENCIO_CLAVE"]}, json={"telefono": "5493446123456", "plantilla": {"nombre": "recordatorio_turno", "variables": ["Ana", "mañana a las 10:30"]}}, timeout=30, ) print(res.status_code, res.json()) ``` | Campo | | |---|---| | `texto` | El mensaje, sólo texto (no se pueden mandar imágenes ni documentos). Sale sólo dentro de las 24 h desde el último mensaje del cliente. Va `texto` o `plantilla`, no los dos. | | `plantilla.nombre` | El nombre de una plantilla aprobada de la cuenta del número por el que sale. | | `plantilla.idioma` | El código de idioma (`es_AR`). Sólo hace falta si la plantilla está en más de un idioma. | | `plantilla.variables` | Los valores de las variables: una lista en el orden en que aparecen (primero las del encabezado, después las del cuerpo), o un objeto por nombre (`{"nombre": "Ana"}`). Texto sin saltos de línea. Sin variables, no hace falta. | | `conversacion_id` | El `conversacion.id` del aviso. Es la forma recomendada: sale por el mismo número al que escribió el cliente. | | `telefono` | En lugar de `conversacion_id`: número internacional, sólo dígitos. Si no hay una conversación con ese teléfono, se crea. | | `numero` | Sólo junto con `telefono`: la etiqueta del número del negocio desde el que sale. Si no la mandás, sale por el principal. | | Código | Cuerpo | Qué significa | |---|---|---| | `200` | `{"ok": true, "wam_id": "wamid...", "conversacion_id": 812}` | Meta lo aceptó. Todavía puede fallar después: mirá los avisos `estado`. | | `400` | `{"error": "Falta texto o plantilla"}` (u otro dato) | El pedido está mal armado: falta un dato, la plantilla lleva otra cantidad de variables, no está aprobada o tiene algo que ATENCIO todavía no manda (una imagen en el encabezado). No reintentes. | | `401` | `{"error": "Clave inválida"}` | Revisá o regenerá la clave. | | `402` | `{"error": "...", "codigo": "plan"}` | El plan de la cuenta no está al día (falta la tarjeta, se venció la prueba o se dio de baja). No sale nada hasta regularizarlo en Configuración → Plan. | | `404` | `{"error": "Conversación inexistente"}` | El id no existe o es de otro negocio, o la plantilla no existe en la cuenta de ese número. | | `429` | `{"error": "...", "codigo": "limite"}` | Pasaste un límite (ver abajo). La cabecera `retry-after` dice cuántos segundos esperar. No sale nada. | | `409` | `{"error": "...", "codigo": "baja"}` | Es una plantilla de marketing y ese cliente pidió no recibir promociones. No sale. Las de utilidad (un turno, un pedido) sí. | | `409` | `{"error": "...", "codigo": "en_curso"}` | Con `Idempotency-Key`: ya hay un envío con esa clave en curso, o se cortó sin saber si salió. Mirá la conversación antes de mandarlo con otra clave. | | `409` | `{"error": "...", "codigo": "incierto"}` | Con `Idempotency-Key`: el primer intento con esa clave terminó en un `502` con `"incierto": true` (pudo haber salido). No se manda de nuevo: mirá la conversación (o el aviso `estado`) antes de mandarlo con otra clave. | | `422` | `{"error": "...", "codigo": "clave_usada"}` | Con `Idempotency-Key`: esa clave ya se usó con otro mensaje en las últimas 24 h. No sale nada. | | `502` | `{"error": "...", "codigo": "131047"}` | Meta lo rechazó. El mensaje queda en la bandeja marcado como fallido. | | `502` | `{"error": "...", "codigo": null, "incierto": true}` | Meta no contestó a tiempo o dio un error suyo (5xx): **el mensaje pudo haber salido**. No lo reintentes a ciegas: el cliente lo recibiría dos veces (y Meta lo cobraría dos veces). | - Si el `502` trae `codigo` `131047` (más de 24 h desde el último mensaje del cliente), reintentar el texto no sirve: mandá una plantilla. - Una plantilla queda en la bandeja como la ve el cliente, con las variables puestas. - Si trae `130429`, `131056` u `80007`, Meta pide ir más despacio: esperá unos segundos y reintentá una vez. - **Límites**, por cuenta (todas las claves y números juntos): 60 mensajes por minuto, 1.000 por hora y, de ésos, 200 plantillas de marketing por hora. El `texto`, hasta 4.096 caracteres; el `telefono`, de 8 a 15 dígitos. Si necesitás más, escribinos. - **Envíos repetidos**: mandá la cabecera `Idempotency-Key` con un valor único por mensaje (el id del turno o del pedido en tu sistema, o un UUID; hasta 200 caracteres visibles, sin espacios). Si reintentás con la misma clave algo que ya salió, no se manda de nuevo: la respuesta es la misma `200` de la primera vez, con `"repetido": true`. Si el primer intento terminó en `502` sin `incierto` (Meta lo rechazó o no lo procesó), el reintento con la misma clave sí sale; si terminó en `502` con `"incierto": true`, el reintento contesta `409` con `codigo` `incierto` y no sale. La clave vale 24 h. Sin la cabecera no hay control: si reintentás algo que en realidad salió, el cliente lo recibe dos veces (y Meta lo cobra dos veces). > **Antes de mandar, respetá `responder`.** Si en el último aviso de esa conversación vino `responder: false`, o te llegó un `eco`, no mandes: la está atendiendo una persona. ATENCIO no te frena si mandás igual, y el mensaje se pisa con lo que está escribiendo la persona. > **Cada mensaje se paga.** Desde el 1/10/2026 Meta cobra cada mensaje que sale, también los que contestan dentro de las 24 h, y lo paga el negocio. Mandá una respuesta completa en un solo mensaje, no tres cortos seguidos. ## Ver las plantillas `GET /api/externo/plantillas` Las plantillas aprobadas de la cuenta de un número, con sus variables: lo que necesitás para armar `plantilla` en `/enviar`. Con `?numero=Etiqueta`, las de ese número; sin nada, las del principal. cURL: ```bash curl https://atencio.ar/api/externo/plantillas \ -H "x-atencio-clave: $ATENCIO_CLAVE" ``` Node.js: ```js const res = await fetch("https://atencio.ar/api/externo/plantillas", { headers: { "x-atencio-clave": process.env.ATENCIO_CLAVE }, }); console.log(res.status, await res.json()); ``` PHP: ```php $ch = curl_init("https://atencio.ar/api/externo/plantillas"); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ["x-atencio-clave: " . getenv("ATENCIO_CLAVE")], CURLOPT_RETURNTRANSFER => true, ]); $respuesta = json_decode(curl_exec($ch), true); ``` Python: ```python import os, requests res = requests.get( "https://atencio.ar/api/externo/plantillas", headers={"x-atencio-clave": os.environ["ATENCIO_CLAVE"]}, timeout=30, ) print(res.status_code, res.json()) ``` ``` { "numero": "Principal", "plantillas": [ { "nombre": "recordatorio_turno", "idioma": "es_AR", "categoria": "UTILITY", "variables": ["1", "2"], "texto": "Hola {{1}}, te esperamos {{2}}.", "se_puede_mandar": true } ] } ``` - `variables` son los nombres de las variables en el orden en que van en la lista de `/enviar`: los números de `{{1}}`, `{{2}}`, o los nombres si la plantilla usa `{{nombre}}`. - `categoria` la pone Meta: `MARKETING` (promociones; no salen a quien pidió la baja), `UTILITY` (avisos de algo que el cliente pidió) o `AUTHENTICATION`. - `se_puede_mandar: false` trae un `motivo`: es una plantilla aprobada con algo que ATENCIO todavía no manda, como una imagen en el encabezado. > Cada plantilla se cobra según su categoría, y Meta mira cómo reaccionan los clientes: si muchos la bloquean o la reportan, baja la calidad del número y puede limitar cuántos mensajes manda por día. Mandá plantillas a quien las espera. ## Pasarle la conversación a una persona `POST /api/externo/escalar` cURL: ```bash curl -X POST https://atencio.ar/api/externo/escalar \ -H "x-atencio-clave: $ATENCIO_CLAVE" \ -H "content-type: application/json" \ -d '{"conversacion_id":812,"motivo":"pide un técnico","detalle":"sin servicio desde ayer, luz LOS en rojo"}' ``` Node.js: ```js const res = await fetch("https://atencio.ar/api/externo/escalar", { method: "POST", headers: { "x-atencio-clave": process.env.ATENCIO_CLAVE, "content-type": "application/json", }, body: JSON.stringify({ "conversacion_id": 812, "motivo": "pide un técnico", "detalle": "sin servicio desde ayer, luz LOS en rojo" }), }); console.log(res.status, await res.json()); ``` PHP: ```php $ch = curl_init("https://atencio.ar/api/externo/escalar"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "x-atencio-clave: " . getenv("ATENCIO_CLAVE"), "content-type: application/json", ], CURLOPT_POSTFIELDS => json_encode(["conversacion_id" => 812, "motivo" => "pide un técnico", "detalle" => "sin servicio desde ayer, luz LOS en rojo"]), CURLOPT_RETURNTRANSFER => true, ]); $respuesta = json_decode(curl_exec($ch), true); $codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE); ``` Python: ```python import os, requests res = requests.post( "https://atencio.ar/api/externo/escalar", headers={"x-atencio-clave": os.environ["ATENCIO_CLAVE"]}, json={"conversacion_id": 812, "motivo": "pide un técnico", "detalle": "sin servicio desde ayer, luz LOS en rojo"}, timeout=30, ) print(res.status_code, res.json()) ``` - `motivo` es obligatorio. Es lo que se ve en Escalamientos y en la tarjeta de Telegram: corto y claro. - `detalle` es opcional. La conversación se identifica con `conversacion_id`, o con `telefono` (y opcionalmente `numero`). - Respuesta: `200 {"ok": true, "nuevo": true, "conversacion_id": 812}`. `nuevo: false` quiere decir que ya había un caso abierto: no se duplica. - Pausa el bot en esa conversación: los avisos siguientes llegan con `responder: false` hasta que alguien marca el caso como resuelto en el panel. - En un negocio dado de alta por un integrador (sin panel ni usuarios), nadie puede resolver el caso en ATENCIO: el caso queda tomado y la pausa dura lo que la de una persona (`pausa_humano_min`, 30 minutos de entrada). Al vencer, el caso se resuelve solo y los avisos vuelven a llegar con `responder: true`. > A diferencia del modo `externo`, esta llamada **no le manda nada al cliente**. Si querés avisarle ("te paso con una persona"), mandalo antes con `/api/externo/enviar`. ## Bajar un archivo `GET /api/externo/media/{id}` Es el `media_url` que vino en el aviso. Devuelve los bytes del archivo con su `content-type`. cURL: ```bash curl https://atencio.ar/api/externo/media/10403 \ -H "x-atencio-clave: $ATENCIO_CLAVE" \ -o foto.jpg ``` Node.js: ```js import { writeFile } from "node:fs/promises"; const res = await fetch("https://atencio.ar/api/externo/media/10403", { headers: { "x-atencio-clave": process.env.ATENCIO_CLAVE }, }); if (!res.ok) throw new Error("ATENCIO respondió " + res.status); await writeFile("foto.jpg", Buffer.from(await res.arrayBuffer())); // res.headers.get("content-type") dice qué tipo de archivo es ``` PHP: ```php $ch = curl_init("https://atencio.ar/api/externo/media/10403"); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ["x-atencio-clave: " . getenv("ATENCIO_CLAVE")], CURLOPT_RETURNTRANSFER => true, ]); $bytes = curl_exec($ch); if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 200) throw new Exception("ATENCIO no devolvió el archivo"); file_put_contents("foto.jpg", $bytes); ``` Python: ```python import os, requests res = requests.get( "https://atencio.ar/api/externo/media/10403", headers={"x-atencio-clave": os.environ["ATENCIO_CLAVE"]}, timeout=30, ) res.raise_for_status() open("foto.jpg", "wb").write(res.content) ``` > Si el mensaje no existe, no tiene archivo o es de otro negocio, devuelve `404`. ATENCIO guarda los archivos por su cuenta, así que no vencen a los 7 días como los de Meta. ## Ejemplo completo en modo relay Un servidor mínimo que recibe el aviso, lo verifica, responde `200` enseguida y contesta después. `pensarRespuesta` es tu bot. Node.js: ```js import express from "express"; import crypto from "node:crypto"; const app = express(); app.use(express.json({ limit: "2mb" })); const procesados = new Map(); // conversación → id del último mensaje procesado app.post("/atencio", (req, res) => { const recibido = Buffer.from(req.headers["x-atencio-secreto"] ?? ""); const esperado = Buffer.from(process.env.ATENCIO_SECRETO); if (recibido.length !== esperado.length || !crypto.timingSafeEqual(recibido, esperado)) { return res.sendStatus(401); } res.sendStatus(200); // primero el 200: ATENCIO espera 10 s como mucho atender(req.body).catch(console.error); }); async function atender(aviso) { if (aviso.evento === "eco") return marcarComoAtendidaPorPersona(aviso.conversacion.id); if (aviso.evento !== "mensaje") return; const { conversacion, mensajes } = aviso; if (!conversacion.responder) return; // la atiende una persona const ultimo = procesados.get(conversacion.id) ?? 0; const nuevos = mensajes.filter((m) => m.de === "cliente" && m.id > ultimo); if (!nuevos.length) return; // aviso repetido procesados.set(conversacion.id, nuevos.at(-1).id); const texto = await pensarRespuesta(conversacion, mensajes); // tu bot await fetch("https://atencio.ar/api/externo/enviar", { method: "POST", headers: { "x-atencio-clave": process.env.ATENCIO_CLAVE, "content-type": "application/json" }, body: JSON.stringify({ conversacion_id: conversacion.id, texto }), }); } app.listen(3000); ``` Python: ```python import hmac, os, threading, requests from flask import Flask, request app = Flask(__name__) procesados = {} # conversación → id del último mensaje procesado @app.post("/atencio") def recibir(): if not hmac.compare_digest(request.headers.get("x-atencio-secreto", ""), os.environ["ATENCIO_SECRETO"]): return "", 401 aviso = request.get_json() threading.Thread(target=atender, args=(aviso,)).start() return "", 200 # primero el 200: ATENCIO espera 10 s como mucho def atender(aviso): if aviso["evento"] == "eco": return marcar_como_atendida_por_persona(aviso["conversacion"]["id"]) if aviso["evento"] != "mensaje": return conv = aviso["conversacion"] if not conv["responder"]: return # la atiende una persona ultimo = procesados.get(conv["id"], 0) nuevos = [m for m in aviso["mensajes"] if m["de"] == "cliente" and m["id"] > ultimo] if not nuevos: return # aviso repetido procesados[conv["id"]] = nuevos[-1]["id"] texto = pensar_respuesta(conv, aviso["mensajes"]) # tu bot requests.post( "https://atencio.ar/api/externo/enviar", headers={"x-atencio-clave": os.environ["ATENCIO_CLAVE"]}, json={"conversacion_id": conv["id"], "texto": texto}, timeout=30, ) ``` > En producción, guardá `procesados` en tu base y no en memoria: si tu servidor se reinicia, no tiene que volver a contestar lo que ya contestó. ## Pasar a ATENCIO un bot que ya habla con Meta 1. **Conectar el número en ATENCIO**, con el negocio creado. 2. **Adaptar la entrada.** El endpoint que hoy recibe el webhook de Meta pasa a recibir el aviso de ATENCIO. Verificá `x-atencio-secreto` en lugar de la firma de Meta (`x-hub-signature-256`), y leé `conversacion.telefono` y los mensajes con `de: "cliente"` en lugar de `entry[].changes[].value.messages[]`. Un aviso puede traer varios mensajes seguidos del cliente: tomá todos los que llegaron después del último `id` que procesaste. Los audios ya vienen transcriptos, y los archivos se bajan de `media_url` con la clave, no de la Graph API. Respondé `200` enseguida. 3. **Adaptar la salida.** Cada llamada que hoy va a `graph.facebook.com/.../messages` pasa a ser `POST /api/externo/enviar` con `conversacion_id` y `texto`. Guardá el `wam_id` que devuelve si necesitás relacionarlo con los avisos `estado`. 4. **Pasar los avisos a personas por ATENCIO.** Si el bot hoy avisa a un grupo de Telegram, un mail o algo propio, cambialo por `/api/externo/escalar`: el caso aparece en Escalamientos y en los avisos de Telegram de ATENCIO, con quién lo tomó y cuándo se resolvió. 5. **Respetar a las personas:** con `responder: false` o un `eco`, el bot no habla. 6. **Apagar la conexión vieja con Meta.** Si la app de Meta anterior sigue suscripta a la misma cuenta de WhatsApp, a tu sistema le llegan los mensajes dos veces (de Meta y de ATENCIO) y el cliente recibe dos respuestas. Desuscribí la app vieja o desactivá su webhook, y sacá el token de Meta de tu sistema: ya no lo necesita. 7. **En el panel:** elegí "Mi sistema, que contesta cuando quiere", cargá la URL y el secreto, generá la clave y guardá. 8. **Probar** con un teléfono que no sea el del negocio (ver la sección siguiente). ## Probar Sin tocar WhatsApp, simulá un aviso contra tu sistema: ```bash curl -X POST "$TU_URL" \ -H "content-type: application/json" \ -H "x-atencio-secreto: $ATENCIO_SECRETO" \ -d '{"version":1,"evento":"mensaje","negocio":{"id":0,"nombre":"Prueba"}, "conversacion":{"id":1,"telefono":"5490000000000","numero":"Principal","contacto":"Prueba", "dentroDeHorario":true,"responder":true,"escalamiento_abierto":false,"escalamiento":null},"reenvio":false, "mensajes":[{"id":1,"de":"cliente","texto":"hola","tipo":"text","fecha":0}], "conocimiento":[],"reglas":null,"bienvenida":null,"fueraDeHorario":null}' ``` - Sin la cabecera `x-atencio-secreto`, o con otro valor, tu sistema responde `401`. - Con `"responder": false` tu sistema no contesta. - De punta a punta: escribile al número desde otro teléfono y confirmá en la **Bandeja** de ATENCIO que aparecen el mensaje del cliente y la respuesta de tu sistema. Si algo falla, la conversación aparece en **Escalamientos** con el motivo (en modo `relay`, sólo si lo elegiste en Conexión: ver [Si tu sistema no recibe un aviso](#relay-sin-entregar)): | Motivo en Escalamientos | Qué pasó | |---|---| | el sistema del negocio no responde | La URL no contesta: está caída, el DNS falla o hay un problema con HTTPS. | | el sistema del negocio no recibió el mensaje | Sólo en modo `relay`: tu sistema respondió un error, por ejemplo `401` por un secreto mal copiado. El código aparece en el detalle. | | el flujo del negocio no respondió / falló / devolvió algo que no se entiende | Sólo en modo `externo`: tardó más de 30 s, respondió un error o una respuesta que no se entiende. | | el asistente no está configurado | Falta la URL en el panel. | ## Recibir los pedidos en tu sistema `POST → tu URL de pedidos` Para los negocios que toman pedidos con el asistente de ATENCIO: cada pedido que confirma un cliente (por WhatsApp o desde la web del local) entra solo a tu sistema (tu POS, tu sistema de delivery). Se configura en **Configuración → Pedidos → Mandar los pedidos a tu sistema**, de dos formas: por **API** (ATENCIO le hace un `POST` a tu URL) o por **MCP** (ATENCIO llama a la herramienta `crear_pedido` de tu app, conectada en Conectar MCP). Lo manda el programa, no el modelo: es el pedido como lo calculó ATENCIO, con el sí del cliente. ### Por API Un `POST` con `content-type: application/json` y la clave en `x-atencio-secreto` (la ves en esa misma pantalla; verificala como en [Verificar que el aviso viene de ATENCIO](#autenticar)). El campo `evento` dice qué pasó: | `evento` | Cuándo | |---|---| | `pedido_nuevo` | Se confirmó un pedido (o se aceptó, si el local acepta los pedidos a mano) | | `pedido_cambiado` | El cliente cambió un pedido ya mandado: llega el pedido completo como quedó | | `pedido_cancelado` | Se canceló un pedido ya mandado | | `prueba` | Tocaron «Mandar una prueba»: no lo prepares | ```json { "evento": "pedido_nuevo", "pedido": { "referencia": "atencio-12-3456", "numero": 142, "estado": "confirmado", "origen": "whatsapp", "tipo": "delivery", "pago": "transferencia", "cobro": "esperando", "cliente": { "nombre": "Juan Pérez", "telefono": "5493445123456" }, "direccion": "Neyra 504, timbre 2", "notas": "Sin cebolla en la hamburguesa", "para_las": "21:30", "items": [ { "codigo": "pizza-maldita", "codigo_externo": "PZ-01", "nombre": "Pizza Maldita", "opcion": "Grande", "adicionales": null, "cantidad": 1, "precio_unitario": 39300, "subtotal": 39300, "notas": null } ], "descuentos": [{ "texto": "Martes 10% en delivery", "monto": 3930 }], "total": 35370, "moneda": "ARS", "confirmado": "2026-10-22T00:41:00.000Z" } } ``` - `referencia` es única y fija para cada pedido: usala para no cargarlo dos veces (un reintento llega con la misma). - `codigo_externo` es el código del producto en tu sistema, si el local lo cargó (Configuración → Pedidos → Códigos de tus productos). Si viene `null`, usá `nombre` o `codigo`. - Los montos van en la moneda del negocio, que dice `moneda` (código ISO 4217: `ARS` en Argentina, `MXN`, `EUR`…): en pesos argentinos, enteros; en las monedas con centavos, con hasta dos decimales (`12.5`). `total` ya tiene los descuentos restados. - Respondé `2xx` cuando lo cargaste. Si devolvés JSON con `id` (`{"id": "A-981"}`), el local lo ve en el pedido. - Cualquier otra respuesta (o ninguna en 20 segundos) se reintenta varias veces. Si no entra, el pedido queda igual en ATENCIO, marcado «No entró a tu sistema», y le avisa al local por Telegram para que lo cargue a mano. ### Por MCP Si tu app tiene un servidor MCP, ATENCIO llama a estas herramientas con el mismo `pedido` de arriba. Sólo `crear_pedido` es obligatoria; sin las otras, cuando un pedido cambia o se cancela, el local recibe un aviso para hacerlo a mano. | Herramienta | Argumentos | Devolvé | |---|---|---| | `crear_pedido` | `{ pedido }` | `{ "id": "…" }` con el id en tu sistema (opcional) | | `modificar_pedido` | `{ referencia, id_externo, pedido }` | cualquier cosa; un error (`isError`) se reintenta | | `cancelar_pedido` | `{ referencia, id_externo }` | cualquier cosa | ## Recibir los archivos de tu equipo `POST → tu URL de archivos` Los contactos que el local marca como **empleados** (Configuración → No contestar y equipo) no reciben respuestas del asistente, y cada foto o PDF que mandan al WhatsApp del local se le pasa a tu sistema apenas llega: por ejemplo, las facturas de los proveedores, para cargarlas en tu sistema de gestión. ATENCIO le hace un `POST` a la dirección que cargó el local, con la clave en el encabezado `x-atencio-secreto`, y el archivo adentro. ```json { "evento": "archivo_empleado", "referencia": "atencio-8-m12345", "empleado": { "telefono": "5493446000000", "nombre": "Juan (cocina)" }, "archivo": { "nombre": "whatsapp-12345.jpg", "tipo": "image/jpeg", "contenido_base64": "/9j/4AAQ…" }, "texto": "factura de la carne", "recibido": "2026-10-26T19:40:00.000Z" } ``` - Van fotos (JPG, PNG, WEBP) y PDF de hasta 12 MB. Audios, videos y textos no. - `referencia` es única y fija para cada archivo: usala para no cargarlo dos veces (un reintento llega con la misma). - `texto` es lo que el empleado escribió con la foto, o `null`. - Respondé `2xx` cuando lo guardaste. Cualquier otra respuesta (o ninguna en 30 segundos) se reintenta varias veces. - El botón «Mandar una prueba» manda `"evento": "prueba"` con `"archivo": null`: contestá `2xx` sin guardar nada. - Si el local cargó un mensaje para el empleado («📄 Recibida, queda para revisar»), ATENCIO se lo manda cuando tu sistema contestó `2xx`. ## Lo que todavía no existe - Mandar imágenes o documentos por la API: sale texto o una plantilla. Tampoco plantillas con una imagen, video o documento en el encabezado, ni con un botón de enlace variable. - La firma HMAC de los avisos. Hoy se autentican con el secreto fijo en `x-atencio-secreto`; usá HTTPS siempre.