Ir al contenido

Integrar un CRM

Esta página arma un CRM de atención por WhatsApp sobre el motor, de punta a punta. El ejemplo es Mi CRM: varias sucursales («Sucursal centro», «Sucursal norte»), cada una con su número, y agentes que contestan desde una bandeja. Es el CRM que se construirá sobre el motor; aquí se usa su diseño para mostrar el patrón, que sirve igual para cualquier bandeja de atención.

Los ejemplos usan https://motor.ejemplo.com como dirección del motor y TU_API_KEY como token. El motor vive en tu red privada: el CRM lo llama desde su backend, nunca desde el navegador de un agente.

Mi CRM es una app cliente del motor. Todo lo que el CRM toca (sus instancias, sus suscripciones, sus medios, sus comandos) queda aislado de las demás apps: el motor saca la app de la credencial, nunca del cuerpo de un pedido. Si mañana un bot o un tablero de métricas leen los mismos números, son otras apps con sus propias keys, no más scopes en la key del CRM.

Una app puede tener varias keys. Conviene una por proceso (la API del CRM, su worker) o por entorno (mw_live_… en producción, mw_test_… en prueba): así revocas una sin cortar las otras. Una key restringida a instancias solo ve esas instancias y no puede crear otras.

Pide solo lo que el CRM usa. Esta es la lista de Mi CRM, que da de alta y vincula sus propios números:

Scope Para qué lo usa el CRM ¿Hace falta?
instances:read Listar los números, su estado y su diagnóstico. Sí
instances:create Dar de alta el número de una sucursal. Si el CRM crea las instancias
instances:pair Mostrar el QR o el código al vincular y al volver a vincular. Si el CRM vincula
instances:manage Encender la importación del historial y pedir una reconexión. Sí
instances:logout Desvincular o archivar un número. Solo si el CRM lo permite
messages:send Responder, marcar como leído y consultar el comando de un envío. Sí
messages:read Leer el store de mensajes del motor y los recibos de cada destinatario. Sí
media:read Descargar lo que llega. Sí
media:write Subir lo que manda un agente. Sí
events:read Leer el ledger, abrir un stream y listar las entregas. Sí
subscriptions:manage Crear, probar y rotar su webhook. Si el CRM administra su webhook
deliveries:replay Pedir el reenvío de entregas fallidas. Recomendado
history:read, history:manage Ver y pedir el historial. Si usa el historial
presence:write Mostrar «escribiendo…» al contacto. Opcional
contacts:read Resolver un @lid a su número cuando el motor lo conoce. Opcional

Si las instancias las crea y vincula un operador desde el panel, quita instances:create, instances:pair e instances:logout. El CRM no necesita los scopes de grupos, canales, estados, perfil, privacidad ni etiquetas de WhatsApp para atender chats. La lista completa está en Apps, instancias y scopes.

Cada número de WhatsApp es una instancia. Al crearla, pon en owner_ref la referencia del dueño en el dominio del CRM: el ID de la sucursal, por ejemplo. Es un texto opaco de hasta 200 caracteres: el motor no le da significado, solo lo guarda y lo devuelve en cada lectura de la instancia. El PATCH de /v1 no lo cambia.

En el CRM, guarda la relación en tu propia tabla (un canal de la sucursal ↔ instance_id) y úsala para enrutar cada evento: el sobre trae instance_id, nunca la sucursal. owner_ref te sirve para reconstruir esa relación (al listar las instancias de la app) y para que un operador reconozca la instancia en el panel.

Ventana de terminal
curl -X POST https://motor.ejemplo.com/v1/instances \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: alta-canal-8a1d2c3e" \
-d '{"nombre": "Sucursal centro", "owner_ref": "sucursal-8a1d2c3e"}'

El motor es el transporte: sabe de instancias, chats, mensajes, recibos y medios de WhatsApp. No sabe de tickets, agentes, colas, asignaciones, etiquetas del CRM, notas internas ni «conversación resuelta». Todo eso es del CRM, en sus tablas.

Algunas cosas se parecen y no son lo mismo:

  • Archivar un chat en WhatsApp no es resolver una conversación del CRM.
  • Marcar leído en WhatsApp (POST /v1/instances/{id}/read) no es «el agente ya lo vio»: es el recibo que ve el contacto. La lectura de cada agente es del CRM.
  • Las etiquetas de WhatsApp Business no son las etiquetas del CRM.
  • Los contactos del motor son de transporte (lo que sabe el teléfono). La ficha del cliente, sus datos y las fusiones son del CRM.

Los dos entregan el mismo sobre, «al menos una vez» y en orden de seq dentro de cada instancia. Elige uno como entrada principal:

