Cada vez que Compense intenta llevar un evento a uno de tus endpoints crea una entrega (delivery). Si tu receptor no responde 2xx, la entrega se reintenta sola; y todo queda en un historial que puedes consultar, filtrar y, para las entregas que fallaron, reintentar a mano.

Reintentos automáticos #

Una entrega falla cuando tu receptor responde con un código que no es 2xx, cuando no responde en 10 segundos o cuando hay un error de red (DNS, TLS, conexión rechazada). Entonces Compense reintenta la misma entrega (mismo cuerpo y mismo X-Webhook-Id) con espera exponencial:

IntentoCuándo saleTiempo acumulado aproximado
OriginalEn cuanto ocurre el evento0
Reintento 130 segundos después30 s
Reintento 22 minutos después2 min 30 s
Reintento 38 minutos después10 min 30 s
Reintento 432 minutos después42 min 30 s
Reintento 5~2 horas después~2 h 43 min
  • El número de reintentos es el max_retries del endpoint (5 por defecto) y el tiempo de espera por intento es su timeout_seconds (10). Los dos vienen en la respuesta del endpoint.
  • Si se agotan los reintentos sin un 2xx, la entrega queda abandoned. Ya no se reintenta sola: puedes reintentarla a mano.
  • Como el mismo evento puede llegar varias veces, tu receptor debe deduplicar por event_id.

Estados de una entrega #

EstadoSignificado
pendingEn cola, todavía no se ha enviado.
in_flightSe está enviando en este momento.
successTu receptor respondió 2xx.
failedEl intento falló (no 2xx, timeout o error de red).
abandonedSe agotaron los reintentos automáticos.
stateDiagram-v2
    [*] --> pending
    pending --> in_flight
    in_flight --> success: respuesta 2xx
    in_flight --> failed: no 2xx, timeout o error de red
    failed --> in_flight: reintento automático (backoff)
    failed --> abandoned: se agotan los max_retries
    failed --> pending: replay manual (entrega hija)
    abandoned --> pending: replay manual (entrega hija)
    success --> [*]

Cuando el endpoint deja de recibir #

  • 410 Gone deshabilita. Responder 410 le dice a Compense que la URL ya no existe: tras respuestas 410 repetidas (3) el endpoint pasa a disabled y deja de recibir eventos. Un endpoint deshabilitado no se reactiva por API: crea otro o escribe a soporte. No uses 410 para rechazar una entrega puntual.
  • failing avisa. Si en 24 horas un endpoint tuvo 10 o más entregas y menos del 50 % fue exitosa, Compense lo marca failing. Revisa el historial y la salud de tu receptor.
  • paused lo decides tú. Un endpoint pausado (active: false) no recibe eventos. Ver Suscribir un endpoint.

Historial de entregas #

GET/v1/gateway/webhooks/deliveries/ lista las entregas de tus endpoints, de la más reciente a la más antigua, con estos filtros:

ParámetroValores
statuspending, in_flight, success, failed, abandoned (uno por consulta)
event_typeClave del catálogo, p. ej. credits.decision
endpoint_idId de uno de tus endpoints
date_from, date_toYYYY-MM-DD o ISO-8601. Filtran por fecha de encolado; date_to es inclusivo (hasta el final de ese día)
page, page_sizePaginación por número de página; page_size máximo 100, por defecto 25
import os
from compense_gateway import CompenseClient

client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")

page = client.webhooks.deliveries.list(
    status="failed",
    event_type="credits.decision",
    date_from="2026-10-01",
    date_to="2026-10-31",
    page_size=50,
)
print("total:", page.count)
for d in page:
    print(d["id"], d["event_type"], d["status"], d["attempt_number"], d["response_status_code"])

Respuesta (una página):

