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.
1. El diseño
Sección titulada «1. El diseño»Una app cliente por CRM
Sección titulada «Una app cliente por CRM»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.
Los scopes mínimos
Sección titulada «Los scopes mínimos»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.
Una instancia por número, con owner_ref
Sección titulada «Una instancia por número, con owner_ref»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.
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"}'import motor "github.com/projectx27/motor-whatsapp/sdk/go"
c := motor.Nuevo("https://motor.ejemplo.com", apiKey)// La clave sale del ID del canal en el CRM: repetir el alta no crea otra instancia.inst, err := c.CrearInstancia(ctx, "alta-canal-"+canal.ID, "Sucursal centro", "sucursal-"+canal.SucursalID)if err != nil { return err}// Guarda inst.ID en el canal, en tu base.const res = await fetch('https://motor.ejemplo.com/v1/instances', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': `alta-canal-${canal.id}`, }, body: JSON.stringify({ nombre: 'Sucursal centro', owner_ref: `sucursal-${canal.sucursalId}` }),});const instancia = await res.json(); // 201 (o 200 si repetiste la clave)// Guarda instancia.id en el canal, en tu base.El dominio vive en el CRM
Sección titulada «El dominio vive en el CRM»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.
2. Recibir los eventos
Sección titulada «2. Recibir los eventos»Webhook o stream
Sección titulada «Webhook o stream»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
INSERTchoca 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.
El orden por instancia
Sección titulada «El orden por instancia»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.
# 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.import ( "context" "database/sql" "net/http"
"github.com/projectx27/motor-whatsapp/sdk/go/webhook")
inbox := webhook.InboxSQL{DB: db, Cliente: "motor-principal"} // tabla mw_inboxif err := inbox.Crear(ctx); err != nil { return err}http.Handle("/webhooks/motor", webhook.Receptor{ Verificador: webhook.Verificador{Claves: claves}, // key_id → secreto, sellados Inbox: inbox, Efecto: aplicar,})
// aplicar corre dentro de la transacción del inbox: si devuelve error, no// queda nada guardado y el motor reintenta.func aplicar(ctx context.Context, tx *sql.Tx, ev webhook.Evento) error { // Serializa la instancia: el cursor queda bloqueado hasta el commit. if _, err := tx.ExecContext(ctx, `INSERT INTO crm_cursores (instancia_id) VALUES ($1) ON CONFLICT DO NOTHING`, ev.InstanceID); err != nil { return err } if _, err := tx.ExecContext(ctx, `SELECT 1 FROM crm_cursores WHERE instancia_id = $1 FOR UPDATE`, ev.InstanceID); err != nil { return err } var err error switch ev.Type { case "message.received": err = mensajeRecibido(ctx, tx, ev) case "message.updated", "message.revoked", "message.reaction": err = mensajeCambiado(ctx, tx, ev) case "message.sent", "command.failed", "command.ambiguous": err = resultadoDeEnvio(ctx, tx, ev) case "media.ready", "media.failed": err = medioTerminado(ctx, tx, ev) case "state.changed": err = estadoDelCanal(ctx, tx, ev) } // Los demás tipos quedan en el inbox, sin efecto, y cuentan para el cursor. if err != nil { return err } _, err = tx.ExecContext(ctx, avanzarCursor, ev.InstanceID, ev.Seq) return err}// `timestampVigente` y `firmaValida` son las del Inicio rápido. `db` es tu cliente de Postgres// (por ejemplo, un Pool de `pg`): aquí solo se usa `query`.import { createServer } from 'node:http';
createServer(async (req, res) => { const partes = []; for await (const p of req) partes.push(p); const cuerpo = Buffer.concat(partes); // el cuerpo crudo, antes de parsear
if (!timestampVigente(req.headers)) { res.writeHead(408).end(); // reloj desfasado: el motor reintenta return; } if (!firmaValida(req.headers, cuerpo, claves)) { res.writeHead(401).end(); return; } const ev = JSON.parse(cuerpo); const tx = await db.connect(); try { await tx.query('BEGIN'); const { rowCount } = await tx.query( `INSERT INTO mw_inbox (cliente, instancia, event_id, seq, tipo) VALUES ('motor-principal', $1, $2, $3, $4) ON CONFLICT DO NOTHING`, [ev.instance_id, ev.event_id, ev.seq, ev.type], ); if (rowCount === 1) await aplicar(tx, ev); // solo la primera vez await tx.query('COMMIT'); res.writeHead(204).end(); // 2xx solo después del commit } catch (err) { await tx.query('ROLLBACK'); res.writeHead(500).end(); // el motor reintenta } finally { tx.release(); }}).listen(8081);
async function aplicar(tx, ev) { await tx.query('INSERT INTO crm_cursores (instancia_id) VALUES ($1) ON CONFLICT DO NOTHING', [ev.instance_id]); await tx.query('SELECT 1 FROM crm_cursores WHERE instancia_id = $1 FOR UPDATE', [ev.instance_id]); switch (ev.type) { case 'message.received': await mensajeRecibido(tx, ev); break; case 'message.updated': case 'message.revoked': case 'message.reaction': await mensajeCambiado(tx, ev); break; case 'message.sent': case 'command.failed': case 'command.ambiguous': await resultadoDeEnvio(tx, ev); break; case 'media.ready': case 'media.failed': await medioTerminado(tx, ev); break; case 'state.changed': await estadoDelCanal(tx, ev); break; } await tx.query(AVANZAR_CURSOR, [ev.instance_id, ev.seq]);}3. Modelar conversaciones y mensajes
Sección titulada «3. Modelar conversaciones y mensajes»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.
-- 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_cursoresSET seq_contiguo = (SELECT max(n) FROM s), seq_visto = GREATEST(seq_visto, $2)WHERE instancia_id = $1;Conversaciones por peer_ref, no por JID
Sección titulada «Conversaciones por peer_ref, no por JID»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_refvale dentro de una instancia. El mismo cliente que escribe a dos sucursales tiene dospeer_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(scopecontacts:read) dice lo que el motor conoce. - En un grupo,
data.chates el grupo (clase: grupo,es_grupo: true) ydata.autor, quien escribió. La conversación es el grupo; el autor va en cada mensaje. - Para responder, usa el mismo
peer_refendestino.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 deunidosa la delpeerque queda y borra las otras. Lospeer_refdeunidosya no sirven para responder (422).
Mensajes por provider_id
Sección titulada «Mensajes por provider_id»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 conestado: 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 suprovider_idllega conmessage.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.
Estados de entrega que solo suben
Sección titulada «Estados de entrega que solo suben»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.
Ediciones, borrados y reacciones
Sección titulada «Ediciones, borrados y reacciones»- Edición:
message.updatedconoperacion: edicion, elobjetivo(el mensaje editado:provider_idyde_mi), elcontenidonuevo yversion. Aplícala solo siversiones 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 elobjetivo. 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 elobjetivo, lareaccion(el emoji; vacía si la quitó) yversion. 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.
Lo que el motor no reconoce
Sección titulada «Lo que el motor no reconoce»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.
# 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"// motor.Peer es el peer del SDK (peer_ref, jid, clase, aliases).type objetivo struct { ProviderID string `json:"provider_id"` DeMi bool `json:"de_mi"`}
type datosMensaje struct { Chat motor.Peer `json:"chat"` Autor *motor.Peer `json:"autor"` ProviderID string `json:"provider_id"` FromMe bool `json:"from_me"` EsGrupo bool `json:"es_grupo"` Operacion string `json:"operacion"` Objetivo *objetivo `json:"objetivo"` Contenido *struct { Tipo string `json:"tipo"` Texto string `json:"texto"` NoSoportado string `json:"no_soportado"` Medio *struct { MediaID string `json:"media_id"` } `json:"medio"` } `json:"contenido"` Estado string `json:"estado"` Version int `json:"version"` Momento time.Time `json:"momento"`}
var rango = map[string]int{"pendiente": 0, "fallido": 0, "recibido": 1, "enviado": 1, "entregado": 2, "leido": 3, "reproducido": 4}
func mensajeRecibido(ctx context.Context, tx *sql.Tx, ev webhook.Evento) error { var d datosMensaje if err := json.Unmarshal(ev.Data, &d); err != nil || d.Contenido == nil { return err // sin contenido: un mensaje temporal vencido o redactado } conv, err := conversacion(ctx, tx, ev.InstanceID, d.Chat.PeerRef, d.EsGrupo) // crea si no existe if err != nil { return err } var medio *string if d.Contenido.Medio != nil { medio = &d.Contenido.Medio.MediaID } var autor *string if d.Autor != nil { autor = &d.Autor.PeerRef } estado := "recibido" if d.FromMe { estado = "enviado" // desde el teléfono } _, err = tx.ExecContext(ctx, `INSERT INTO mensajes (id, conversacion_id, provider_id, from_me, autor_peer_ref, tipo, texto, no_soportado, media_id, estado, rango, origen, momento) VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, NULLIF($7, ''), $8, $9, 1, $10, $11) ON CONFLICT (conversacion_id, provider_id, from_me) DO NOTHING`, conv, d.ProviderID, d.FromMe, autor, d.Contenido.Tipo, d.Contenido.Texto, d.Contenido.NoSoportado, medio, estado, ev.Origin, d.Momento) if err == nil && ev.Origin == "live" && !d.FromMe { err = avisarAlAgente(ctx, tx, conv) // nunca por lo importado del historial } return err}
func mensajeCambiado(ctx context.Context, tx *sql.Tx, ev webhook.Evento) error { var d datosMensaje if err := json.Unmarshal(ev.Data, &d); err != nil { return err } conv, err := conversacion(ctx, tx, ev.InstanceID, d.Chat.PeerRef, d.EsGrupo) if err != nil { return err } switch { case ev.Type == "message.updated" && d.Operacion == "estado": _, err = tx.ExecContext(ctx, `UPDATE mensajes SET estado = $1, rango = $2 WHERE conversacion_id = $3 AND provider_id = $4 AND from_me = $5 AND rango < $2`, d.Estado, rango[d.Estado], conv, d.ProviderID, d.FromMe) case ev.Type == "message.updated" && d.Operacion == "edicion" && d.Contenido != nil: _, err = tx.ExecContext(ctx, `UPDATE mensajes SET texto = $1, version_edicion = $2 WHERE conversacion_id = $3 AND provider_id = $4 AND from_me = $5 AND version_edicion < $2`, d.Contenido.Texto, d.Version, conv, d.Objetivo.ProviderID, d.Objetivo.DeMi) case ev.Type == "message.revoked": _, err = tx.ExecContext(ctx, `UPDATE mensajes SET borrado = true WHERE conversacion_id = $1 AND provider_id = $2 AND from_me = $3`, conv, d.Objetivo.ProviderID, d.Objetivo.DeMi) case ev.Type == "message.reaction": err = reaccion(ctx, tx, conv, d) // upsert por (mensaje, autor) si version sube } return err}const RANGO = { pendiente: 0, fallido: 0, recibido: 1, enviado: 1, entregado: 2, leido: 3, reproducido: 4 };
async function mensajeRecibido(tx, ev) { const d = ev.data; if (!d.contenido) return; // un mensaje temporal vencido o redactado const conv = await conversacion(tx, ev.instance_id, d.chat.peer_ref, d.es_grupo); // crea si no existe await tx.query( `INSERT INTO mensajes (id, conversacion_id, provider_id, from_me, autor_peer_ref, tipo, texto, no_soportado, media_id, estado, rango, origen, momento) VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11) ON CONFLICT (conversacion_id, provider_id, from_me) DO NOTHING`, [conv, d.provider_id, d.from_me, d.autor?.peer_ref ?? null, d.contenido.tipo, d.contenido.texto ?? null, d.contenido.no_soportado ?? null, d.contenido.medio?.media_id ?? null, d.from_me ? 'enviado' : 'recibido', ev.origin, d.momento], ); if (ev.origin === 'live' && !d.from_me) await avisarAlAgente(tx, conv); // nunca por el historial}
async function mensajeCambiado(tx, ev) { const d = ev.data; const conv = await conversacion(tx, ev.instance_id, d.chat.peer_ref, d.es_grupo); if (ev.type === 'message.updated' && d.operacion === 'estado') { await tx.query( `UPDATE mensajes SET estado = $1, rango = $2 WHERE conversacion_id = $3 AND provider_id = $4 AND from_me = $5 AND rango < $2`, [d.estado, RANGO[d.estado], conv, d.provider_id, d.from_me], ); } else if (ev.type === 'message.updated' && d.operacion === 'edicion' && d.contenido) { await tx.query( `UPDATE mensajes SET texto = $1, version_edicion = $2 WHERE conversacion_id = $3 AND provider_id = $4 AND from_me = $5 AND version_edicion < $2`, [d.contenido.texto, d.version, conv, d.objetivo.provider_id, d.objetivo.de_mi], ); } else if (ev.type === 'message.revoked') { await tx.query( 'UPDATE mensajes SET borrado = true WHERE conversacion_id = $1 AND provider_id = $2 AND from_me = $3', [conv, d.objetivo.provider_id, d.objetivo.de_mi], ); } else if (ev.type === 'message.reaction') { await reaccion(tx, conv, d); // upsert por (mensaje, autor) si version sube }}4. Responder
Sección titulada «4. Responder»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.
La Idempotency-Key
Sección titulada «La Idempotency-Key»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>, connel 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 mismomessage.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: lageneraciondel canal que el CRM conoce. Si el número se volvió a vincular, el envío responde 409 (o terminafallidocongeneracion_cambiadasi 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:fallidoconcomando_vencido. Úsalo para que una respuesta vieja no llegue fuera de contexto.
El 202 y la conciliación
Sección titulada «El 202 y la conciliación»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. |
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" }'// El SDK de Go trae el texto (EnviarTexto); generacion_esperada todavía no// está en motor.Texto: si la usas, manda el envío por HTTP directo.vence := time.Now().Add(time.Hour)cmd, err := c.EnviarTexto(ctx, canal.InstanciaID, "crm-msg-"+msg.ID, motor.Texto{ Destino: motor.Destino{PeerRef: conv.PeerRef}, Texto: msg.Texto, Cita: &motor.Cita{ProviderID: citado.ProviderID}, ExpiresAt: &vence, CorrelationID: "crm-msg-" + msg.ID,})if err != nil { var e *motor.Error if errors.As(err, &e) && e.Status < 500 && e.Status != http.StatusTooManyRequests { return marcarFallido(msg.ID, e.Codigo(), e.Detalle) // 409, 422…: no se guardó } return err // red, 5xx o cola llena: el worker reintenta con la MISMA clave}// 202 o 429 con el comando guardado: cmd.Estado es "en_cola" y cmd.NextTry dice cuándo sale.return guardarComando(msg.ID, cmd.ID)const res = await fetch(`https://motor.ejemplo.com/v1/instances/${canal.instanciaId}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': `crm-msg-${msg.id}`, }, body: JSON.stringify({ destino: { peer_ref: conv.peerRef }, tipo: 'text', texto: msg.texto, generacion_esperada: canal.generacion, expires_at: new Date(Date.now() + 3600_000).toISOString(), correlation_id: `crm-msg-${msg.id}`, }),});const cuerpo = await res.json();if (res.status === 202 || (res.status === 429 && cuerpo.command_id)) { await guardarComando(msg.id, cuerpo.command_id); // en cola: el resultado llega por eventos} else if (res.status >= 500 || res.status === 429) { throw new Error('reintentar con la misma clave'); // 5xx o cola llena} else { await marcarFallido(msg.id, cuerpo.detail); // 409 o 422: no se guardó}{ "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.
5. Multimedia
Sección titulada «5. Multimedia»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.
Recibir
Sección titulada «Recibir»- Llega
message.receivedconcontenido.medio:media_id,estado: pendiente,mime,bytesy, si es un documento,nombre. El caption va encontenido.texto. Guarda el mensaje ya: el agente ve «descargando…». - El motor descarga el archivo a su almacenamiento privado y deja
media.ready(conmedia_id,mimereal,bytes,sha256y elprovider_iddel mensaje) omedia.failed(conmotivo, por ejemplovencido_en_whatsapp). - Con
media.ready, el CRM pideGET /v1/media/{id}(scopemedia:read): traedescarga.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.
# 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"// El SDK de Go no trae multimedia: es HTTP directo.req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+"/v1/media/"+mediaID, nil)req.Header.Set("Authorization", "Bearer "+c.Token)res, err := c.HTTP.Do(req)if err != nil { return err}var medio struct { MIME string `json:"mime"` Descarga *struct { URL string `json:"url"` } `json:"descarga"`}err = json.NewDecoder(res.Body).Decode(&medio)res.Body.Close()if err != nil || medio.Descarga == nil { return err // todavía no está listo}// Con el permiso no hace falta la key: puede bajarlo otro proceso.arch, err := http.Get(c.Base + medio.Descarga.URL)if err != nil { return err}defer arch.Body.Close()return guardarEnElCRM(ctx, mediaID, medio.MIME, arch.Body)const base = 'https://motor.ejemplo.com';const medio = await (await fetch(`${base}/v1/media/${mediaId}`, { headers: { Authorization: `Bearer ${apiKey}` },})).json();if (!medio.descarga) return; // todavía no está listo
// Con el permiso no hace falta la key.const archivo = await fetch(base + medio.descarga.url);await guardarEnElCRM(mediaId, medio.mime, Buffer.from(await archivo.arrayBuffer()));- Sube el archivo con
POST /v1/instances/{id}/media/uploads(scopemedia:write): el cuerpo es el archivo crudo (ni multipart ni base64) yContent-Type, su MIME. Para un documento, pasa?nombre=. El motor detecta el MIME real: si no coincide con el declarado, 422mime_falso. Responde 201 con elmedia_id. - Manda el mensaje con
tipo(image,video,document,audioopttpara una nota de voz) ymedio.media_id, con la misma regla de laIdempotency-Key.textoes 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.
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." }'// El SDK de Go no trae multimedia: es HTTP directo.func subir(ctx context.Context, c *motor.Cliente, instanciaID, nombre, mime string, archivo io.Reader) (string, error) { ruta := c.Base + "/v1/instances/" + instanciaID + "/media/uploads?nombre=" + url.QueryEscape(nombre) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, ruta, archivo) req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("Content-Type", mime) res, err := c.HTTP.Do(req) if err != nil { return "", err } defer res.Body.Close() if res.StatusCode != http.StatusCreated { return "", fmt.Errorf("subida: %d", res.StatusCode) // 413, 422 mime_falso, 429 cuota_excedida… } var m struct { MediaID string `json:"media_id"` } return m.MediaID, json.NewDecoder(res.Body).Decode(&m)}
// El envío: el mismo POST …/messages de un texto, con tipo y medio.cuerpo, _ := json.Marshal(map[string]any{ "destino": map[string]string{"peer_ref": conv.PeerRef}, "tipo": "document", "medio": map[string]string{"media_id": mediaID, "nombre_archivo": "factura-1042.pdf"}, "texto": "Aquí tienes tu factura.",})req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/v1/instances/"+canal.InstanciaID+"/messages", bytes.NewReader(cuerpo))req.Header.Set("Authorization", "Bearer "+c.Token)req.Header.Set("Content-Type", "application/json")req.Header.Set("Idempotency-Key", "crm-msg-"+msg.ID)res, err := c.HTTP.Do(req) // 202 con el comandoimport { readFile } from 'node:fs/promises';
const base = `https://motor.ejemplo.com/v1/instances/${canal.instanciaId}`;const auth = { Authorization: `Bearer ${apiKey}` };
const subida = await fetch(`${base}/media/uploads?nombre=${encodeURIComponent('factura-1042.pdf')}`, { method: 'POST', headers: { ...auth, 'Content-Type': 'application/pdf' }, body: await readFile('factura-1042.pdf'),});const { media_id } = await subida.json(); // 201
await fetch(`${base}/messages`, { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json', 'Idempotency-Key': `crm-msg-${msg.id}` }, body: JSON.stringify({ destino: { peer_ref: conv.peerRef }, tipo: 'document', medio: { media_id, nombre_archivo: 'factura-1042.pdf' }, texto: 'Aquí tienes tu factura.', }),}); // 2026. Historial
Sección titulada «6. Historial»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.
Al vincular
Sección titulada «Al vincular»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 conmedio.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.
Bajo pedido, por tandas
Sección titulada «Bajo pedido, por tandas»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.
# 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 SDK de Go no trae el historial: es HTTP directo.func pedir(ctx context.Context, c *motor.Cliente, metodo, ruta, clave string, cuerpo any) (*http.Response, error) { b, _ := json.Marshal(cuerpo) req, _ := http.NewRequestWithContext(ctx, metodo, c.Base+ruta, bytes.NewReader(b)) req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("Content-Type", "application/json") if clave != "" { req.Header.Set("Idempotency-Key", clave) } return c.HTTP.Do(req)}
// Antes de vincular._, err := pedir(ctx, c, http.MethodPatch, "/v1/instances/"+inst.ID, "", map[string]any{ "config": map[string]any{"historial_importar": true, "historial_dias": 90, "historial_mensajes": 5000},})
// Una tanda: la clave cuenta las tandas de esa conversación.res, err := pedir(ctx, c, http.MethodPost, "/v1/instances/"+inst.ID+"/history/requests", fmt.Sprintf("crm-historial-%s-%d", conv.ID, conv.Tandas+1), map[string]any{"destino": map[string]string{"peer_ref": conv.PeerRef}, "cantidad": 50})// 202: el resultado llega como sync.completed o sync.failed con el command_id.const base = `https://motor.ejemplo.com/v1/instances/${instancia.id}`;const auth = { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' };
// Antes de vincular.await fetch(base, { method: 'PATCH', headers: auth, body: JSON.stringify({ config: { historial_importar: true, historial_dias: 90, historial_mensajes: 5000 } }),});
// Una tanda: la clave cuenta las tandas de esa conversación.const res = await fetch(`${base}/history/requests`, { method: 'POST', headers: { ...auth, 'Idempotency-Key': `crm-historial-${conv.id}-${conv.tandas + 1}` }, body: JSON.stringify({ destino: { peer_ref: conv.peerRef }, cantidad: 50 }),});// 202: { command_id, … }; 409 sin_mensajes o importacion_apagada.7. Reconexión con seq
Sección titulada «7. Reconexión con seq»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 por instancia
Sección titulada «El cursor por instancia»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.
Al arrancar
Sección titulada «Al arrancar»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.
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.// ponerseAlDia lee el ledger desde el cursor contiguo y lo pasa por el inbox.func ponerseAlDia(ctx context.Context, c *motor.Cliente, inbox webhook.InboxSQL, instanciaID string) error { desde, err := seqContiguo(ctx, instanciaID) if err != nil { return err } for { p, err := c.Eventos(ctx, instanciaID, desde, nil, 500) if err != nil { return err } for _, ev := range p.Eventos { // motor.Evento y webhook.Evento tienen los mismos campos. if _, err := inbox.Aceptar(ctx, webhook.Evento(ev), aplicar); err != nil { return err } } if !p.HasMore { return nil } desde = p.NextAfterSeq }}async function ponerseAlDia(instanciaId) { let desde = await seqContiguo(instanciaId); for (;;) { const url = `https://motor.ejemplo.com/v1/instances/${instanciaId}/events?after_seq=${desde}&limit=500`; const pagina = await (await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } })).json(); for (const ev of pagina.events) await aceptarEnInbox(ev); // el mismo INSERT + aplicar del webhook if (!pagina.has_more) return; desde = pagina.next_after_seq; }}Con un stream
Sección titulada «Con un stream»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 unseq, así que reanudas con tuseq_contiguodirectamente.
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.
# 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).import "github.com/projectx27/motor-whatsapp/sdk/go/stream"
abrir := func(cursor string) *stream.Cliente { return stream.Abrir(stream.Opciones{Base: "https://motor.ejemplo.com", Token: apiKey, Cursor: cursor})}s := abrir(cursorGuardado) // vacío la primera vez: desde ahoradefer func() { s.Cerrar() }()for { e, err := s.Siguiente(ctx) // reconecta solo ante un corte reanudable var vencido *stream.CursorVencido if errors.As(err, &vencido) { for _, id := range vencido.Instancias { if err := ponerseAlDia(ctx, c, inbox, id); err != nil { return err } } s.Cerrar() s = abrir(vencido.Primer) continue } if err != nil { return err // *stream.Cerrado (credencial_revocada, sin_scope), stream.ErrRechazado… } if _, err := inbox.Aceptar(ctx, e.Evento, aplicar); err != nil { return err } guardarCursorStream(e.Cursor) // después de procesar}// Node 18+ no trae EventSource: se leen las tramas SSE del cuerpo.async function* tramas(res) { const dec = new TextDecoder(); let buf = ''; for await (const trozo of res.body) { buf += dec.decode(trozo, { stream: true }); let fin; while ((fin = buf.indexOf('\n\n')) >= 0) { const t = { id: '', tipo: '', datos: '' }; for (const linea of buf.slice(0, fin).split('\n')) { if (linea.startsWith('id:')) t.id = linea.slice(3).trim(); else if (linea.startsWith('event:')) t.tipo = linea.slice(6).trim(); else if (linea.startsWith('data:')) t.datos += linea.slice(5).replace(/^ /, ''); } buf = buf.slice(fin + 2); yield t; } }}
const desde = cursorGuardado ? `?desde=${encodeURIComponent(cursorGuardado)}` : '';const res = await fetch(`https://motor.ejemplo.com/v1/events/stream${desde}`, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'text/event-stream' },});if (res.status === 410) { const { primer_cursor, instancias_vencidas } = await res.json(); for (const id of instancias_vencidas) await ponerseAlDia(id); await guardarCursorStream(primer_cursor); // y vuelve a abrir} else { for await (const t of tramas(res)) { if (t.tipo === 'evento') { await aceptarEnInbox(JSON.parse(t.datos)); await guardarCursorStream(t.id); // después de procesar } else if (t.tipo === 'cierre') { const { motivo, reanudable } = JSON.parse(t.datos); if (!reanudable) throw new Error(`stream cerrado: ${motivo}`); break; // vuelve a abrir con el cursor guardado } }}Webhooks fallidos y replay
Sección titulada «Webhooks fallidos y replay»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_contiguoy rellena el hueco. No necesita ningún scope más queevents:read. - El replay:
POST /v1/subscriptions/{id}/replays(scopedeliveries:replay,Idempotency-Keyobligatoria) crea entregas nuevas de los mismos hechos, con el mismoevent_id: un rango deseqde una instancia (hasta 10 000) o hasta 500event_ids, con unmotivo. Nunca vuelve a mandar nada a WhatsApp. Las fallidas se listan conGET /v1/deliveries?estado=fallida.
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"}'fallidas, err := c.Entregas(ctx, "fallida")if err != nil { return err}var ids []stringfor _, e := range fallidas { if e.SuscripcionID == suscripcionID { ids = append(ids, e.EventID) }}if len(ids) > 0 { r, err := c.Replay(ctx, suscripcionID, "replay-2026-10-24-caida-crm", motor.PedidoReplay{ EventIDs: ids, // hasta 500 Motivo: "El CRM estuvo caído", }) if err != nil { return err } log.Printf("replay %s: %d entregas nuevas", r.ID, r.Entregas)}const base = 'https://motor.ejemplo.com/v1';const auth = { Authorization: `Bearer ${apiKey}` };
const { elementos } = await (await fetch(`${base}/deliveries?estado=fallida&por_pagina=200`, { headers: auth })).json();const ids = elementos.filter((e) => e.suscripcion_id === suscripcionId).map((e) => e.event_id);
if (ids.length > 0) { await fetch(`${base}/subscriptions/${suscripcionId}/replays`, { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json', 'Idempotency-Key': 'replay-2026-10-24-caida-crm' }, body: JSON.stringify({ event_ids: ids.slice(0, 500), motivo: 'El CRM estuvo caído' }), }); // 202: { replay_id, entregas, … }}Cómo detectar huecos
Sección titulada «Cómo detectar huecos»Con una suscripción a todos los tipos, cada instancia avanza de uno en uno. Hay un hueco cuando:
seq_vistoes mayor queseq_contiguopor 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}/diagnosticstraependiente.ultimo_seq(el últimoseqde la instancia) ypendiente.eventos_sin_entregar. Siultimo_seqsupera tuseq_contiguoy 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.
8. Operación del lado del CRM
Sección titulada «8. Operación del lado del CRM»Los estados que importan
Sección titulada «Los estados que importan»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).
Volver a vincular y la generación
Sección titulada «Volver a vincular y la generación»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
generaciondel canal con cadastate.changedy mándala comogeneracion_esperadaen los envíos: un envío pensado para el vínculo anterior no sale por el nuevo. - Al volver a
conectado, comparanumero(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.availableavisa que hay uno, pero el reto se lee conGET /v1/instances/{id}/pairing.
Alertas
Sección titulada «Alertas»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,reemplazadoobloqueado: enseguida. - Un canal en
desconectadooreconectandopor más de unos minutos. - La suscripción del CRM en
suspendida(GET /v1/subscriptions). - Un hueco que dura:
seq_vistomayor queseq_contiguo, opendiente.ultimo_seqpor delante. - Comandos
ambiguosin conciliar yfallidoque un agente no vio. - Medios en
pendientepor mucho tiempo, o muchosmedia.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 conowner_refy unaIdempotency-Keyderivada del canal. - El host del webhook del CRM está en los destinos permitidos y
POST …/subscriptions/{id}/testllega 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), conUNIQUEen la base. - Los estados solo suben; las ediciones y las reacciones solo avanzan de
version. -
no_soportadose 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-Keysale 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. -
fallidoofrece reintentar con clave nueva;ambiguonunca 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.