Webhooks
Reintentos y entregas
Cómo reintenta Compense una entrega fallida (30 s, 2 min, 8 min, 32 min y ~2 h), cuándo queda abandonada o se deshabilita el endpoint, cómo consultar el historial con filtros y paginación, ver el detalle con sus intentos y reprocesar todas las entregas fallidas.
En esta página
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:
| Intento | Cuándo sale | Tiempo acumulado aproximado |
|---|---|---|
| Original | En cuanto ocurre el evento | 0 |
| Reintento 1 | 30 segundos después | 30 s |
| Reintento 2 | 2 minutos después | 2 min 30 s |
| Reintento 3 | 8 minutos después | 10 min 30 s |
| Reintento 4 | 32 minutos después | 42 min 30 s |
| Reintento 5 | ~2 horas después | ~2 h 43 min |
- El número de reintentos es el
max_retriesdel endpoint (5 por defecto) y el tiempo de espera por intento es sutimeout_seconds(10). Los dos vienen en la respuesta del endpoint. - Si se agotan los reintentos sin un
2xx, la entrega quedaabandoned. 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 #
| Estado | Significado |
|---|---|
pending | En cola, todavía no se ha enviado. |
in_flight | Se está enviando en este momento. |
success | Tu receptor respondió 2xx. |
failed | El intento falló (no 2xx, timeout o error de red). |
abandoned | Se 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 Gonedeshabilita. Responder410le dice a Compense que la URL ya no existe: tras respuestas410repetidas (3) el endpoint pasa adisabledy deja de recibir eventos. Un endpoint deshabilitado no se reactiva por API: crea otro o escribe a soporte. No uses410para rechazar una entrega puntual.failingavisa. Si en 24 horas un endpoint tuvo 10 o más entregas y menos del 50 % fue exitosa, Compense lo marcafailing. Revisa el historial y la salud de tu receptor.pausedlo 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ámetro | Valores |
|---|---|
status | pending, in_flight, success, failed, abandoned (uno por consulta) |
event_type | Clave del catálogo, p. ej. credits.decision |
endpoint_id | Id de uno de tus endpoints |
date_from, date_to | YYYY-MM-DD o ISO-8601. Filtran por fecha de encolado; date_to es inclusivo (hasta el final de ese día) |
page, page_size | Paginació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"])import { CompenseClient } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const page = await client.webhooks.deliveries.list({
status: "failed",
eventType: "credits.decision",
dateFrom: "2026-10-01",
dateTo: "2026-10-31",
pageSize: 50,
});
console.log("total:", page.count);
for (const d of page) {
console.log(d.id, d.event_type, d.status, d.attempt_number, d.response_status_code);
}<?php
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\CompenseClient;
$client = new CompenseClient(apiKey: getenv('COMPENSE_API_KEY'), environment: 'sandbox');
$page = $client->webhooks->deliveries->list(
status: 'failed',
eventType: 'credits.decision',
dateFrom: '2026-10-01',
dateTo: '2026-10-31',
pageSize: 50,
);
echo 'total: ', $page->count, PHP_EOL;
foreach ($page as $d) {
echo $d['id'], ' ', $d['event_type'], ' ', $d['status'], ' ', $d['attempt_number'], PHP_EOL;
}using System.Text.Json.Nodes;
using Compense.Sdk;
using Compense.Sdk.Models;
using var client = new CompenseClient(
Environment.GetEnvironmentVariable("COMPENSE_API_KEY")!, CompenseEnvironment.Sandbox);
Page page = await client.Webhooks.Deliveries.ListAsync(
status: "failed",
eventType: "credits.decision",
dateFrom: "2026-10-01",
dateTo: "2026-10-31",
pageSize: 50);
Console.WriteLine($"total: {page.Count}");
foreach (JsonObject d in page)
{
Console.WriteLine($"{d["id"]} {d["event_type"]} {d["status"]} {d["attempt_number"]} {d["response_status_code"]}");
}package main
import (
"context"
"fmt"
"log"
"os"
compense "github.com/Appiasoft/compense-saas-sdk-go"
)
func main() {
ctx := context.Background()
client, err := compense.NewClient(os.Getenv("COMPENSE_API_KEY"),
compense.WithEnvironment(compense.Sandbox))
if err != nil {
log.Fatal(err)
}
page, err := client.Webhooks.Deliveries.List(ctx, &compense.DeliveryListParams{
Status: "failed",
EventType: "credits.decision",
DateFrom: "2026-10-01",
DateTo: "2026-10-31",
PageSize: 50,
})
if err != nil {
log.Fatal(err)
}
fmt.Println("total:", page.Count)
for _, d := range page.Results {
fmt.Println(d["id"], d["event_type"], d["status"], d["attempt_number"], d["response_status_code"])
}
}curl "https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/deliveries/?status=failed&event_type=credits.decision&date_from=2026-10-01&date_to=2026-10-31&page_size=50" \
-H "X-API-Key: $COMPENSE_API_KEY"Respuesta (una página):
{
"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: elstatus_codeque 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_classyerror_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"])const detalle = await client.webhooks.deliveries.get("8b7c6d5e-0000-4a1b-9c2d-000000000301");
const respuesta = detalle.response as Record<string, unknown>;
console.log(detalle.status, respuesta.status_code, respuesta.body_preview);
for (const intento of detalle.attempts as Array<Record<string, unknown>>) {
console.log(intento.attempt_number, intento.status, intento.response_status_code);
}$detalle = $client->webhooks->deliveries->get('8b7c6d5e-0000-4a1b-9c2d-000000000301');
echo $detalle['status'], ' ', $detalle['response']['status_code'], ' ', $detalle['response']['body_preview'], PHP_EOL;
foreach ($detalle['attempts'] as $intento) {
echo $intento['attempt_number'], ' ', $intento['status'], ' ', $intento['response_status_code'], PHP_EOL;
}JsonObject detalle = await client.Webhooks.Deliveries.GetAsync("8b7c6d5e-0000-4a1b-9c2d-000000000301");
Console.WriteLine($"{detalle["status"]} {detalle["response"]?["status_code"]} {detalle["response"]?["body_preview"]}");
foreach (JsonNode? intento in detalle["attempts"]!.AsArray())
{
Console.WriteLine($"{intento!["attempt_number"]} {intento["status"]} {intento["response_status_code"]}");
}detalle, err := client.Webhooks.Deliveries.Get(ctx, "8b7c6d5e-0000-4a1b-9c2d-000000000301")
if err != nil {
log.Fatal(err)
}
resp, _ := detalle["response"].(map[string]any)
fmt.Println(detalle["status"], resp["status_code"], resp["body_preview"])
for _, raw := range detalle["attempts"].([]any) {
intento := raw.(map[string]any)
fmt.Println(intento["attempt_number"], intento["status"], intento["response_status_code"])
}curl https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/deliveries/$DELIVERY_ID/ \
-H "X-API-Key: $COMPENSE_API_KEY"Respuesta (resumida):
{
"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 yparent_delivery_id= la original;- un
event_idnuevo, 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"])const tx = await client.webhooks.deliveries.replay("8b7c6d5e-0000-4a1b-9c2d-000000000301", {
idempotencyKey: "replay-8b7c6d5e-0000-4a1b-9c2d-000000000301",
});
console.log(tx.data.delivery_id, tx.data.replayed_from, tx.data.attempt_number, tx.data.status);$tx = $client->webhooks->deliveries->replay(
'8b7c6d5e-0000-4a1b-9c2d-000000000301',
idempotencyKey: 'replay-8b7c6d5e-0000-4a1b-9c2d-000000000301',
);
echo $tx->data['delivery_id'], ' ', $tx->data['attempt_number'], ' ', $tx->data['status'], PHP_EOL;Transaction tx = await client.Webhooks.Deliveries.ReplayAsync(
"8b7c6d5e-0000-4a1b-9c2d-000000000301",
idempotencyKey: "replay-8b7c6d5e-0000-4a1b-9c2d-000000000301");
Console.WriteLine($"{tx.Data["delivery_id"]} {tx.Data["replayed_from"]} {tx.Data["attempt_number"]} {tx.Data["status"]}");tx, err := client.Webhooks.Deliveries.Replay(ctx, "8b7c6d5e-0000-4a1b-9c2d-000000000301",
compense.WithIdempotencyKey("replay-8b7c6d5e-0000-4a1b-9c2d-000000000301"))
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Data["delivery_id"], tx.Data["replayed_from"], tx.Data["attempt_number"], tx.Data["status"])curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/deliveries/$DELIVERY_ID/replay/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: replay-$DELIVERY_ID"{
"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:
- Recolecta primero los ids de todas las páginas, para
failedy paraabandoned(el filtrostatusadmite un valor por consulta). Así los replays que vas creando no desplazan la paginación. - Reintenta uno por uno con una
Idempotency-Keyestable por entrega. - Tolera los
409: una entrega que ya no es reintentable o un endpoint que no estáactiveno debe detener el lote. El motivo viene endetail(WEBHOOK_DELIVERY_NOT_REPLAYABLE,WEBHOOK_ENDPOINT_INACTIVE).
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)import { CompenseClient, IdempotencyConflictError } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const DESDE = "2026-10-01";
async function idsPorEstado(status: string): Promise<string[]> {
const ids: string[] = [];
const primera = await client.webhooks.deliveries.list({ status, dateFrom: DESDE, pageSize: 100 });
for await (const d of primera) ids.push(String(d.id)); // pide las páginas siguientes por ti
return ids;
}
const pendientes = [...(await idsPorEstado("failed")), ...(await idsPorEstado("abandoned"))];
console.log(`${pendientes.length} entregas por reintentar`);
for (const deliveryId of pendientes) {
try {
const tx = await client.webhooks.deliveries.replay(deliveryId, { idempotencyKey: `replay-${deliveryId}` });
console.log(deliveryId, "→", tx.data.delivery_id);
} catch (err) {
if (err instanceof IdempotencyConflictError) console.log(deliveryId, "omitida:", err.detail);
else throw err;
}
}<?php
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\CompenseClient;
use Compense\Sdk\Exception\IdempotencyConflictException;
$client = new CompenseClient(apiKey: getenv('COMPENSE_API_KEY'), environment: 'sandbox');
const DESDE = '2026-10-01';
const TAM = 100;
/** @return string[] */
function idsPorEstado(CompenseClient $client, string $status): array
{
$ids = [];
for ($pagina = 1; ; $pagina++) {
$page = $client->webhooks->deliveries->list(status: $status, dateFrom: DESDE, page: $pagina, pageSize: TAM);
foreach ($page as $d) {
$ids[] = $d['id'];
}
if ($page->results === [] || $pagina * TAM >= $page->count) {
return $ids;
}
}
}
$pendientes = [...idsPorEstado($client, 'failed'), ...idsPorEstado($client, 'abandoned')];
echo count($pendientes), " entregas por reintentar\n";
foreach ($pendientes as $deliveryId) {
try {
$tx = $client->webhooks->deliveries->replay($deliveryId, idempotencyKey: "replay-{$deliveryId}");
echo $deliveryId, ' → ', $tx->data['delivery_id'], PHP_EOL;
} catch (IdempotencyConflictException $e) { // 409: no reintentable o endpoint inactivo
echo $deliveryId, ' omitida: ', $e->detail, PHP_EOL;
}
}using System.Linq;
using System.Text.Json.Nodes;
using Compense.Sdk;
using Compense.Sdk.Models;
using var client = new CompenseClient(
Environment.GetEnvironmentVariable("COMPENSE_API_KEY")!, CompenseEnvironment.Sandbox);
const string Desde = "2026-10-01";
async Task<List<string>> IdsPorEstado(string status)
{
var ids = new List<string>();
// ListAutoPagingAsync pide las páginas siguientes por ti.
await foreach (JsonObject d in client.Webhooks.Deliveries.ListAutoPagingAsync(
status: status, dateFrom: Desde, pageSize: 100))
{
ids.Add(d["id"]!.GetValue<string>());
}
return ids;
}
var pendientes = (await IdsPorEstado("failed")).Concat(await IdsPorEstado("abandoned")).ToList();
Console.WriteLine($"{pendientes.Count} entregas por reintentar");
foreach (string deliveryId in pendientes)
{
try
{
Transaction tx = await client.Webhooks.Deliveries.ReplayAsync(deliveryId, idempotencyKey: $"replay-{deliveryId}");
Console.WriteLine($"{deliveryId} → {tx.Data["delivery_id"]}");
}
catch (IdempotencyConflictException e) // 409: no reintentable o endpoint inactivo
{
Console.WriteLine($"{deliveryId} omitida: {e.Detail}");
}
}package main
import (
"context"
"errors"
"fmt"
"log"
"os"
compense "github.com/Appiasoft/compense-saas-sdk-go"
)
const desde = "2026-10-01"
func idsPorEstado(ctx context.Context, client *compense.Client, status string) ([]string, error) {
var ids []string
it := compense.NewPageIterator(ctx, func(ctx context.Context, page int) (*compense.Page, error) {
return client.Webhooks.Deliveries.List(ctx, &compense.DeliveryListParams{
Status: status, DateFrom: desde, Page: page, PageSize: 100,
})
})
for it.Next() {
ids = append(ids, fmt.Sprint(it.Item()["id"]))
}
return ids, it.Err()
}
func main() {
ctx := context.Background()
client, err := compense.NewClient(os.Getenv("COMPENSE_API_KEY"),
compense.WithEnvironment(compense.Sandbox))
if err != nil {
log.Fatal(err)
}
var pendientes []string
for _, status := range []string{"failed", "abandoned"} {
ids, err := idsPorEstado(ctx, client, status)
if err != nil {
log.Fatal(err)
}
pendientes = append(pendientes, ids...)
}
fmt.Println(len(pendientes), "entregas por reintentar")
for _, id := range pendientes {
tx, err := client.Webhooks.Deliveries.Replay(ctx, id, compense.WithIdempotencyKey("replay-"+id))
var apiErr *compense.APIError
switch {
case compense.IsIdempotencyConflict(err) && errors.As(err, &apiErr): // 409
fmt.Println(id, "omitida:", apiErr.Detail)
case err != nil:
log.Fatal(err)
default:
fmt.Println(id, "→", tx.Data["delivery_id"])
}
}
}BASE="https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/deliveries"
: > ids.txt
for STATUS in failed abandoned; do
PAGE=1
while :; do
RESP=$(curl -s "$BASE/?status=$STATUS&date_from=2026-10-01&page=$PAGE&page_size=100" \
-H "X-API-Key: $COMPENSE_API_KEY")
echo "$RESP" | jq -r '.results[].id' >> ids.txt
COUNT=$(echo "$RESP" | jq '.count')
[ $((PAGE * 100)) -ge "$COUNT" ] && break
PAGE=$((PAGE + 1))
done
done
while read -r ID; do
curl -s -o /dev/null -w "$ID → %{http_code}\n" -X POST "$BASE/$ID/replay/" \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: replay-$ID"
done < ids.txtPaginació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 #
| HTTP | Código (en detail) | Cuándo |
|---|---|---|
403 | — | La llave no tiene webhooks:read (listar, ver) o webhooks:manage (replay). |
404 | WEBHOOK_DELIVERY_NOT_FOUND | La entrega no existe o es de otra organización. |
409 | WEBHOOK_DELIVERY_NOT_REPLAYABLE | La entrega no está failed ni abandoned. |
409 | WEBHOOK_ENDPOINT_INACTIVE | El endpoint de la entrega no está active. |
409 | — | Misma Idempotency-Key con la petición original aún en curso. |
422 | WEBHOOK_INVALID_FILTER | Filtro 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.