Webhook Stream (SSE o WebSocket)
Quién inicia El motor hace un POST firmado a tu URL. Tu proceso abre la conexión y lee.
Conviene cuando La API del CRM tiene una URL estable en la red interna. El consumidor no puede recibir conexiones (un worker sin puerto).
Si el CRM se cae El motor reintenta hasta 72 h y luego la entrega queda fallida. Reanudas con el cursor que guardaste.
Requisitos El host en los destinos permitidos; secreto de la suscripción. events:read; un tope de streams abiertos por app (5 de fábrica).
Lo que tienes que guardar Nada aparte del inbox. El cursor, después de procesar cada evento.

Para Mi CRM, la entrada es un webhook a su API, y el ledger (GET …/events) es la red de seguridad para ponerse al día al arrancar y rellenar huecos. Puedes usar las dos entradas a la vez sin riesgo: el inbox deduplica por event_id.

Suscríbete a todos los tipos (tipos vacío) y deja ordenado en true. Así un salto en seq siempre es un hueco, nunca un tipo que filtraste; los tipos que el CRM no usa se guardan en el inbox sin efecto. Los detalles de cada canal están en Webhooks y en Streams SSE y WebSocket.

El inbox: deduplicar en la misma transacción

Sección titulada «El inbox: deduplicar en la misma transacción»

Cada evento entra al CRM por un inbox: una tabla con clave única por event_id. Guardar la fila del inbox y aplicar el efecto (crear el mensaje, subir un estado) van en la misma transacción, y el webhook responde 2xx solo después del commit:

  • Si el mismo evento llega dos veces (un reintento, un replay, el ledger y el webhook a la vez), la segunda vez el INSERT choca con la clave y el efecto no se repite.
  • Si el efecto falla, no queda nada guardado, el webhook responde 5xx y el motor reintenta.
  • Si tu proceso cae después del commit y antes de responder, el motor reentrega el mismo evento y el inbox lo descarta.

El receptor del SDK de Go (webhook.Receptor con webhook.InboxSQL) hace exactamente eso. En otros lenguajes, reprodúcelo: firma sobre el cuerpo crudo, INSERT … ON CONFLICT DO NOTHING en el inbox y, solo si insertó, el efecto, todo en una transacción.

Con ordenado: true, el motor no entrega un evento de una instancia mientras quede pendiente uno anterior de esa instancia. Entre instancias no hay orden común, y no lo necesitas: cada conversación es de una sola instancia.

Dos casos rompen el orden y el CRM tiene que tolerarlos:

  • Una entrega fallida no bloquea el carril: si respondiste 4xx a un evento (o se agotaron sus 72 h), los siguientes siguen saliendo. Ahí aparece un hueco en seq.
  • Un replay es su propio carril: reenvía hechos viejos que pueden llegar después de otros más nuevos.

Por eso el efecto de cada evento es idempotente y monótono (un estado que solo sube, una edición que solo avanza de versión) y el CRM lleva, por instancia, un cursor contiguo: el mayor seq hasta el que no falta nada. El efecto bloquea la fila del cursor (FOR UPDATE), así dos eventos de la misma instancia nunca se aplican a la vez. La tabla y el SQL del cursor están en la sección siguiente.

Ventana de terminal
# La suscripción del CRM: todos los tipos, en orden.
curl -X POST https://motor.ejemplo.com/v1/subscriptions \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"nombre": "Mi CRM",
"url": "https://crm.ejemplo.com/webhooks/motor",
"ordenado": true
}'
# Guarda secreto.key_id y secreto.valor sellados: se muestran una sola vez.

Estas son las tablas de dominio de Mi CRM que tocan al motor. Son un ejemplo: los nombres y las columnas de tu CRM pueden ser otros, pero las claves son las que importan.

