Deliveries & Retries

A webhook that fires into a void is worse than no webhook: you believe the other system knows, and it does not. FerrisKey records every delivery attempt as a row you can query, with the HTTP status it got back, the error class when there was no status at all, and when it will try again.

FerrisKey webhooks list showing endpoint count, subscription count, never-triggered and without-event counters FerrisKey webhooks list showing endpoint count, subscription count, never-triggered and without-event counters

The delivery record

GET /realms/{realm_name}/webhooks/{webhook_id}/deliveries
GET /realms/{realm_name}/webhooks/{webhook_id}/deliveries/{delivery_id}
FieldDescription
idDelivery identifier, used for replay
eventThe trigger that produced it, for example user.created
resource_idThe id of the object the event is about
statuspending, delivering, succeeded or failed
attempt_countHow many attempts have been made so far
next_attempt_atWhen the worker will try again. Absent once the delivery is terminal
last_attempt_atWhen the last attempt ran
last_status_codeHTTP status returned by your endpoint, when it answered at all
last_error_codeWhy the attempt failed, when there was no usable HTTP response
last_error_detailFree-text detail for that error
created_at, updated_atRow timestamps

The single-delivery read wraps the same summary alongside the payload that was sent, which is what you compare against what your endpoint logged:

{
  "data": {
    "summary": { "id": "…", "event": "user.created", "status": "failed", "attempt_count": 5, "last_status_code": 500 },
    "payload": { "…": "the body that was posted" }
  }
}

Status, and what it means for you

StatusMeaning
pendingQueued, waiting for its turn or for next_attempt_at
deliveringA worker holds a lease on it right now
succeededYour endpoint answered with a success status
failedTerminal. The attempts ran out, or the error is not worth retrying

Two fields, two kinds of failure

last_status_code and last_error_code answer different questions. A 500 in last_status_code means your endpoint was reached and broke — the problem is on your side of the wire. An error code with no status means the request never got that far: DNS, TLS, a refused address. Read them in that order.

Error codes

When there is no HTTP response to report, the delivery carries one of ten error codes. They separate the causes that need different fixes:

CodeWhat happenedWhere to look
dns_resolution_failedThe endpoint host did not resolveDNS, or a typo in the endpoint
no_usable_addressIt resolved, but to nothing FerrisKey may callOften a private address with the guard on — see below
cleartext_not_allowedThe endpoint is http:// and cleartext is refusedUse https://
malformed_endpointThe stored endpoint is not a usable URLThe webhook’s configuration
missing_hostThe URL carries no hostSame
reserved_headerA custom header collides with one FerrisKey sets itselfRemove it from the webhook’s headers
header_encoding_failedA custom header value is not encodableSame
client_build_failedThe HTTP client could not be constructed for this endpointUsually a TLS or proxy problem server-side
transport_errorThe request failed in transit: connection reset, timeoutNetwork, or your endpoint dropping the connection
http_<status>Your endpoint answered with a failing status. The code is interpolated, so a 500 reads http_500last_status_code has the number on its own

Private addresses are refused by default

An endpoint that resolves to loopback or a private range is refused with no_usable_address, because a webhook that can reach inside the deployment’s own network is an SSRF primitive. For local development, WEBHOOK_ALLOW_PRIVATE_ENDPOINTS=true lifts the restriction — never in production. Link-local addresses stay refused regardless of that setting.

Automatic retries

A failed delivery is retried with exponential backoff and jitter. Four numbers govern it:

SettingSystem defaultBounds
max_attempts51 to 20
base_delay_ms500 ms100 ms to 60 s
max_delay_ms30 sat least base_delay_ms
max_total_delay_ms120 s—

base_delay_ms is the wait before the first retry; each subsequent wait grows and is capped by max_delay_ms. max_total_delay_ms caps the whole sequence, so a delivery stops being retried when either the attempt count or the total elapsed budget runs out — whichever comes first.

Jitter is applied to each interval, which is what stops a hundred deliveries that failed together from retrying in lockstep and hammering your endpoint the moment it comes back.

Where the numbers come from

