Every time Compense tries to bring an event to one of your endpoints it creates a delivery. If your receiver doesn’t respond 2xx, the delivery is retried automatically; and everything is kept in a history you can query, filter and, for deliveries that failed, retry manually.

Automatic retries #

A delivery fails when your receiver responds with a non-2xx code, when it doesn’t respond within 10 seconds or when there’s a network error (DNS, TLS, connection refused). Compense then retries the same delivery (same body and same X-Webhook-Id) with exponential backoff:

AttemptWhen it goes outApproximate cumulative time
OriginalAs soon as the event happens0
Retry 130 seconds later30 s
Retry 22 minutes later2 min 30 s
Retry 38 minutes later10 min 30 s
Retry 432 minutes later42 min 30 s
Retry 5~2 hours later~2 h 43 min
  • The number of retries is the endpoint’s max_retries (5 by default) and the per-attempt timeout is its timeout_seconds (10). Both come in the endpoint response.
  • If the retries run out without a 2xx, the delivery becomes abandoned. It’s no longer retried automatically: you can retry it manually.
  • Since the same event can arrive several times, your receiver must deduplicate by event_id.

Delivery states #

StateMeaning
pendingQueued, not sent yet.
in_flightBeing sent right now.
successYour receiver responded 2xx.
failedThe attempt failed (non-2xx, timeout or network error).
abandonedThe automatic retries ran out.
stateDiagram-v2
    [*] --> pending
    pending --> in_flight
    in_flight --> success: 2xx response
    in_flight --> failed: non-2xx, timeout or network error
    failed --> in_flight: automatic retry (backoff)
    failed --> abandoned: max_retries exhausted
    failed --> pending: manual replay (child delivery)
    abandoned --> pending: manual replay (child delivery)
    success --> [*]

When the endpoint stops receiving #

  • 410 Gone disables. Responding 410 tells Compense the URL no longer exists: after repeated 410 responses (3) the endpoint becomes disabled and stops receiving events. A disabled endpoint can’t be reactivated through the API: create another one or contact support. Don’t use 410 to reject a single delivery.
  • failing warns you. If in 24 hours an endpoint had 10 or more deliveries and less than 50% were successful, Compense marks it failing. Check the history and your receiver’s health.
  • paused is your call. A paused endpoint (active: false) doesn’t receive events. See Subscribe an endpoint.

Delivery history #

GET/v1/gateway/webhooks/deliveries/ lists your endpoints’ deliveries, from newest to oldest, with these filters:

ParameterValues
statuspending, in_flight, success, failed, abandoned (one per query)
event_typeCatalog key, e.g. credits.decision
endpoint_idId of one of your endpoints
date_from, date_toYYYY-MM-DD or ISO-8601. They filter by enqueue date; date_to is inclusive (through the end of that day)
page, page_sizePage-number pagination; page_size max 100, default 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"])

Response (one page):

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 tells you whether the delivery allows a manual retry (failed or abandoned).

Delivery details #

GET/v1/gateway/webhooks/deliveries/{delivery_id}/ returns everything you need to troubleshoot:

  • request.payload: the body that was sent.
  • request.headers: the headers that were sent. Signature headers are shown as is; any other one appears as [redactado].
  • response: the status_code your receiver returned and the first 2 KB of its body (body_preview).
  • attempts: every attempt for the same event (the original delivery and its replays), with their code, duration and error.
  • error_class and error_message: why it failed, if it failed.

The details never include the secret.

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"])

Response (abridged):

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 }
  ]
}

Manual retry (replay) #

POST/v1/gateway/webhooks/deliveries/{delivery_id}/replay/ sends a delivery again. It doesn’t modify it: it creates a child delivery with:

  • the same payload;
  • attempt_number = the original’s + 1 and parent_delivery_id = the original;
  • a new event_id, in the form <original event_id>-retry-<hex>.

Only deliveries in the failed or abandoned state can be retried, and only if the endpoint is active. In any other case the response is 409.

The replay comes with a different event_id

Since the replay’s event_id is new, your deduplication by event_id won’t discard it. That’s what you want when the original was never processed; if it might have been processed (for example, your receiver applied the event and then responded 500), make your processing idempotent by the resource’s business key as well.

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"
  }
}

Use an Idempotency-Key derived from the delivery id (replay-<delivery_id>): if your script gets interrupted and you run it again, you won’t fire two replays of the same delivery. Follow the result through the child delivery’s details (data.delivery_id).

From the terminal: compense replay <delivery_id> (see The compense CLI).

Reprocess every failed delivery #

After your receiver goes down (a broken deployment, an expired certificate, a poorly coordinated rotation) you’ll want to bulk-retry everything left failed or abandoned. The pattern:

  1. Collect first the ids from every page, for failed and for abandoned (the status filter accepts one value per query). That way the replays you create don’t shift the pagination.
  2. Retry one by one with a stable Idempotency-Key per delivery.
  3. Tolerate 409s: a delivery that is no longer retryable or an endpoint that isn’t active shouldn’t stop the batch. The reason comes in detail (WEBHOOK_DELIVERY_NOT_REPLAYABLE, WEBHOOK_ENDPOINT_INACTIVE).
Reprocess every failed delivery
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)} deliveries to retry")

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: not retryable or endpoint inactive
        print(delivery_id, "skipped:", e.detail)

Auto-pagination and the next field

The JavaScript (for await), .NET (ListAutoPagingAsync) and Go (NewPageIterator) iterators fetch the next page as long as the response includes next. This list’s contract guarantees count, page, page_size and results, but not next (the sandbox doesn’t send it). If your response doesn’t include it, those iterators stop at the first page: use page_size: 100 and, if count is larger, walk through page as in the Python, PHP and cURL tabs. More in Pagination.

Errors #

HTTPCode (in detail)When
403—The key doesn’t have webhooks:read (list, view) or webhooks:manage (replay).
404WEBHOOK_DELIVERY_NOT_FOUNDThe delivery doesn’t exist or belongs to another organization.
409WEBHOOK_DELIVERY_NOT_REPLAYABLEThe delivery is neither failed nor abandoned.
409WEBHOOK_ENDPOINT_INACTIVEThe delivery’s endpoint isn’t active.
409—Same Idempotency-Key while the original request is still in progress.
422WEBHOOK_INVALID_FILTERInvalid filter (status, UUID or date).
429—Per-minute limit or key quota. Honor Retry-After in large batches.

A 409 isn’t always an idempotency conflict even though the SDK raises it with that class (IdempotencyConflictError, IdempotencyConflictException, compense.ErrIdempotencyConflict): read detail to find out which one it is. See Errors.