Esquema de ejemplo del CRM (Postgres)
-- El cursor contiguo de cada instancia: todo lo que tiene seq <= seq_contiguo ya se aplicó.
CREATE TABLE crm_cursores (
instancia_id uuid PRIMARY KEY,
seq_contiguo bigint NOT NULL DEFAULT 0,
seq_visto bigint NOT NULL DEFAULT 0 -- el mayor seq que llegó
);
CREATE INDEX ON mw_inbox (instancia, seq); -- el inbox del SDK de Go
-- Un número de una sucursal: una instancia del motor.
CREATE TABLE canales (
id uuid PRIMARY KEY,
sucursal_id uuid NOT NULL,
instancia_id uuid NOT NULL UNIQUE,
estado text NOT NULL DEFAULT 'nuevo', -- el último state.changed
generacion bigint NOT NULL DEFAULT 0
);
-- Una conversación por chat de WhatsApp, por canal.
CREATE TABLE conversaciones (
id uuid PRIMARY KEY,
canal_id uuid NOT NULL REFERENCES canales (id),
peer_ref text NOT NULL, -- data.chat.peer_ref
es_grupo boolean NOT NULL,
contacto_id uuid, -- la ficha del CRM (la decides tú)
estado text NOT NULL DEFAULT 'abierta', -- del CRM: abierta, resuelta…
asignada_a uuid, -- del CRM: el agente
UNIQUE (canal_id, peer_ref)
);
CREATE TABLE mensajes (
id uuid PRIMARY KEY,
conversacion_id uuid NOT NULL REFERENCES conversaciones (id),
provider_id text, -- null hasta que el envío del CRM sale
from_me boolean NOT NULL,
command_id uuid UNIQUE, -- solo los que mandó el CRM
autor_peer_ref text, -- en un grupo, quién escribió
tipo text NOT NULL, -- contenido.tipo, también no_soportado
texto text,
no_soportado text, -- contenido.no_soportado
media_id uuid,
estado text NOT NULL,
rango smallint NOT NULL DEFAULT 0, -- para que el estado solo suba
version_edicion int NOT NULL DEFAULT 0,
borrado boolean NOT NULL DEFAULT false,
origen text NOT NULL, -- live, history u operator
momento timestamptz NOT NULL,
UNIQUE (conversacion_id, provider_id, from_me)
);
CREATE TABLE reacciones (
mensaje_id uuid NOT NULL REFERENCES mensajes (id),
autor text NOT NULL, -- peer_ref de quien reaccionó, o 'yo'
emoji text NOT NULL, -- vacío: la quitó
version int NOT NULL,
PRIMARY KEY (mensaje_id, autor)
);
-- AVANZAR_CURSOR ($1 = instancia, $2 = seq del evento): sube seq_contiguo
-- mientras el siguiente seq ya esté en el inbox.
WITH RECURSIVE s (n) AS (
SELECT seq_contiguo FROM crm_cursores WHERE instancia_id = $1
UNION ALL
SELECT s.n + 1 FROM s
WHERE EXISTS (SELECT 1 FROM mw_inbox WHERE instancia = $1 AND seq = s.n + 1)
)
UPDATE crm_cursores
SET seq_contiguo = (SELECT max(n) FROM s), seq_visto = GREATEST(seq_visto, $2)
WHERE instancia_id = $1;

La clave de una conversación es (instancia, data.chat.peer_ref). El JID de un mismo contacto cambia: WhatsApp puede mandarlo por su número (CONTACTO@s.whatsapp.net) o por su LID (CONTACTO@lid). El motor une los dos en el mismo peer y su peer_ref no cambia; los otros identificadores comprobados viajan en aliases. Con el JID como clave tendrías dos conversaciones del mismo cliente. Lo explican El peer y Identidades: PN, LID y peer_ref.

  • peer_ref vale dentro de una instancia. El mismo cliente que escribe a dos sucursales tiene dos peer_ref: son dos conversaciones (una por canal) que el CRM puede colgar de la misma ficha de contacto.
  • La ficha del cliente es del CRM. No fusiones contactos por nombre_perfil (lo elige el contacto y cambia); si necesitas el número de un @lid, GET /v1/instances/{id}/identities (scope contacts:read) dice lo que el motor conoce.
  • En un grupo, data.chat es el grupo (clase: grupo, es_grupo: true) y data.autor, quien escribió. La conversación es el grupo; el autor va en cada mensaje.
  • Para responder, usa el mismo peer_ref en destino.peer_ref.
  • Si el motor vio a un contacto por separado (por su número y por su LID) antes de saber que era el mismo, tendrás dos conversaciones hasta que llegue un peer.merged: mueve los mensajes de las conversaciones de unidos a la del peer que queda y borra las otras. Los peer_ref de unidos ya no sirven para responder (422).

provider_id es el ID del mensaje en WhatsApp. Dentro de una conversación, un mensaje es (provider_id, from_me): la misma clave que usa el motor para no duplicarlo. Con un UNIQUE sobre esas columnas, una reentrega no crea otro mensaje.

  • Entrante (message.received, from_me: false): crea la conversación si no existe y el mensaje con estado: recibido.
  • Enviado desde el teléfono (message.received, from_me: true, origin: live): alguien escribió desde el teléfono o desde otro dispositivo vinculado. Muéstralo como saliente, «desde el teléfono».
  • Enviado por el CRM: no llega como message.received. El eco de un envío del motor se concilia con su envío y no se duplica. El mensaje ya existe en el CRM (lo creaste al aceptar el envío) y su provider_id llega con message.sent.
  • Importado del historial (origin: history): es un mensaje como cualquier otro, con la misma identidad que en vivo. No dispares notificaciones, asignaciones ni respuestas automáticas por él.

message.updated con operacion: estado trae el estado nuevo de un mensaje (provider_id, from_me). Los recibos pueden llegar fuera de orden (un leido antes que su entregado), así que guarda el rango y actualiza solo si sube:

estado Rango Qué ve el agente
pendiente, fallido 0 Reloj o error
recibido, enviado 1 Un check
entregado 2 Dos checks
leido 3 Dos checks de color
reproducido 4 Nota de voz o video escuchado

