Idempotencia
Una red que se corta deja a tu app sin saber si su pedido llegó. Si lo repite sin más, puede
mandar dos veces el mismo mensaje. La Idempotency-Key resuelve eso: repetir un pedido con
la misma clave devuelve lo mismo que la primera vez, sin un segundo efecto en WhatsApp.
Cómo funciona
Sección titulada «Cómo funciona»Mandas la clave en el encabezado Idempotency-Key: un texto tuyo de 1 a 200 caracteres (los
espacios de los extremos se quitan). Lo que pasa depende de si el motor ya la vio:
| La clave… | Con el mismo contenido | Con otro contenido |
|---|---|---|
| Es nueva | Se acepta el pedido y la clave queda tomada. | — |
| Ya se usó | Devuelve el mismo comando (o instancia, o replay). Nada nuevo sale a WhatsApp. | 409: el motor no adivina cuál de los dos querías. |
Un comando repetido vuelve con su estado actual: si la primera vez respondió en_cola y el
mensaje ya salió, el reintento trae enviado, con el mismo command_id y el mismo
provider_message_id.
Una clave no vence. El motor no borra los comandos: una clave usada queda tomada para siempre en su alcance.
El alcance de la clave
Sección titulada «El alcance de la clave»| Dónde | Alcance | Consecuencia |
|---|---|---|
| Comandos de una instancia (mensajes, chats, grupos, perfil…) | App + instancia | La misma clave en dos instancias son dos pedidos distintos. En una misma instancia, la comparten todas las operaciones: usar pedido-1042 para un mensaje y después para una etiqueta es un 409. |
Alta de instancias (POST /v1/instances) |
App | Una clave por alta en toda la app. |
Replays (POST /v1/subscriptions/{id}/replays) |
App | Entre todas las suscripciones de la app. |
Nunca es global: dos apps pueden usar la misma clave sin enterarse.
Qué cuenta como el mismo pedido
Sección titulada «Qué cuenta como el mismo pedido»El motor compara una huella del contenido, no el texto crudo del cuerpo.
No cambian la huella: los espacios del JSON, el orden de los campos y la zona horaria de una
fecha (2026-10-24T18:00:00Z y 2026-10-24T15:00:00-03:00 son lo mismo).
Sí la cambian, en un comando: el tipo, la instancia, cualquier dato del cuerpo (el destino, el
texto, la cita, las menciones…), expires_at, correlation_id y la generación de la instancia.
En el alta de una instancia, el nombre y el owner_ref. En un replay, la suscripción, el rango
o los event_ids y el motivo.
La clave se resuelve primero
Sección titulada «La clave se resuelve primero»En los envíos y en los comandos de chats, etiquetas, perfil, canales e historial, el motor busca la clave antes de revisar las condiciones que cambian con el tiempo: la ventana para editar un mensaje, la cola llena, el estado de la instancia, que una etiqueta siga existiendo. Así un reintento de algo ya aceptado devuelve el comando aunque ahora el pedido fuera rechazado (la ventana de edición venció, la instancia se desconectó). Sin esto, un reintento tardío recibiría un error por algo que sí se hizo.
Dónde es obligatoria y dónde opcional
Sección titulada «Dónde es obligatoria y dónde opcional»Obligatoria
Sección titulada «Obligatoria»Sin ella, la respuesta es 422. Son las operaciones que mandan algo a WhatsApp o que crean trabajo durable:
| Dominio | Operaciones |
|---|---|
| Mensajes | POST …/messages (todos los tipos, también publicar en un canal) y POST …/read |
| Chats y etiquetas | POST …/chats/actions, POST …/appstate/resync, POST …/labels, PATCH y DELETE …/labels/{label_id}, POST …/labels/{label_id}/associations |
| Perfil y privacidad | PUT …/profile/name, …/profile/about, …/profile/picture, DELETE …/profile/picture, PUT …/privacy, PUT …/privacy/disappearing, POST …/blocklist |
| Grupos y comunidades | POST …/groups, POST …/groups/join, PUT …/groups/{grupo_ref}/name, …/description, …/picture, …/settings, DELETE …/groups/{grupo_ref}/picture, POST …/groups/{grupo_ref}/participants, …/requests, …/leave, …/invite-link, …/invite/send, POST …/communities, POST …/communities/{comunidad_ref}/links |
| Canales | POST …/newsletters, POST …/newsletters/follow, POST …/newsletters/{canal_ref}/unfollow, …/view, …/reaction, PUT …/newsletters/{canal_ref}/mute |
| Status | POST …/status |
| Historial | POST …/history/requests, POST …/history/imports |
| Webhooks | POST /v1/subscriptions/{id}/replays |
Opcional
Sección titulada «Opcional»| Operación | Con clave | Sin clave |
|---|---|---|
POST /v1/instances |
Repetir devuelve la misma instancia con 200 (la primera vez, 201) y no gasta cupo. | Cada pedido crea una instancia. |
POST /v1/instances/{id}/commands |
Repetir devuelve el mismo comando de control. | El motor le pone una clave propia: cada pedido es un comando nuevo. |
DELETE /v1/instances/{id} |
Igual. | Igual que arriba; archivar dos veces no hace daño. |
No la aceptan
Sección titulada «No la aceptan»| Operación | Qué pasa si la repites |
|---|---|
POST /v1/subscriptions |
Crea otra suscripción. Lista antes de repetir. |
POST /v1/subscriptions/{id}/rotate |
Emite otro secreto. |
POST /v1/instances/{id}/pairing |
Abre otra generación y reemplaza el intento anterior (es lo que quieres para un QR vencido). |
POST /v1/instances/{id}/media/uploads |
Sube otro archivo, con otro media_id. ?sha256= comprueba la integridad, no deduplica. |
POST …/contacts/check, …/contacts/info, …/contacts/picture |
Vuelve a consultar al servidor y vuelve a gastar cuota. |
POST …/calls/{call_id}/reject |
Nada: el rechazo es idempotente por llamada. |
POST /v1/commands/{id}/cancel |
Devuelve el mismo comando cancelado. |
PATCH /v1/instances/{id}, PATCH /v1/subscriptions/{id} |
Aplica el cambio otra vez. Usa version_esperada para no pisar un cambio ajeno (409 si la versión cambió). |
El reintento seguro
Sección titulada «El reintento seguro»La regla es simple: genera la clave una vez, guárdala con el pedido y repite siempre con la misma. Repite ante un error de red, un timeout o un 5xx; no repitas ante un 4xx, que no se arregla solo.
# La clave sale de tu sistema, no del reloj: el mismo pedido, la misma clave.CLAVE="pedido-1042-confirmacion"for intento in 1 2 3 4 5; do status=$(curl -s -o respuesta.json -w '%{http_code}' -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: $CLAVE" \ -d '{"destino": {"telefono": "+NUMERO_DEL_CONTACTO"}, "texto": "Tu pedido 1042 está confirmado."}') case "$status" in 202|429) break ;; # aceptado (un 429 de ritmo trae el comando guardado) 000|5*) sleep $((intento * 2)) ;; # red o 5xx: repetir con la misma clave *) break ;; # 4xx: no se arregla repitiendo esacdone// EnviarTexto con la misma clave es seguro de repetir: nunca manda dos mensajes.func enviarConReintentos(ctx context.Context, c *motor.Cliente, inst string, t motor.Texto, clave string) (motor.Comando, error) { var ultimo error for intento := 1; intento <= 5; intento++ { cmd, err := c.EnviarTexto(ctx, inst, clave, t) var e *motor.Error switch { case err == nil: return cmd, nil // 202, o un 429 de ritmo con el comando guardado case errors.As(err, &e) && e.Status < 500 && e.Status != http.StatusTooManyRequests: return cmd, err // 4xx: no se arregla repitiendo } ultimo = err // red, 5xx o cola llena: esperar y repetir con la MISMA clave select { case <-ctx.Done(): return motor.Comando{}, ctx.Err() case <-time.After(time.Duration(intento) * 2 * time.Second): } } return motor.Comando{}, ultimo}async function enviarConReintentos(instanciaId, clave, cuerpo) { for (let intento = 1; intento <= 5; intento++) { let res; try { res = await fetch(`https://motor.ejemplo.com/v1/instances/${instanciaId}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': clave, // la MISMA en cada intento }, body: JSON.stringify(cuerpo), }); } catch { res = null; // red: repetir } if (res) { const json = await res.json(); if (res.status === 202 || (res.status === 429 && json.command_id)) return json; if (res.status < 500 && res.status !== 429) throw new Error(`${res.status}: ${json.detail}`); } await new Promise((r) => setTimeout(r, intento * 2000)); } throw new Error('El motor no respondió: el pedido sigue pendiente con la misma clave.');}Si agotas los intentos, guarda la clave y repite más tarde: mientras uses la misma, nunca habrá un segundo mensaje.
Buenas claves
Sección titulada «Buenas claves»Una buena clave sale de tu sistema, no del momento en que haces el pedido:
| Situación | Clave | Por qué |
|---|---|---|
| Un mensaje que nace de una acción de tu sistema | pedido-1042-confirmacion |
El mismo pedido de negocio da la misma clave aunque tu proceso se reinicie. |
| Una respuesta a un evento | <event_id>:1, <event_id>:2 |
Si tu app procesa dos veces el mismo evento (llega «al menos una vez»), responde una sola vez. |
| Un mensaje que sí quieres repetir | recordatorio-1042-2026-10-24 |
Otra clave a propósito: es otro mensaje. |
| Un alta de instancia | alta-sucursal-centro |
Repetir el alta no crea dos instancias. |
Evita:
- Un UUID nuevo en cada intento. Es como no mandar clave: cada reintento es un mensaje nuevo.
- La hora del pedido. Dos reintentos en segundos distintos son dos claves.
- Datos personales en la clave (el teléfono, el nombre). La clave vuelve en las respuestas
(
idempotency_key) y en los registros de tu app. - Reusar una clave para otra cosa en la misma instancia: es un 409, aunque sea otra operación.
generacion_esperada
Sección titulada «generacion_esperada»La generación de una instancia sube cada vez que se vuelve a vincular. En un envío
(POST …/messages), si mandas generacion_esperada y no coincide con la actual, el pedido
responde 409 y no se acepta. Úsala
cuando el mensaje solo tiene sentido para ese número: si el operador desvinculó la instancia y
vinculó otro teléfono, tu mensaje no sale por el número nuevo.
Aunque no la mandes, un comando aceptado antes de volver a vincular nunca sale por la nueva
vinculación: el motor lo hace fallar con generacion_cambiada. generacion_esperada adelanta
ese rechazo al momento de pedir.
En la vinculación, generacion_esperada evita que dos pantallas se pisen: ver
La generación.
expires_at
Sección titulada «expires_at»expires_at pone fecha de vencimiento a un comando: si no salió antes, no sale, y termina
fallido con comando_vencido. Sirve para lo que pierde sentido con el tiempo: un código de
verificación, un «tu pedido llega en 10 minutos».
expires_at es parte del contenido: la misma clave con otro expires_at es un 409. Si tu app
calcula el vencimiento con la hora actual, calcúlalo una vez y guárdalo con la clave, para que
el reintento lleve la misma fecha.
Lo que la clave no hace
Sección titulada «Lo que la clave no hace»- No deduplica eventos. Tu receptor deduplica por
event_id(ver El primer webhook). - No vuelve exacto un
ambiguo. Un comandoambiguopudo salir o no: repetirlo con la misma clave devuelve el mismo comandoambiguo, y con otra clave puede duplicar el mensaje. Espera su conciliación; lo explica Comandos asíncronos y eventos. - No protege lo que no es un comando. Una subida de multimedia, una consulta de contactos o el alta de una suscripción no la aceptan (tabla de arriba).