The policy resolves in three layers, each field independently:

  1. The webhook’s own retry_policy, when it sets that field.
  2. Otherwise the realm’s setting — webhook_retry_max_attempts, webhook_retry_base_delay_ms, webhook_retry_max_delay_ms, webhook_retry_max_total_delay_ms.
  3. Otherwise the system default above.

Because the resolution is per field, a webhook can raise only max_attempts and inherit the three delays. A webhook read returns both retry_policy (what this webhook overrides) and effective_retry_policy (what will actually be applied) — use the second one when you want to know what the worker will do.

When the attempts run out

The delivery goes failed, and SeaWatch records webhook_delivery_exhausted. That event is the one to alert on: it is the difference between “a delivery failed and recovered” and “this endpoint has been unreachable long enough that we gave up”.

Manual replay

POST /realms/{realm_name}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry

Puts a terminal delivery back in the queue so the worker attempts it again. This is the tool for the ordinary case: the receiving service was down for an hour, the deliveries exhausted their attempts, the service is back and you want the events it missed.

Replay re-sends the stored payload, unchanged. It is not a fresh snapshot of the resource — the event described the world as it was, and replaying it says the same thing again. If the resource has since changed twice more, your endpoint will also receive those deliveries, and the order it sees them in is the order they are replayed.

Make your endpoint idempotent

Between automatic retries and manual replay, an endpoint will eventually receive the same delivery twice. The delivery id is stable across attempts and replays, which makes it the natural deduplication key.

Rotating the signing secret

POST /realms/{realm_name}/webhooks/{webhook_id}/secret/rotate

Generates a new signing secret and returns it. This is the only response that ever contains the secret — it cannot be read back afterwards, so capture it from this call or rotate again.

Rotation breaks in-flight deliveries

Deliveries already signed with the previous secret will fail verification at your endpoint. Rotate when you have somewhere to put the new secret immediately, and expect the deliveries queued at that moment to fail and retry — they will be re-signed on the next attempt.

Reading the console

The webhooks list summarises the state of every endpoint at a glance:

CounterReads as
TotalEndpoints configured in this realm
SubscriptionsHow many triggers are subscribed across all webhooks
Never triggeredEndpoints that have no delivery yet — either new, or subscribed to something that never happens
Without eventEndpoints subscribed to nothing. They will never fire

“Without event” above zero is almost always a mistake: a webhook was created and its triggers were never picked.

API reference

MethodPathDescription
GET/realms/{realm_name}/webhooksList the realm’s webhooks
POST/realms/{realm_name}/webhooksCreate a webhook
GET/realms/{realm_name}/webhooks/{webhook_id}Read one, with its effective retry policy
PUT/realms/{realm_name}/webhooks/{webhook_id}Update endpoint, headers, triggers, retry policy
DELETE/realms/{realm_name}/webhooks/{webhook_id}Delete a webhook
GET/realms/{realm_name}/webhooks/{webhook_id}/deliveriesList deliveries
GET/realms/{realm_name}/webhooks/{webhook_id}/deliveries/{delivery_id}One delivery, with its payload
POST/realms/{realm_name}/webhooks/{webhook_id}/deliveries/{delivery_id}/retryReplay a delivery
POST/realms/{realm_name}/webhooks/{webhook_id}/secret/rotateRotate the signing secret

Required permissions: view_webhooks or manage_webhooks to read, manage_webhooks to create, update, delete, replay or rotate. manage_realm grants all of them. Note that query_webhooks exists as a permission but is not what the read endpoints accept.

Debugging a webhook that is not arriving

Check it is subscribed to anything

“Without event” on the list, or an empty subscribers, means it will never fire whatever you do next.

Look for a delivery at all

No delivery row means the event never matched a subscription. Check the trigger name — they are exact strings, and user.updated is not user.update.

Read the last error

A last_status_code points at your endpoint. A last_error_code points at the path to it. The table above says which is which.

Fix, then replay

Do not wait for the next event to find out whether the fix worked. Replay the failed delivery: same payload, same signature contract, immediate answer.