Un recibo que no llegó puede no llegar nunca: si el contacto apagó las confirmaciones de lectura, no hay leido. En un grupo, el estado del mensaje es el mejor recibo; el detalle por participante está en GET /v1/instances/{id}/messages/{provider_id}/receipts.

  • Edición: message.updated con operacion: edicion, el objetivo (el mensaje editado: provider_id y de_mi), el contenido nuevo y version. Aplícala solo si version es mayor que la que tienes: la última es la vigente. Guarda el texto anterior si tu CRM muestra «editado».
  • Borrado para todos: message.revoked, con el objetivo. Marca el mensaje como borrado; no lo elimines de la conversación del agente si tu política dice conservarlo.
  • Reacción: message.reaction, con el objetivo, la reaccion (el emoji; vacía si la quitó) y version. Hay una reacción vigente por autor y mensaje.

El objetivo se busca en la misma conversación por (objetivo.provider_id, objetivo.de_mi). Si todavía no está (llegó antes la reacción que el mensaje, por un replay), guarda la operación y aplícala cuando llegue el mensaje, o reléela del ledger. Los payloads están en message.updated y message.reaction.

Un tipo de mensaje que el motor todavía no normaliza llega igual, como message.received con contenido.tipo: "no_soportado" y el tipo del proveedor en contenido.no_soportado. Nunca con un texto inventado. Guárdalo como mensaje (para que la conversación no tenga un hueco) y muéstralo como «Mensaje no compatible: ábrelo en el teléfono». Contar los no_soportado por tipo te dice qué conviene pedir que el motor soporte. Más en Limitaciones de WhatsApp.

Ventana de terminal
# El store del motor sirve para revisar una conversación contra la del CRM
# (no es un archivo para siempre: lo que el motor no capturó no aparece).
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/messages?peer_ref=PEER_REF&limit=50" \
-H "Authorization: Bearer TU_API_KEY"

Cuando un agente escribe, el CRM primero guarda el mensaje en su base (con estado pendiente y su propio ID) y después manda el comando al motor, desde un worker que reintenta. Si el CRM se cae entre las dos cosas, el worker retoma el envío con la misma clave y no duplica.

Es obligatoria al enviar y vale por app e instancia (hasta 200 caracteres). Derívala de algo que no cambie entre reintentos:

  • Del mensaje del agente: crm-msg-<id del mensaje en el CRM>. Es el caso normal de una bandeja: el ID nace al guardar el mensaje.
  • Del evento que lo origina: <event_id>:<n>, con n el número de respuesta (1, 2…). Para una respuesta automática (un saludo fuera de horario, un acuse): si el CRM procesa dos veces el mismo message.received, el motor manda la respuesta una vez.

Repetir el pedido con la misma clave devuelve el mismo comando; la misma clave con otro contenido es 409. Una clave nueva es un mensaje nuevo: no la generes en cada reintento.

Dos campos más protegen al agente:

  • generacion_esperada: la generacion del canal que el CRM conoce. Si el número se volvió a vincular, el envío responde 409 (o termina fallido con generacion_cambiada si cambió después de aceptarlo) en lugar de salir por una sesión nueva.
  • expires_at: si el mensaje no salió antes (por ejemplo, el número estuvo caído una hora), no sale: fallido con comando_vencido. Úsalo para que una respuesta vieja no llegue fuera de contexto.

POST /v1/instances/{id}/messages responde 202 con el comando en_cola. Guarda el command_id en el mensaje del CRM. Si el ritmo del número no deja enviarlo ya, la respuesta es 429 con el comando guardado y next_try: no reintentes, ya está en cola. Con la cola llena (429 sin command_id) o un error de validación (422), no se guardó nada.

El resultado llega por eventos, con el command_id en el sobre:

Evento El comando Qué hace el CRM Qué ve el agente
message.sent enviado Guarda data.provider_id en el mensaje. Un check.
message.updated — Sube el estado por provider_id (solo si sube). Dos checks, de color al leerlo.
command.failed fallido Guarda error_code y stage. «No se envió» y Reintentar (con clave nueva: no salió).
command.ambiguous ambiguo Guarda provider_message_id; no reintentes solo. «Sin confirmar»; Reenviar solo con confirmación (puede duplicar).
message.sent con conciliado: true enviado Igual que message.sent. Pasa de «sin confirmar» a un check.
command.cancelled cancelado Marca el mensaje como cancelado. «Cancelado».

Mientras el comando siga aceptado o en_cola, el agente puede cancelarlo (POST /v1/commands/{id}/cancel); si ya entró al envío, la respuesta es 409 porque pudo salir. GET /v1/commands/{id} da el estado en cualquier momento, con su provider_message_id una vez que el motor lo fijó.

