Hedra Webhooks API
Configure, test, verify, and inspect outbound webhooks. **Delivery and retries.** Delivery is at-least-once. A `2xx` from your endpoint is success; anything else — including a redirect, which is never followed — is retried. Hedra makes up to **12 attempts over approximately 6 hours**, backing off 10s, 30s, 90s, 4m30s, 13m30s, 40m30s, then hourly. An endpoint that is unreachable for the whole window is marked `FAILED` and is not retried again; replay it with `POST /webhooks/deliveries/{job_id}/redeliver`. Two failures are permanent and stop the ladder immediately: a URL that resolves to a blocked address range, and a redirect pointing at one. Because the retry window is bounded, respond quickly and do your own processing asynchronously — acknowledge with a `2xx` first. **Deduplication.** Key on **`X-Hedra-Webhook-Id`**. It identifies the *event* — it is the job's own id, and it is byte-identical across every retry and every replay. Do **not** hash the request body: each attempt re-signs the output URL, so the body legitimately differs between attempts of the same event and a body hash would treat every retry as new. Each POST also carries `X-Hedra-Webhook-Attempt` (1-based, informational) and **`X-Hedra-Webhook-Redelivery`** (`true` / `false`). Redelivery is the exception to deduplication: when it is `true`, an operator asked for this event to be sent again — process it even if you have already recorded that id, because that request is the whole reason it was sent. **Inspecting deliveries.** `GET /webhooks/deliveries` reports each delivery's cumulative `attempts`, its latest outcome (`status`, `last_response_status`, `last_error`), and its replay history: `redelivery_count` counts operator replays, and `redeliveries` archives the outcome each replay superseded — a replay re-sends on the same delivery record. Each entry's `attempts` value is the cumulative total when that replay was requested, so the per-cycle attempt counts (and any total beyond the 12-attempt ladder) can be read off the record directly. **Why a delivery failed.** `last_error` — on the delivery and on every archived replay entry — is the same error envelope a failed job returns from `GET /jobs/{job_id}`, not a free-text diagnostic. Its `code` comes from the one error vocabulary this API uses, narrowed for delivery to this set: | `code` | Means | | --- | --- | | `DEADLINE_EXCEEDED` | Your endpoint did not respond before the delivery timeout. | | `UNAVAILABLE` | Your endpoint could not be reached (DNS, TLS, or connection failure) or answered `5xx` — or redirected, which is never followed. | | `RESOURCE_EXHAUSTED` | Your endpoint answered `429`. | | `UNAUTHORIZED` | Your endpoint answered `401`. | | `PERMISSION_DENIED` | Your endpoint answered `403`. | | `NOT_FOUND` | Your endpoint answered `404`. | | `INVALID_ARGUMENT` | Your endpoint answered some other `4xx`. | | `FAILED_PRECONDITION` | The URL is not a permitted webhook target — it resolves to a blocked address range, or redirects to one. Fix the URL; replaying will not help. | | `INTERNAL` | Hedra could not build the payload. | | `UNKNOWN` | No classification is available, including for deliveries that failed before this field became structured. | `retryable` describes the condition, not what Hedra did: every non-2xx response is retried on the ladder above, so it answers whether replaying the delivery is likely to help. The `message` is a fixed summary — your endpoint's URL, address, headers, credentials, and response body are never echoed back, so treat your own logs, not this field, as the record of what your endpoint returned. **Verifying a delivery.** Every request carries `X-Hedra-Webhook-Signature`, a hex-encoded ed25519 signature over a canonical string of **five newline-separated fields, in this order**: ``` {X-Hedra-Webhook-Timestamp} {X-Hedra-Webhook-Id} {X-Hedra-Webhook-Event} {X-Hedra-Webhook-Redelivery} {sha256 hex digest of the raw request body} ``` Rebuild that string from the request you received, verify it against the key from `GET /webhooks/public-key`, and reject the delivery if the timestamp is more than 5 minutes old. Hash the body **exactly as received**, before any JSON parsing or re-serialization. The signature deliberately covers the deduplication id and the redelivery flag, not just the body — both of them decide whether you process a duplicate, so an unsigned copy of either would let anyone who captured a delivery replay it past your idempotency check. Verify *before* you act on any header. `X-Hedra-Webhook-Attempt` is outside the signature and is informational only; do not branch on it.