Webhooks
Retries and deliveries
How Compense retries a failed delivery (30 s, 2 min, 8 min, 32 min and ~2 h), when it becomes abandoned or the endpoint gets disabled, how to query the history with filters and pagination, view the details with their attempts and reprocess every failed delivery.
On this page
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:
| Attempt | When it goes out | Approximate cumulative time |
|---|---|---|
| Original | As soon as the event happens | 0 |
| Retry 1 | 30 seconds later | 30 s |
| Retry 2 | 2 minutes later | 2 min 30 s |
| Retry 3 | 8 minutes later | 10 min 30 s |
| Retry 4 | 32 minutes later | 42 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 itstimeout_seconds(10). Both come in the endpoint response. - If the retries run out without a
2xx, the delivery becomesabandoned. 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 #
| State | Meaning |
|---|---|
pending | Queued, not sent yet. |
in_flight | Being sent right now. |
success | Your receiver responded 2xx. |
failed | The attempt failed (non-2xx, timeout or network error). |
abandoned | The 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 Gonedisables. Responding410tells Compense the URL no longer exists: after repeated410responses (3) the endpoint becomesdisabledand stops receiving events. A disabled endpoint can’t be reactivated through the API: create another one or contact support. Don’t use410to reject a single delivery.failingwarns you. If in 24 hours an endpoint had 10 or more deliveries and less than 50% were successful, Compense marks itfailing. Check the history and your receiver’s health.pausedis 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:
| Parameter | Values |
|---|---|
status | pending, in_flight, success, failed, abandoned (one per query) |
event_type | Catalog key, e.g. credits.decision |
endpoint_id | Id of one of your endpoints |
date_from, date_to | YYYY-MM-DD or ISO-8601. They filter by enqueue date; date_to is inclusive (through the end of that day) |
page, page_size | Page-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"])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"Response (one page):
{
"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: thestatus_codeyour 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_classanderror_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"])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"Response (abridged):
{
"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 andparent_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"])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"
}
}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:
- Collect first the ids from every page, for
failedand forabandoned(thestatusfilter accepts one value per query). That way the replays you create don’t shift the pagination. - Retry one by one with a stable
Idempotency-Keyper delivery. - Tolerate
409s: a delivery that is no longer retryable or an endpoint that isn’tactiveshouldn’t stop the batch. The reason comes indetail(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)} 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)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)); // fetches the following pages for you
return ids;
}
const pendientes = [...(await idsPorEstado("failed")), ...(await idsPorEstado("abandoned"))];
console.log(`${pendientes.length} deliveries to retry`);
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, "skipped:", 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), " deliveries to retry\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: not retryable or endpoint inactive
echo $deliveryId, ' skipped: ', $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 fetches the following pages for you.
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} deliveries to retry");
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: not retryable or endpoint inactive
{
Console.WriteLine($"{deliveryId} skipped: {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), "deliveries to retry")
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, "skipped:", 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.txtAuto-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 #
| HTTP | Code (in detail) | When |
|---|---|---|
403 | — | The key doesn’t have webhooks:read (list, view) or webhooks:manage (replay). |
404 | WEBHOOK_DELIVERY_NOT_FOUND | The delivery doesn’t exist or belongs to another organization. |
409 | WEBHOOK_DELIVERY_NOT_REPLAYABLE | The delivery is neither failed nor abandoned. |
409 | WEBHOOK_ENDPOINT_INACTIVE | The delivery’s endpoint isn’t active. |
409 | — | Same Idempotency-Key while the original request is still in progress. |
422 | WEBHOOK_INVALID_FILTER | Invalid 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.