Algunos error_code de un envío y cómo mostrarlos:

error_code Qué decirle al agente
destino_invalido El número no está en WhatsApp o el destino no sirve.
rechazado_por_whatsapp WhatsApp rechazó el mensaje.
no_salio No se pudo enviar tras varios intentos (el número estaba sin conexión).
generacion_cambiada El número se volvió a vincular: revisa que sea el mismo antes de reenviar.
comando_vencido Venció antes de salir (expires_at).
medio_no_disponible, medio_incompatible, conversion_fallida El archivo no sirvió: súbelo de nuevo o en otro formato.
Ventana de terminal
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/messages \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-msg-3f9a7c20-0000-4000-8000-0000000000e1" \
-d '{
"destino": {"peer_ref": "PEER_REF"},
"tipo": "text",
"texto": "Hola, sí hacemos envíos a domicilio. ¿A qué zona?",
"cita": {"provider_id": "PROVIDER_MESSAGE_ID"},
"generacion_esperada": 1,
"expires_at": "2026-10-24T16:10:00Z",
"correlation_id": "crm-msg-3f9a7c20-0000-4000-8000-0000000000e1"
}'
202 Accepted
{
"command_id": "5c2e7a10-0000-4000-8000-0000000000c1",
"instance_id": "8a1d2c3e-0000-4000-8000-0000000000a1",
"idempotency_key": "crm-msg-3f9a7c20-0000-4000-8000-0000000000e1",
"type": "message.text",
"generation": 1,
"status": "en_cola",
"correlation_id": "crm-msg-3f9a7c20-0000-4000-8000-0000000000e1",
"created_at": "2026-10-24T15:10:00Z",
"updated_at": "2026-10-24T15:10:00Z"
}

El efecto de los eventos del resultado busca el mensaje por command_id, que viaja en el sobre: message.sent le pone provider_id y estado enviado (si el rango sube), command.failed fallido con su error_code, y command.ambiguous lo deja «sin confirmar» con su provider_message_id (así un recibo posterior lo encuentra). Los payloads están en message.sent y command.failed.

Cuando el agente abre una conversación, puedes marcar los mensajes como leídos en WhatsApp con POST /v1/instances/{id}/read (destino y provider_ids, o hasta). Es una decisión del CRM: el contacto verá los dos checks de color.

La multimedia necesita el almacenamiento del motor configurado (Ajustes → Almacenamiento). Sin él, los envíos de medios responden 503 multimedia_deshabilitada y el texto sigue igual. La guía completa está en Multimedia.

  1. Llega message.received con contenido.medio: media_id, estado: pendiente, mime, bytes y, si es un documento, nombre. El caption va en contenido.texto. Guarda el mensaje ya: el agente ve «descargando…».
  2. El motor descarga el archivo a su almacenamiento privado y deja media.ready (con media_id, mime real, bytes, sha256 y el provider_id del mensaje) o media.failed (con motivo, por ejemplo vencido_en_whatsapp).
  3. Con media.ready, el CRM pide GET /v1/media/{id} (scope media:read): trae descarga.url, un permiso temporal (15 minutos de fábrica) que se usa sin API key. El backend del CRM descarga el archivo y lo guarda en su almacenamiento.

El permiso es una ruta del motor (/v1/media/{id}/content?permiso=…): pégala detrás de la dirección del motor. Sirve para pasarle la descarga a otro proceso interno sin darle la key; no la mandes al navegador del agente, que no llega al motor. El archivo del motor se borra a los 30 días de fábrica (Ajustes → Multimedia): el CRM tiene que tener su copia.

Ventana de terminal
# El medio y su permiso temporal (solo si está listo).
curl https://motor.ejemplo.com/v1/media/MEDIA_ID \
-H "Authorization: Bearer TU_API_KEY"
# La descarga con el permiso, sin API key.
curl -o archivo "https://motor.ejemplo.com/v1/media/MEDIA_ID/content?permiso=PERMISO"
  1. Sube el archivo con POST /v1/instances/{id}/media/uploads (scope media:write): el cuerpo es el archivo crudo (ni multipart ni base64) y Content-Type, su MIME. Para un documento, pasa ?nombre=. El motor detecta el MIME real: si no coincide con el declarado, 422 mime_falso. Responde 201 con el media_id.
  2. Manda el mensaje con tipo (image, video, document, audio o ptt para una nota de voz) y medio.media_id, con la misma regla de la Idempotency-Key. texto es el caption (un audio no lleva).

Si el CRM sube el archivo desde su worker, la subida y el envío pueden reintentarse: guarda el media_id en el mensaje del CRM después de subir, así un reintento del envío no vuelve a subir.

