Comandos asíncronos y eventos
El motor separa lo que pides de lo que pasa. Lo que pides (mandar un mensaje, rechazar una llamada, unirte a un grupo) es un comando: el motor lo guarda y te responde enseguida. Lo que pasa (el servidor aceptó el mensaje, llegó una respuesta, el teléfono se desvinculó) es un evento en el ledger de la instancia. Tu app manda comandos y consume eventos.
Aceptado es durable
Sección titulada «Aceptado es durable»Un comando existe cuando su fila quedó guardada en Postgres, no antes. Por eso
POST /v1/instances/{id}/messages responde 202 con el comando, no con el mensaje enviado:
si el motor se reinicia un segundo después, el comando sigue ahí y sale al volver. Nada de lo que
el motor aceptó vive solo en memoria.
En la misma transacción en que guarda un envío o una operación remota (grupos, canales, perfil,
etiquetas, llamadas, historial), el motor deja en el ledger un command.accepted que lo cita. Los
comandos de control de la instancia (reconectar, desconectar, pausar, reanudar, desvincular,
archivar) no lo emiten: su resultado se ve en state.changed.
Los estados de un comando
Sección titulada «Los estados de un comando»status |
Qué significa |
|---|---|
aceptado |
Guardado; los comandos de control (que no son envíos) nacen así. |
en_cola |
Guardado y esperando su turno: los envíos nacen así. next_try dice cuándo sale. |
ejecutando |
El motor lo está mandando a WhatsApp. |
enviado |
El servidor de WhatsApp aceptó el mensaje. No significa entregado ni leído. |
completado |
Un comando de control terminó bien. |
fallido |
No tuvo efecto. Trae error_code y stage. |
ambiguo |
Pudo haber salido o no: se cortó justo en la frontera. El motor nunca lo reenvía solo. |
cancelado |
Lo cancelaste antes de que saliera. |
stage dice dónde falló o quedó en duda: previa (no salió), servidor (WhatsApp lo rechazó;
server_code trae su código) o frontera (pudo salir: ambiguo).
Consulta un comando con GET /v1/commands/{id}. Cancélalo con POST /v1/commands/{id}/cancel
mientras esté aceptado o en_cola; si ya entró al envío o terminó, la respuesta es 409
porque pudo tener efecto afuera.
Qué eventos cuentan el resultado
Sección titulada «Qué eventos cuentan el resultado»| Evento | Cuándo |
|---|---|
command.accepted |
En la misma transacción que un envío o una operación remota. |
message.sent |
El servidor aceptó el envío. Trae command_id, el chat y aceptado_en. |
message.updated |
Cambió el estado de un mensaje: entregado, leido, reproducido. |
command.failed |
El comando no tuvo efecto: error_code y, en los envíos, stage y provider_message_id. |
command.ambiguous |
Quedó ambiguo. |
command.cancelled |
Lo cancelaste. |
No hay un command.completed: un envío termina con message.sent (y luego sus
message.updated), y un comando de control, con el evento de su efecto.
El ledger y seq
Sección titulada «El ledger y seq»Cada instancia tiene su ledger: la lista de todo lo que le pasó, en orden. Cada evento recibe un
seq: un número por instancia que crece de uno en uno, sin huecos ni repetidos. El motor
lo asigna en la misma transacción en que guarda el evento, con la fila de la secuencia bloqueada;
un evento duplicado del proveedor se descarta sin gastar número.
Tres detalles importan al consumirlo:
seqes el orden en que el motor guardó los hechos, no el orden de WhatsApp. Para eso estáoccurred_at(cuándo pasó) frente areceived_at(cuándo lo recibió el motor).- El orden vale dentro de una instancia. Entre instancias no hay un orden común.
- Si filtras por tipos (
types), vas a ver saltos enseq: son los eventos de otros tipos.
Los eventos del ledger no se borran. Cuando vence la retención de un contenido, el motor lo
redacta (data queda en {"motivo": "retencion", "redactado": true}) pero el evento y su
seq siguen ahí. origin dice de dónde viene cada hecho: live (en vivo), history (importado
del historial) u operator (un comando del motor).
Leer el ledger
Sección titulada «Leer el ledger»GET /v1/instances/{id}/events (scope events:read) devuelve los eventos con seq mayor que
after_seq, de a limit (1 a 500; por omisión, 100). Guarda next_after_seq y pide la página
siguiente mientras has_more sea true. types acepta hasta 20 tipos separados por comas.
Para no consultar en bucle, usa un webhook, un stream (SSE o WebSocket) o un broker: los tres
entregan el mismo sobre. Un stream se reanuda con su cursor (?desde= o Last-Event-ID); si el
cursor quedó más viejo que la retención de reanudación, la respuesta es 410 con un
primer_cursor para volver a empezar.
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/events?after_seq=1200&limit=100" \ -H "Authorization: Bearer TU_API_KEY"desde := cursorGuardado // el último seq que procesastefor { p, err := c.Eventos(ctx, instanciaID, desde, nil, 100) if err != nil { return err } for _, ev := range p.Eventos { procesar(ev) // idempotente por event_id } desde = p.NextAfterSeq guardarCursor(desde) if !p.HasMore { break }}let desde = cursorGuardado; // el último seq que procesastelet hayMas = true;while (hayMas) { const url = `https://motor.ejemplo.com/v1/instances/${instanciaId}/events?after_seq=${desde}&limit=100`; const pagina = await (await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } })).json(); for (const ev of pagina.events) await procesar(ev); // idempotente por event_id desde = pagina.next_after_seq; hayMas = pagina.has_more; await guardarCursor(desde);}{ "events": [ { "schema_version": 1, "event_id": "e1f0c2d4-0000-4000-8000-000000001201", "instance_id": "8a1d2c3e-0000-4000-8000-0000000000a1", "seq": 1201, "type": "message.sent", "occurred_at": "2026-10-24T15:10:01Z", "received_at": "2026-10-24T15:10:01Z", "generation": 1, "origin": "operator", "command_id": "5c2e7a10-0000-4000-8000-0000000000c1", "data": { "…": "…" } } ], "next_after_seq": 1201, "has_more": false}Idempotencia
Sección titulada «Idempotencia»Una red que se corta deja a tu app sin saber si su pedido llegó. La Idempotency-Key resuelve
eso: repetir un pedido con la misma clave devuelve el mismo comando, nunca hace un segundo
envío.
- La clave vale por app e instancia. Tiene de 1 a 200 caracteres.
- Es obligatoria al mandar mensajes y opcional en otros comandos (como crear una instancia).
- La misma clave con otro contenido (otro tipo, otro destino, otro texto, otro
expires_atocorrelation_id) es un 409: el motor no adivina cuál de los dos querías. - El motor resuelve la clave antes de revisar el resto: repetir un pedido que ya se aceptó
devuelve el comando aunque la instancia haya cambiado después, también si se volvió a vincular
(cambió su
generation): lo explica Idempotencia.
Cuando un comando nace de un evento (por ejemplo, responder a un mensaje que llegó), una buena
clave es <event_id del evento>:<n>: si tu app procesa dos veces el mismo evento, el motor manda
la respuesta una sola vez.
generacion_esperada es la otra protección: si la mandas y la instancia se volvió a vincular (su
generation cambió), la respuesta es 409 y el mensaje no sale por el número nuevo.
expires_at pone fecha de vencimiento: si no salió antes, no sale (fallido con
comando_vencido).
«Al menos una vez»
Sección titulada ««Al menos una vez»»El motor entrega cada evento al menos una vez: por webhook, por stream o por broker, el mismo evento puede llegarte dos veces (un reintento después de un corte, un replay que pediste). Nunca se pierde uno que esté en el ledger, pero tampoco hay «exactamente una vez».
Tu app lo resuelve con tres reglas:
- Deduplica por
event_id. Guarda elevent_iden la misma transacción que el efecto del evento (el receptor del SDK de Go lo hace con un inboxUNIQUE). - Procesa cada instancia en orden de
seq. Una suscripciónordenado(por omisión) no entrega un evento de una instancia mientras quede pendiente uno anterior de esa instancia. - Usa una
Idempotency-Keyen cada comando que mandes como reacción a un evento.
Un webhook se da por entregado solo con un 2xx. Un error de red, un timeout, un 408, un 429 o
un 5xx se reintentan a los 1 s, 5 s, 30 s, 2 min y 10 min, y luego cada hora (con ±20 % de
variación y respetando Retry-After hasta 1 h); a las 72 h la entrega queda fallida. Un 401 o un
403 suspenden la suscripción, y otro 3xx o 4xx deja la entrega fallida enseguida. Una entrega
fallida se recupera con un replay (detalle en Webhooks). Un replay reenvía los mismos hechos, con
el mismo event_id y el mismo occurred_at, y nunca vuelve a mandar nada a WhatsApp.