JSON
{
  "count": 1,
  "page": 1,
  "page_size": 50,
  "results": [
    {
      "id": "8b7c6d5e-0000-4a1b-9c2d-000000000301",
      "endpoint_id": "3f1a2b3c-0000-4a1b-9c2d-000000000001",
      "event_type": "credits.decision",
      "event_id": "evt-2026-10-02-000123",
      "status": "failed",
      "attempt_number": 1,
      "response_status_code": 500,
      "duration_ms": 120,
      "queued_at": "2026-10-02T12:00:00+00:00",
      "completed_at": "2026-10-02T12:00:00+00:00",
      "parent_delivery_id": null,
      "replayable": true
    }
  ]
}

replayable te dice si la entrega admite un reintento manual (failed o abandoned).

Detalle de una entrega #

GET/v1/gateway/webhooks/deliveries/{delivery_id}/ trae todo lo que necesitas para diagnosticar:

  • request.payload: el cuerpo que se envió.
  • request.headers: las cabeceras enviadas. Las de firma se ven tal cual; cualquier otra aparece como [redactado].
  • response: el status_code que devolvió tu receptor y los primeros 2 KB de su cuerpo (body_preview).
  • attempts: todos los intentos del mismo evento (la entrega original y sus replays), con su código, duración y error.
  • error_class y error_message: por qué falló, si falló.

El detalle nunca incluye el secreto.

detalle = client.webhooks.deliveries.get("8b7c6d5e-0000-4a1b-9c2d-000000000301")
print(detalle["status"], detalle["response"]["status_code"], detalle["response"]["body_preview"])
for intento in detalle["attempts"]:
    print(intento["attempt_number"], intento["status"], intento["response_status_code"])

Respuesta (resumida):

JSON
{
  "id": "8b7c6d5e-0000-4a1b-9c2d-000000000301",
  "endpoint_url": "https://erp.nova.example/compense/webhooks",
  "event_type": "credits.decision",
  "event_id": "evt-2026-10-02-000123",
  "status": "failed",
  "error_class": "HTTPError",
  "error_message": "HTTP 500",
  "request": {
    "headers": {
      "X-Webhook-Signature": "sha256=…",
      "X-Webhook-Event": "credits.decision",
      "X-Webhook-Id": "evt-2026-10-02-000123",
      "X-Webhook-Timestamp": "1790942400"
    },
    "payload": { "event": "credits.decision", "version": "1", "tenant_id": "nova", "occurred_at": "2026-10-02T12:00:00Z", "resource": { "…": "…" } }
  },
  "response": { "status_code": 500, "body_preview": "Internal Server Error" },
  "attempts": [
    { "id": "8b7c6d5e-0000-4a1b-9c2d-000000000301", "attempt_number": 1, "status": "failed", "response_status_code": 500, "duration_ms": 120 }
  ]
}

Reintento manual (replay) #

POST/v1/gateway/webhooks/deliveries/{delivery_id}/replay/ vuelve a enviar una entrega. No la modifica: crea una entrega hija con:

  • el mismo payload;
  • attempt_number = el de la original + 1 y parent_delivery_id = la original;
  • un event_id nuevo, con la forma <event_id original>-retry-<hex>.

Solo se pueden reintentar entregas en estado failed o abandoned, y solo si el endpoint está active. En cualquier otro caso la respuesta es 409.

El replay trae otro event_id

Como el event_id del replay es nuevo, tu deduplicación por event_id no lo descartará. Es lo que quieres cuando la original nunca se procesó; si pudo haberse procesado (por ejemplo, tu receptor aplicó el evento y luego respondió 500), haz tu proceso idempotente también por la clave de negocio del resource.

tx = client.webhooks.deliveries.replay(
    "8b7c6d5e-0000-4a1b-9c2d-000000000301",
    idempotency_key="replay-8b7c6d5e-0000-4a1b-9c2d-000000000301",
)
print(tx.data["delivery_id"], tx.data["replayed_from"], tx.data["attempt_number"], tx.data["status"])
JSON
{
  "status": "success",
  "transaction_id": "a1b2c3d4-0000-4a1b-9c2d-000000000030",
  "module": "webhooks",
  "data": {
    "delivery_id": "8b7c6d5e-0000-4a1b-9c2d-000000000302",
    "replayed_from": "8b7c6d5e-0000-4a1b-9c2d-000000000301",
    "attempt_number": 2,
    "status": "pending"
  }
}