Ventana de terminal
curl -X POST "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/media/uploads?nombre=factura-1042.pdf" \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/pdf" \
--data-binary @factura-1042.pdf
# 201: { "media_id": "…", "estado": "listo", "mime": "application/pdf", … }
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/messages \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-msg-3f9a7c20-0000-4000-8000-0000000000e2" \
-d '{
"destino": {"peer_ref": "PEER_REF"},
"tipo": "document",
"medio": {"media_id": "MEDIA_ID", "nombre_archivo": "factura-1042.pdf"},
"texto": "Aquí tienes tu factura."
}'

WhatsApp manda al dispositivo vinculado una parte del historial del teléfono, nunca «toda la historia». El motor procesa siempre sus metadatos; importar los mensajes es una opción por instancia, apagada de fábrica. Lo importado llega como message.received con origin: history, con la misma identidad que en vivo: si un mensaje llegó en vivo y también en el historial, no se duplica. La guía completa está en Historial.

Enciende la importación antes de vincular, con PATCH /v1/instances/{id} (scope instances:manage) y su config:

  • historial_importar: true.
  • historial_dias: lo más viejo que se importa (90 días de fábrica).
  • historial_mensajes: la cuota del trabajo (5000 de fábrica, hasta 100 000).
  • historial_medios: si se descargan los medios de lo importado; si no, quedan con medio.estado: no_descargado.

Al vincular, el motor abre un trabajo inicial y avisa su avance con sync.progress, sync.completed o sync.failed (con sync_job_id, importados, duplicados, omitidos_cuota, no_soportados y progreso). Con muchas entregas de webhooks pendientes en la instancia (2000 de fábrica), la importación espera en en_pausa y sigue sola: tu receptor marca el ritmo.

Lo medido con números reales:

Caso Vistos Importados Resultado
A53 (16/10/2026), 90 días y 5000 mensajes 10 138 5000 Terminó por la cuota en unos 2 minutos y medio; 3426 quedaron fuera de la cuota y 1266 eran no soportados. Una pausa por contrapresión se reanudó sola en un minuto.
A88 (23/10/2026), revinculación con historial 31 744 5000, en 60 chats 14 687 fuera de la cuota y 3800 no soportados; el trabajo cerró solo unos 12 minutos después del último lote.

Si tus sucursales necesitan más, sube historial_mensajes antes de vincular. En el CRM, marca lo importado (columna origen) y no lo trates como mensajes nuevos.

Cuando el agente pide «mensajes anteriores» en una conversación, POST /v1/instances/{id}/history/requests (scope history:manage, Idempotency-Key obligatoria) pide al teléfono los cantidad mensajes anteriores (hasta 50, el valor de fábrica) al más viejo que el motor conoce de ese chat. Responde 202 con un comando; el trabajo tiene su mismo ID (GET /v1/instances/{id}/history/jobs/{job_id}) y termina con sync.completed o sync.failed, con el command_id en el sobre:

Resultado (motivo) Qué mostrar
pedido_atendido Los mensajes llegaron como message.received (origin: history). Ofrece otra tanda.
sin_mas_historial «No hay mensajes anteriores en el teléfono».
fuera_del_historial El chat empezó después de vincular: no hay nada que pedir.
sin_respuesta El teléfono no contestó a tiempo (10 minutos de fábrica): estaba sin conexión o en reposo, o no tiene nada más viejo de ese chat. Pide que tenga WhatsApp abierto y reintenta.

Si el motor no conoce ningún mensaje del chat, la respuesta es 409 sin_mensajes; con la importación apagada, 409 importacion_apagada; con cantidad mayor que 50, 422. Pide de a una tanda por conversación y espera su resultado antes de la siguiente.

Lo medido bajo pedido (21/10/2026, un chat 1:1 largo, tandas de 50): cada tanda tardó 3 segundos de mediana; 195 tandas seguidas trajeron 9604 mensajes (9577 importados), unos tres meses hacia atrás, sin repetir ni saltar ninguno, y el teléfono seguía marcando que quedaba más. Las demoras largas y el único sin_respuesta vinieron de la pantalla del teléfono apagada: con WhatsApp abierto, las 10 tandas siguientes contestaron en 3 a 6 segundos. El historial que llega al vincular puede traer casi un mensaje por chat; la historia larga de una conversación se pide así, por tandas.

Ventana de terminal
# Antes de vincular: encender la importación.
curl -X PATCH https://motor.ejemplo.com/v1/instances/INSTANCIA_ID \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{"config": {"historial_importar": true, "historial_dias": 90, "historial_mensajes": 5000, "historial_medios": true}}'
# Una tanda de 50 mensajes anteriores de un chat.
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/history/requests \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-historial-PEER_REF-1" \
-d '{"destino": {"peer_ref": "PEER_REF"}, "cantidad": 50}'
# El trabajo (su ID es el command_id).
curl https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/history/jobs/COMANDO_ID \
-H "Authorization: Bearer TU_API_KEY"

