Ir al contenido

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.

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.

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.

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.

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:

  • seq es el orden en que el motor guardó los hechos, no el orden de WhatsApp. Para eso está occurred_at (cuándo pasó) frente a received_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 en seq: 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).

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.

Ventana de terminal
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/events?after_seq=1200&limit=100" \
-H "Authorization: Bearer TU_API_KEY"
200 OK
{
"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
}

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_at o correlation_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).

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:

  1. Deduplica por event_id. Guarda el event_id en la misma transacción que el efecto del evento (el receptor del SDK de Go lo hace con un inbox UNIQUE).
  2. Procesa cada instancia en orden de seq. Una suscripción ordenado (por omisión) no entrega un evento de una instancia mientras quede pendiente uno anterior de esa instancia.
  3. Usa una Idempotency-Key en 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.