Webhooks
Deliver job events to HTTP endpoints — signed, retried, persisted.
Deliver job events to HTTP endpoints — signed, retried, persisted.
Deliver job, worker, queue, workflow, and predicate events to HTTP endpoints. Deliveries are HMAC-SHA256 signed, retried with exponential backoff, and subscriptions are persisted in storage — they survive restarts.
Deliveries and the dashboard now use the dotted wire names everywhere — a
delivery that used to say "event": "success" now says "event": "job.completed". Stored subscriptions created against the old four names
(success / retry / dead / cancelled) keep matching; nothing to
migrate.
WebhookManager.attach(flexiq) registers the manager as middleware on the
client; create subscriptions with the Webhook builder:
WebhookManager webhooks = WebhookManager.attach(flexiq);
Webhook hook = webhooks.create(Webhook.builder("https://hooks.example.com/jobs")
.on(EventName.DEAD, EventName.SUCCESS, EventName.WORKER_OFFLINE) // any of the 26 events — required, or no deliveries
.secret(System.getenv("WEBHOOK_SECRET"))
.taskFilters("send_email", "resize")); // optional, exact task names
webhooks.list();
webhooks.get(hook.id);
webhooks.delete(hook.id);| Builder method | Default | Description |
|---|---|---|
on(EventName...) | — | Events to deliver — any of the 29 dotted wire names (job/worker/queue/workflow/predicate); see Events. |
secret(String) | none | Signs each delivery as X-Flexiq-Signature: sha256=<hex>. |
taskFilters(String...) | all tasks | Only deliver for these exact task names — screens task-bearing events only (see below). Calls accumulate. |
header(String, String) | — | Extra headers added to every delivery. |
maxRetries(int) | 3 | Retry budget per delivery. |
timeoutMs(long) | 10000 | Per-request HTTP timeout. |
retryBackoff(double) | 2.0 | Backoff base in seconds — the Nth wait (from zero) is retryBackoff ^ N. |
enabled(boolean) | true | Disable without deleting. |
description(String) | — | Free-form note. |
Deliveries fire from the process that attached the WebhookManager — usually
the worker process for job outcomes, though queue-, workflow-, and
predicate-level events fire from wherever that action happened (pause(),
submitWorkflow(), …) on the same FlexiQ client. Every delivery is an
async POST with a JSON body in snake_case; job outcomes look like this:
{
"event": "job.dead",
"job_id": "…",
"task_name": "send_email",
"error": "…",
"retry_count": 3,
"timed_out": false,
"duration_ms": null
}Non-outcome events deliver a per-type body instead:
| Event | Body fields |
|---|---|
JOB_ENQUEUED | job_id, task_name, queue |
QUEUE_PAUSED / QUEUE_RESUMED | queue |
WORKER_* | queues |
| Workflow terminal + saga events | run_id, workflow, error |
WORKFLOW_GATE_REACHED | run_id, node |
WORKFLOW_NODE_* | run_id, node, error |
PREDICATE_REJECTED | task_name, reason |
A subscription's taskFilters only screens events that carry a task name —
job outcomes, JOB_ENQUEUED, and PREDICATE_REJECTED. Worker, queue, and
workflow events have no task identity, so they always deliver regardless of
taskFilters.
Subscriptions live in the shared settings store under
webhooks:subscriptions, so every runtime driving the same queue sees the
same hooks. Hooks written by an older release under flexiq.webhooks fold
into that document the first time the manager reads it.
A send error or a 5xx response is retried with backoff — the Nth wait
(counted from zero) is retryBackoff ^ N seconds, capped at 30 s — up to
maxRetries; 4xx responses are not retried. Verify the signature on your endpoint by HMAC-SHA256-ing the raw body
with the shared secret's raw UTF-8 bytes and comparing against the
X-Flexiq-Signature header in constant time.
Every attempt — successful or not — is recorded in a persistent, per-subscription delivery log (see Dashboard management below); final failures are also logged server-side (with the URL's path redacted). Subscriptions themselves are persisted in the queue's settings store, so every process sharing the backend sees the same hooks; changes propagate to running workers within 30 seconds.
SSRF (tricking the server into fetching an internal URL) is the main risk of
letting users configure outbound webhook targets. Webhook URLs are vetted by
WebhookUrlValidator before dashboard-submitted
subscriptions are stored, and every delivery re-validates the URL right
before sending — regardless of how the subscription was created — so a
hostname that starts safe and is later rebound to an internal address (DNS
rebinding) is still refused. Blocked by default:
http/https schemeslocalhost and *.localhost / *.local / *.internal / *.intranet /
*.lan / *.private100.64.0.0/10), or IPv6 unique-local
(fc00::/7) — including cloud metadata endpoints like 169.254.169.254
(link-local)Set FLEXIQ_WEBHOOKS_ALLOW_PRIVATE (1/true/yes/on) to lift the guard
for local development against http://localhost. Keep it unset in
production.
WebhookManager.create(...) called directly from your own code is trusted
developer input and is not pre-validated at creation time — only
dashboard-submitted URLs are checked at creation. Every delivery, from
either surface, is always re-validated regardless.
The same subscriptions this page describes are also manageable from the
dashboard's Webhooks page, or directly over its REST API — full CRUD,
delivery history, secret rotation, and a synchronous test-ping. It's the same
WebhookManager under the hood (WebhookManager.forQueue, backed by the
queue's settings store), so changes from either surface are immediately
visible to every process sharing the backend.
| Method · Path | Effect |
|---|---|
GET /api/webhooks · /{id} | List / fetch subscriptions. Header values and the secret are masked — only a has_secret flag is returned. |
POST /api/webhooks | Create. Same fields as the builder, plus generate_secret: true to mint one server-side. |
PUT /api/webhooks/{id} | Partial update — only included fields change. |
DELETE /api/webhooks/{id} | Delete the subscription and its delivery log. |
POST /api/webhooks/{id}/test | Synchronously deliver a synthetic test event (single attempt, no retry) and record the outcome. |
POST /api/webhooks/{id}/rotate-secret | Mint a fresh secret and persist it. |
GET /api/webhooks/{id}/deliveries | Paged delivery log — ?status=&event=&limit=&offset= (max 200). |
GET /api/webhooks/{id}/deliveries/{deliveryId} | A single delivery. |
POST /api/webhooks/{id}/deliveries/{deliveryId}/replay | Re-fire a stored payload as a fresh delivery (single attempt); the original record is kept. |
The raw secret is returned exactly once — on create (when set or
generated) and on rotate-secret — never on list/get.
Each subscription keeps its most recent 200 deliveries in a FIFO log
(webhooks:deliveries:<subscriptionId> in the settings store), newest-first
when paged. Each record carries the event type, status
(delivered/failed), attempt count, response code, a response body
truncated to 2 KiB, latency, and any transport error — enough to debug a
failing endpoint without leaving the dashboard.