El ledger de cada instancia es la fuente de verdad: guarda todo, en orden de seq, sin huecos, y no borra eventos. El CRM puede caerse, perder entregas o reiniciarse: mientras sepa hasta qué seq aplicó cada instancia, se pone al día leyendo el ledger. Lo explica El ledger y seq.

El cursor de Mi CRM es seq_contiguo, en crm_cursores: el efecto de cada evento lo avanza en la misma transacción (ver el SQL de la sección 3). Así el cursor nunca dice «aplicado» de algo que no se aplicó, y nunca salta un hueco.

Antes de aceptar tráfico, para cada canal: lee GET /v1/instances/{id}/events?after_seq= desde seq_contiguo, pasa cada evento por el mismo inbox (los que ya estaban se descartan) y sigue con next_after_seq mientras has_more sea true. Después, los webhooks siguen como siempre.

Ventana de terminal
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/events?after_seq=1200&limit=500" \
-H "Authorization: Bearer TU_API_KEY"
# Repite con after_seq=next_after_seq mientras has_more sea true.

Si el CRM lee por stream en lugar de webhook, guarda el cursor del stream después de procesar cada evento y reanuda con él (Last-Event-ID o ?desde=): reanudar no salta ni repite. Hay dos formas:

  • El stream de la app (GET /v1/events/stream): una conexión para todas las instancias, con un cursor opaco. Es lo que conviene con muchos números, porque los streams abiertos por app tienen un tope (5 de fábrica).
  • El stream de una instancia (GET /v1/instances/{id}/events/stream): ahí el cursor también puede ser un seq, así que reanudas con tu seq_contiguo directamente.

Si el cursor es más viejo que la retención de reanudación (30 días de fábrica, Ajustes → Streams), la respuesta es 410 con primer_cursor e instancias_vencidas. El motor nunca salta en silencio: usar primer_cursor es aceptar el salto. Antes de usarlo, ponte al día por el ledger (que no vence) en cada instancia vencida; los eventos que el stream repita después se descartan en el inbox.

Ventana de terminal
# El stream de una instancia, reanudado desde un seq.
curl -N "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/events/stream?desde=1200" \
-H "Authorization: Bearer TU_API_KEY" \
-H "Accept: text/event-stream"
# Cada trama «evento» trae id (el cursor) y data (el sobre).

Una entrega que el CRM no aceptó queda fallida: después de 72 horas de reintentos por red o 5xx, o enseguida con un 4xx que no sea 401 o 403. Un 401 o 403 suspende la suscripción: el motor entiende que el secreto está mal. Reactívala con PATCH /v1/subscriptions/{id} y estado: activa cuando lo arregles; lo pendiente sigue saliendo.

Las fallidas no se pierden: el evento sigue en el ledger. Tienes dos caminos:

  • El barrido por el ledger (lo de «Al arrancar»): lee desde seq_contiguo y rellena el hueco. No necesita ningún scope más que events:read.
  • El replay: POST /v1/subscriptions/{id}/replays (scope deliveries:replay, Idempotency-Key obligatoria) crea entregas nuevas de los mismos hechos, con el mismo event_id: un rango de seq de una instancia (hasta 10 000) o hasta 500 event_ids, con un motivo. Nunca vuelve a mandar nada a WhatsApp. Las fallidas se listan con GET /v1/deliveries?estado=fallida.
Ventana de terminal
curl "https://motor.ejemplo.com/v1/deliveries?estado=fallida&instancia_id=INSTANCIA_ID" \
-H "Authorization: Bearer TU_API_KEY"
curl -X POST https://motor.ejemplo.com/v1/subscriptions/SUSCRIPCION_ID/replays \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: replay-2026-10-24-caida-crm" \
-d '{"instancia_id": "INSTANCIA_ID", "desde_seq": 1201, "hasta_seq": 1350, "motivo": "El CRM estuvo caído"}'

Con una suscripción a todos los tipos, cada instancia avanza de uno en uno. Hay un hueco cuando:

  • seq_visto es mayor que seq_contiguo por más de unos minutos: llegó un evento posterior y falta uno anterior (una entrega fallida).
  • El motor va más adelante: GET /v1/instances/{id}/diagnostics trae pendiente.ultimo_seq (el último seq de la instancia) y pendiente.eventos_sin_entregar. Si ultimo_seq supera tu seq_contiguo y no hay entregas pendientes, a tu CRM le falta algo.

En los dos casos, el remedio es el mismo barrido: leer el ledger desde seq_contiguo. Hazlo al arrancar y cada pocos minutos como tarea programada; si no hay hueco, la lectura vuelve vacía.

Cada cambio de estado de una instancia llega como state.changed (de, a, motivo, generacion). Guarda el último en el canal y reacciona así:

