Webhooks
Dashboard-managed HMAC-signed HTTP delivery of lifecycle events, with SSRF guards, a persistent delivery log, and replay.
Dashboard-managed HMAC-signed HTTP delivery of lifecycle events, with SSRF guards, a persistent delivery log, and replay.
Webhooks deliver lifecycle events to
external endpoints as HTTP POSTs, HMAC-signed when the subscription sets a
secret. Unlike in-process listeners,
subscriptions are first-class persisted resources — they survive restarts,
propagate across every worker pointed at the same backend, and are fully
manageable from the dashboard.
The Webhooks page (sidebar → Configuration → Webhooks) lists every subscription with its URL, event filter, optional task filter, retry policy, and status.

Click + New webhook to add an endpoint. The dialog walks you through URL, optional description, the event-type multi-select, an optional per-task filter, and a checkbox to auto-generate an HMAC-SHA256 signing secret.

After save, the new secret is shown once in a copy-and-reveal card — treat it like an API key. The same flow applies when you rotate the secret later from the row-actions menu.
Each row has a "⋯" menu:
| Action | Effect |
|---|---|
| View deliveries | Open the persistent delivery log (see below) |
| Send test | POST a synthetic test.ping event synchronously and toast the result |
| Enable / Disable | Flip the active flag without losing the configuration |
| Rotate secret | Generate a new HMAC secret. Confirm dialog prevents accidents |
| Delete | Type-to-confirm destructive dialog removes the subscription |
The same operations are available programmatically:
from flexiq import Queue
from flexiq.events import EventType
queue = Queue(db_path="tasks.db")
sub = queue.add_webhook(
url="https://hooks.example.com/ops-failures",
events=[EventType.JOB_FAILED, EventType.JOB_DEAD],
secret="whsec_my_signing_secret",
description="Page ops on permanent failures",
max_retries=5,
timeout=8.0,
task_filter=["myapp.tasks.send_email"], # optional per-task gate
)
print(sub.id) # use this to update / remove later
queue.update_webhook(sub.id, enabled=False)
queue.rotate_webhook_secret(sub.id)
queue.remove_webhook(sub.id)| Parameter | Type | Default | Description |
|---|---|---|---|
url | str | — | http/https URL. SSRF-guarded (server-side request forgery) — see below |
events | list[EventType] | None | None | Event types to subscribe to. None means all events |
task_filter | list[str] | None | None | Restrict to specific task names. None means all tasks |
headers | dict[str, str] | None | None | Extra HTTP headers (e.g. Authorization) |
secret | str | None | None | HMAC-SHA256 signing key |
max_retries | int | 3 | Total delivery attempts, initial included — 3 means one attempt plus two retries |
timeout | float | 10.0 | HTTP request timeout in seconds |
retry_backoff | float | 2.0 | Base for exponential backoff between retries |
description | str | None | None | Free-form label shown in the dashboard |
When a secret is set, every webhook request includes
X-Flexiq-Signature: sha256=<hex digest>. Verify it on the receiving end:
import hashlib
import hmac
def verify_signature(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)The signature is computed over the raw JSON request body. Verify before parsing the body — that way a forged payload never reaches your business logic.
The secret column stores the value as-is in the dashboard settings table. The
DB is already trusted with everything else flexiq persists (job payloads,
error tracebacks). If you need at-rest encryption beyond filesystem-level
(e.g. SQLite encrypted with SQLCipher), the dashboard never returns the raw
secret after the initial create / rotate response — only a has_secret
indicator.
Outbound webhook URLs are validated both when a webhook is registered and again
at delivery time, so a hostname that is rebound to an internal address after
registration (DNS rebinding) is still refused on the actual request. Redirects
are not followed, so a 30x response can't bounce delivery to an internal host
either. The guard rejects:
http / https schemeslocalhost, *.local, *.internal, *.intranet, *.lan, *.private169.254.169.254)FLEXIQ_WEBHOOKS_ALLOW_PRIVATE=1 lifts the guard, for local development only.
Leave it unset in production so the guard stays active.
Failed webhook deliveries are retried with exponential backoff, configurable per
subscription via max_retries, timeout, and retry_backoff. max_retries
counts total attempts, so the defaults (max_retries=3,
retry_backoff=2.0) give one initial delivery and two retries:
| Attempt | Delay before the next one |
|---|---|
| 1st (initial) | 1 second (2.0 ** 0) |
| 2nd | 2 seconds (2.0 ** 1) |
| 3rd | — final; the delivery is given up on |
4xx responses are NOT retried — they're treated as client errors and the
delivery is marked failed. 5xx responses retry until exhausted, at which point
the delivery is marked dead.
Every webhook attempt — successful, failed, or dead-lettered — is recorded under the subscription so you can debug failures without leaving the dashboard.

Each row carries:
job.completed, job.failed, etc.)delivered (green), failed (yellow), dead (red)Click any row to inspect the full payload, the truncated response body (first 2 KiB), and any transport-level error. The Replay button re-fires the stored payload synchronously and records the outcome as a fresh delivery — the original record is preserved for the audit trail.
Each subscription keeps the most recent 200 deliveries in a FIFO ring buffer per
webhook (configurable via the DeliveryStore(max_per_webhook=N) constructor).
Successful and failed deliveries are stored uniformly so the replay view always
matches what really happened.
queue.add_webhook(
url="https://monitoring.example.com/api/flexiq-events",
events=[EventType.JOB_COMPLETED, EventType.JOB_FAILED, EventType.JOB_DEAD],
secret="whsec_abc123",
headers={"X-Source": "flexiq-prod"},
description="Forward terminal job outcomes to the monitoring service",
)Payload shape received by the endpoint:
{
"event": "job.failed",
"job_id": "01H5K6X...",
"task_name": "myapp.tasks.process",
"queue": "default",
"error": "ConnectionError: ..."
}A minimal Flask app that receives and verifies flexiq webhooks:
from flask import Flask, request, abort
import hashlib, hmac
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_my_signing_secret"
@app.route("/hooks/flexiq", methods=["POST"])
def receive_webhook():
signature = request.headers.get("X-Flexiq-Signature", "")
expected = hmac.new(
WEBHOOK_SECRET.encode(), request.data, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(f"sha256={expected}", signature):
abort(401)
event = request.json
print(f"Received event: {event['event']} for job {event['job_id']}")
return "", 204