Reenviar un webhook de Waspy que falló: deliveries y replay en la API v2
Listá las entregas fallidas con GET /api/v2/webhooks/:id/deliveries y reenvialas con POST /replay/:deliveryId. Si la fila todavía tiene el data, el replay lo repite. Sé idempotente sobre data.id.
Respuesta corta: en un pico, las entregas que tu URL no pudo tomar se listan con GET https://api.waspytech.com/api/v2/webhooks/:id/deliveries?status=failed. Para dispararlas de nuevo, POST /api/v2/webhooks/:id/replay/:deliveryId. Si la fila todavía guarda el data, el replay repite ese objeto y el mismo event. Tu handler tiene que ser idempotente sobre data.id.
La base es https://api.waspytech.com/api/v2. La referencia está en webhooks, el sobre en introducción y el catálogo en eventos. El alta y la firma están en webhooks en Node y Python y en verificar la firma. El CRUD está en gestionar webhooks. La clave pública entra en Scale y Enterprise: la API y Planes.
Lo que dice la doc y lo que hace el worker
| Tema | Página de docs, hoy | El servidor |
|---|---|---|
| Body del replay | “No se guarda verbatim, solo el hash. El replay es sintético.” | Desde la migración 0046 la fila guarda payload (el data). El replay lo reencola si no es null |
| Qué ves en el GET | payloadHash | Igual: el JSON público no trae payload. El hash es SHA-256 del sobre crudo |
Si payload es null | El caso que la doc describe como único | Stub { replayOf, replayedAt, originalEvent } como data |
| Quinto hueco de reintento | La descripción de la ruta lista 12 horas | Con 5 intentos el worker agenda 1 min, 5 min, 30 min y 2 horas. El slot de 12 horas no se usa |
apps/worker/src/webhook-deliveries-worker.ts y a replayWebhookDelivery. El comentario de la columna habla de vaciar payload a los 30 días. En este repo no hay un cron que lo haga. Mientras la columna tenga valor, el replay manda ese data.
Listar las que fallaron
Scope webhooks:read. status es success, failed o all (default all). limit arranca en 50 y llega a 100. El orden es el más nuevo primero.
curl -sS "https://api.waspytech.com/api/v2/webhooks/SUB_ID/deliveries?status=failed&limit=50" \
-H "Authorization: Bearer $WASPY_API_KEY"
Cada ítem de data trae id, subscriptionId, event, attempt, success, statusCode, errorMessage, responseBodyTruncated, payloadHash, deliveredAt y createdAt. El id es el deliveryId del replay. meta.cursor y meta.hasMore paginan. Copiá el cursor tal cual: si lo fabricás, la API responde 400 INVALID_CURSOR. El formato está en paginación.
Un id de suscripción que no es de tu tenant responde 404 WEBHOOK_NOT_FOUND. Un deliveryId que no es UUID no llega al handler: 400 VALIDATION_ERROR.
Reenviar una entrega
Scope webhooks:write. deliveryId es un UUID de esa suscripción.
curl -sS -X POST "https://api.waspytech.com/api/v2/webhooks/SUB_ID/replay/DELIVERY_ID" \
-H "Authorization: Bearer $WASPY_API_KEY"
El 200 es data.enqueued: true, data.event (el de la fila) y data.subscriptionId. No trae samplePayload: eso lo devuelve solo el test. Un delivery de otra suscripción o de otro tenant es 404 DELIVERY_NOT_FOUND.
El 200 quiere decir “encolado”, no “tu URL ya respondió 2xx”. El worker arma un sobre nuevo: event, webhookId, deliveredAt de ahora, attempt: 1 y data igual al payload guardado. La firma HMAC usa un t fresco. X-Waspy-First-Attempt-At es el de esta cadena de replay, no el del intento original. El algoritmo está en seguridad.
Si payload es null, data queda así:
{ "replayOf": "uuid-de-la-fila", "replayedAt": "2026-11-03T18:00:00.000Z", "originalEvent": "message.received" }
Ese stub no tiene data.id. No lo trates como un mensaje nuevo del cliente. Sirve para ver que la URL, la firma y el transporte responden.
Idempotencia en el pico
Un replay de una fila reciente repite el mismo data.id (el UUID del mensaje en Waspy, en message.received y en message.status_changed). Si el primer POST sí procesó el pedido y después tu proceso se cayó al responder, el segundo POST no puede crear otro pedido. La clave es data.id. Cómo separar eso de Idempotency-Key y de clientRef está en idempotencia y scopes.
Los cuatro event que acepta el alta son message.received, message.status_changed, conversation.created y contact.created. El replay conserva el que tenía la fila. No lo convierte todo en message.received.
Test, replay y el contador de fallas
| Entrega real | POST .../test | POST .../replay/:deliveryId | |
|---|---|---|---|
| Qué manda | El data del evento | message.received con datos dummy. La respuesta incluye samplePayload | El data guardado, o el stub |
| Aparece en deliveries | Sí | Sí | Sí |
Mueve lastDeliveryAt y consecutiveFailures | Sí | No | No |
| Si falla, ¿reintenta? | Sí, hasta 5 | Sí, hasta 5 | Sí, hasta 5 |
| Suma a las 10 que apagan | Sí | No | No |
isTest: true. Por eso un replay durante el CyberMonday no “arregla” el contador ni lo empeora: las 10 fallas seguidas salen del tráfico real. Para ver si el replay pegó, volvé a leer GET .../deliveries?status=all&limit=5. No mires lastDeliveryAt.
Si la suscripción está enabled: false, el POST de replay igual responde 200 encolado, y el worker no entrega y no escribe otra fila. Antes de reenviar, reactivala:
curl -sS -X PATCH "https://api.waspytech.com/api/v2/webhooks/SUB_ID" \
-H "Authorization: Bearer $WASPY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "enabled": true }'
Ese PATCH pone consecutiveFailures en 0 y limpia disabledAt y disabledReason.
Qué cuenta como éxito, y cuándo se apaga
| Tu URL | Resultado |
|---|---|
| 2xx | Entrega ok |
| Cualquier 3xx | Entrega ok. Se guarda ese status. No hay un segundo POST a Location |
| 4xx o 5xx | Fallo |
| Nada en 10 segundos | Fallo |
enabled: false. El motivo queda en disabledReason. Los huecos entre los cinco intentos son 1 minuto, 5 minutos, 30 minutos y 2 horas. Respondé 2xx enseguida y procesá después: el body de tu respuesta no cambia el resultado.
La API REST está en Scale (US$150 por mes) y Enterprise. Diez días sin tarjeta, en el registro. Los planes están en Planes.