a Qué pasó Qué hace el CRM
conectado El número está en línea. Cierra la alerta. Los envíos en cola salen solos.
reconectando, desconectado Se cortó el socket; el motor reconecta solo si reconexion_automatica está encendida. Aviso suave; alerta si dura más de unos minutos. Los envíos esperan en cola.
cerrado_por_telefono Alguien quitó el dispositivo vinculado desde el teléfono (logged_out). La sesión ya no sirve. Alerta crítica y volver a vincular.
reemplazado Otra conexión usa la misma sesión (stream_replaced). El motor no reconecta solo ni acepta envíos (409). Alerta crítica al operador.
bloqueado WhatsApp aplicó un baneo temporal (temporary_ban). No acepta envíos (409). Alerta; detén las respuestas automáticas hasta bloqueada_hasta.
error Un error tipificado, en motivo. Revisa el diagnóstico.
cerrado_por_operador, archivado Lo desvinculó o archivó un operador o tu app. Muestra el canal como desvinculado o quítalo de la bandeja.

El detalle (desde cuándo está caída, el último error, las alertas abiertas) está en GET /v1/instances/{id}/diagnostics, sin datos personales. Para pedir una reconexión manual, POST /v1/instances/{id}/commands con type: instance.reconnect (scope instances:manage).

Después de cerrado_por_telefono (o para cambiar de teléfono), el CRM vuelve a pedir el emparejamiento con POST /v1/instances/{id}/pairing, como en Vincular un número. La instancia y su instance_id son los mismos, así que las conversaciones del CRM siguen colgadas del mismo canal. Lo que cambia es la generación: cada pedido de emparejamiento la sube, y viaja en el sobre de cada evento (generation) y en state.changed (generacion).

  • Guarda la generacion del canal con cada state.changed y mándala como generacion_esperada en los envíos: un envío pensado para el vínculo anterior no sale por el nuevo.
  • Al volver a conectado, compara numero (GET /v1/instances/{id}) con el que tenía el canal. Si es otro número, es otra cuenta de WhatsApp: decide en el CRM qué hacer con las conversaciones abiertas antes de seguir respondiendo.
  • Muestra el reto (QR o código) solo a quien vincula y no lo guardes: el evento pairing.available avisa que hay uno, pero el reto se lee con GET /v1/instances/{id}/pairing.

Lo mínimo que el CRM debería vigilar por su cuenta, además de lo que muestra el panel del motor:

  • Un canal en cerrado_por_telefono, reemplazado o bloqueado: enseguida.
  • Un canal en desconectado o reconectando por más de unos minutos.
  • La suscripción del CRM en suspendida (GET /v1/subscriptions).
  • Un hueco que dura: seq_visto mayor que seq_contiguo, o pendiente.ultimo_seq por delante.
  • Comandos ambiguo sin conciliar y fallido que un agente no vio.
  • Medios en pendiente por mucho tiempo, o muchos media.failed.

Los procedimientos del lado del motor están en Runbooks.

9. Lista de verificación antes de producción

Sección titulada «9. Lista de verificación antes de producción»
  • Una app del motor para el CRM, con una key por proceso o entorno y solo los scopes de la tabla. El token y el secreto del webhook guardados sellados, nunca en un archivo ni en un log.
  • Cada canal guarda su instance_id; las instancias se crean con owner_ref y una Idempotency-Key derivada del canal.
  • El host del webhook del CRM está en los destinos permitidos y POST …/subscriptions/{id}/test llega firmado.
  • La suscripción cubre todos los tipos, con ordenado: true.
  • El receptor verifica la firma sobre el cuerpo crudo, guarda en el inbox (único por event_id) y aplica el efecto en la misma transacción; responde 2xx solo después del commit.
  • El CRM nunca responde 401 o 403 al motor por un error propio (eso suspende la suscripción).
  • Las conversaciones se identifican por (instancia, peer_ref) y los mensajes por (provider_id, from_me), con UNIQUE en la base.
  • Los estados solo suben; las ediciones y las reacciones solo avanzan de version.
  • no_soportado se guarda y se muestra como «mensaje no compatible».
  • Lo importado (origin: history) no notifica ni dispara respuestas automáticas.
  • El mensaje del agente se guarda antes de enviarlo; la Idempotency-Key sale de su ID (o de <event_id>:<n>) y no cambia en un reintento.
  • El envío manda generacion_esperada; las respuestas automáticas, expires_at.
  • fallido ofrece reintentar con clave nueva; ambiguo nunca se reenvía solo.
  • Los medios recibidos se copian al almacenamiento del CRM antes de su retención en el motor.
  • La importación del historial se decidió antes de vincular, con su cuota.
  • Al arrancar y cada pocos minutos, el CRM se pone al día por el ledger desde seq_contiguo.
  • Hay alertas para los estados críticos del canal, la suscripción suspendida y los huecos.
  • Las Limitaciones de WhatsApp están revisadas con quien promete funciones a los usuarios del CRM.