Usa una Idempotency-Key derivada del id de la entrega (replay-<delivery_id>): si tu script se corta y lo vuelves a correr, no dispararás dos replays de la misma entrega. Sigue el resultado con el detalle de la entrega hija (data.delivery_id).

Desde la consola: compense replay <delivery_id> (ver La CLI compense).

Reprocesar todas las fallidas #

Después de una caída de tu receptor (un despliegue roto, un certificado vencido, una rotación mal coordinada) querrás reintentar en bloque todo lo que quedó failed o abandoned. El patrón:

  1. Recolecta primero los ids de todas las páginas, para failed y para abandoned (el filtro status admite un valor por consulta). Así los replays que vas creando no desplazan la paginación.
  2. Reintenta uno por uno con una Idempotency-Key estable por entrega.
  3. Tolera los 409: una entrega que ya no es reintentable o un endpoint que no está active no debe detener el lote. El motivo viene en detail (WEBHOOK_DELIVERY_NOT_REPLAYABLE, WEBHOOK_ENDPOINT_INACTIVE).
Reprocesar todas las entregas fallidas
import os
from compense_gateway import CompenseClient, IdempotencyConflictError

client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")
DESDE, TAM = "2026-10-01", 100


def ids_por_estado(status: str) -> list[str]:
    ids, pagina = [], 1
    while True:
        page = client.webhooks.deliveries.list(status=status, date_from=DESDE,
                                               page=pagina, page_size=TAM)
        ids.extend(d["id"] for d in page)
        if not page.results or pagina * TAM >= page.count:
            return ids
        pagina += 1


pendientes = ids_por_estado("failed") + ids_por_estado("abandoned")
print(f"{len(pendientes)} entregas por reintentar")

for delivery_id in pendientes:
    try:
        tx = client.webhooks.deliveries.replay(delivery_id, idempotency_key=f"replay-{delivery_id}")
        print(delivery_id, "→", tx.data["delivery_id"])
    except IdempotencyConflictError as e:      # 409: no reintentable o endpoint inactivo
        print(delivery_id, "omitida:", e.detail)

Paginación automática y el campo next

Los iteradores de JavaScript (for await), .NET (ListAutoPagingAsync) y Go (NewPageIterator) piden la página siguiente mientras la respuesta traiga next. El contrato de esta lista garantiza count, page, page_size y results, pero no next (el sandbox no lo envía). Si tu respuesta no lo trae, esos iteradores se detienen en la primera página: usa page_size: 100 y, si count es mayor, recorre por page como en las pestañas de Python, PHP y cURL. Más en Paginación.

Errores #

HTTPCódigo (en detail)Cuándo
403—La llave no tiene webhooks:read (listar, ver) o webhooks:manage (replay).
404WEBHOOK_DELIVERY_NOT_FOUNDLa entrega no existe o es de otra organización.
409WEBHOOK_DELIVERY_NOT_REPLAYABLELa entrega no está failed ni abandoned.
409WEBHOOK_ENDPOINT_INACTIVEEl endpoint de la entrega no está active.
409—Misma Idempotency-Key con la petición original aún en curso.
422WEBHOOK_INVALID_FILTERFiltro inválido (estado, UUID o fecha).
429—Límite por minuto o cuota de la llave. Respeta Retry-After en lotes grandes.

Un 409 no siempre es un conflicto de idempotencia aunque el SDK lo lance con esa clase (IdempotencyConflictError, IdempotencyConflictException, compense.ErrIdempotencyConflict): lee detail para saber cuál es. Ver Errores.