Signed, SSRF-safe outbound webhooks for QQQ applications. Admins add endpoints and subscribe them to event types: table inserts, updates and deletes (in-process or from the ESB), process completions and failures, and custom domain events. The qbit queues one delivery per event per endpoint in the same transaction as the change. It then signs each delivery in the Standard Webhooks format and sends it with retries, backoff, dead letters and endpoint health. Every row is a normal QQQ table, and the app has a home dashboard.
Design: QRun-IO/qqq#1105 (docs/specs/2026-10-12-qbit-webhooks-assessment.md), and docs/DESIGN.md for what 1.0 implements. Signing and secret generation use QQQ core directly; the local safe HTTP client remains pending a separate migration. Both primitives are available in QQQ core from 4.1.0-RC.5 (QRun-IO/qqq#1109).
- Java 21
- QQQ 4.1.0-RC.8 or later (
qqq-backend-core). Two modules are optional:qqq-middleware-api, for payloads rendered through a QQQ API version.qqq-esb, for ESB topics as an event source.
- A SQL backend for the five tables (DDL in docs/ddl/webhooks-postgres.sql), and a QQQ scheduler for delivery.
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-webhooks</artifactId>
<version>1.0.0</version>
</dependency>new WebhooksQBitProducer()
.withQBitConfig(new WebhooksQBitConfig()
.withBackendName("rdbms")
.withSchedulerName("scheduler")
.withSourceName("myapp") // events say qqq://myapp/...
.withTableEventSources(List.of("order", "customer"))
.withProcessEventSources(List.of("shipOrder")))
.produce(qInstance);
// optional: also consume ESB topics (events from other services)
WebhooksEsbSource.configure(qInstance);Set WEBHOOKS_SECRET_KEY to the base64 of 32 random bytes, so the qbit can generate signing secrets and store them encrypted. Without it, use environment-reference secrets only.
| Table | What it holds | In the nav |
|---|---|---|
webhooks_endpoint |
URL, status (Active, Paused, Disabled), health (Healthy, Probation, Unhealthy), in-order delivery, max per minute, timeout, internal-network allowlist (admin only) | yes |
webhooks_subscription |
Endpoint + event type, record filter (QQQ query filter JSON), detail level, only-these / never-these fields, API name and version | yes |
webhooks_delivery |
One row per event per endpoint: event id (the webhook-id), stored payload, status, attempts, next attempt, lease, last error, dead letter |
yes |
webhooks_endpoint_secret |
Signing secrets: generated (AES-GCM encrypted) or an environment variable name; status Active, Previous (still signing during a rotation), Retired; fingerprint | on the endpoint |
webhooks_delivery_attempt |
One row per HTTP attempt: outcome, status, duration, response excerpt, error, remote address, which secrets signed it. Unique on delivery and attempt number (the claim) | on the delivery |
Deliveries and attempts are written only by the qbit. People act on them through the actions below.
Nothing in this qbit is seeded: endpoints, secrets and subscriptions are your choices, and deliveries and attempts are logs. Instead the app home has a Setup card (event types, encryption key, endpoints, signing secrets, subscriptions, each with its state and next step), and every table has help (help/qbit-webhooks.yaml) that explains how its rows get created and shows when the table is empty.
The event types you can subscribe to come from code, not from a table: the host's tableEventSources, processEventSources and ESB settings, custom events it fires with Webhooks.fire, and a built-in Test event, all held in the WebhooksRegistry.
The producer registers help/qbit-webhooks.yaml, a QBIT bundle covering the app, tables, fields, actions, widgets and possible-value sources. QQQ synchronizes it on startup when the host has the helpContent table; registration is safe without that table, and inline app/table empty-state help remains available.
Hosts can register their own YAML bundle with layer: HOST through HelpBundles.register(qInstance, "help/my-host.yaml"). Use the same entry keys and roles to override selected text with deployment-specific guidance. Keep live {{label:...}}, {{values:...}} and {{default:...}} references where appropriate. If you customize the app name, supply its help under the customized app key in your host bundle.
WebhooksHelpBundleTest uses QQQ's HelpBundleAssertions against the produced instance, including the optional ESB process, to check coverage, summary length and live references. Run it with ./mvnw -q -Dtest=WebhooksHelpBundleTest test; the full ./mvnw -q verify also runs it. Keep the bundle's literal version aligned with the POM revision when releasing.
| Permission | Grants |
|---|---|
webhooks.view |
The app, the home, every table (read) |
webhooks.manage |
Create and edit endpoints and subscriptions; Send Now, Replay, Cancel Delivery, Send Test Event |
webhooks.admin |
Signing secrets (Rotate Signing Secret, environment references, the secret value), internal-network targets, deleting endpoints, Delete Deliveries |
Grant WebhooksPermissions.ALL (which includes webhooks.hasAccess) to administrators. Subscribing to a table's events also needs read permission on that table. Record security locks from withSecurityFields / withRecordSecurityLocks apply to endpoints, subscriptions and deliveries. A subscription only receives records its security values could read.
All of these are table or record actions with the standard review and result screens. None is in the nav.
| Action | On | Does |
|---|---|---|
| Send Now | Deliveries | Sends the selected unfinished deliveries now |
| Replay | Deliveries | Queues finished deliveries (dead letters included) again, with the same event id and a fresh attempt budget |
| Cancel Delivery | Deliveries | Stops pending deliveries |
| Delete Deliveries | Deliveries | Deletes finished deliveries and their attempts (admin) |
| Send Test Event | Endpoint | Sends a signed webhooks.test event now and shows the result |
| Rotate Signing Secret | Endpoint | Creates a new secret; the old one keeps signing for rotationOverlapHours (admin) |
The scheduler runs delivery every deliveryIntervalSeconds (15). It runs maintenance every 5 minutes (probation, retiring old secrets) and the retention purge daily.
| Setting | Default | Meaning |
|---|---|---|
backendName |
required | Backend of the five tables |
schedulerName |
none | Without it nothing is sent on a schedule |
tableEventSources |
none | Local tables: <table>.inserted / updated / deleted |
processEventSources |
none | process.<name>.completed / failed |
esbDestinations, esbTableEventSources, esbSkipOwnEvents |
none, none, true | ESB topics to consume; remote tables; skip this instance's own events |
maxAttempts, retryMinutes |
6, [1, 5, 15, 60, 240, 720] | Then the delivery is a dead letter |
leaseSeconds |
120 | A claimed delivery whose runner died is retried after this |
connectTimeoutMillis, httpTimeoutMillis, totalTimeoutMillis, maxResponseBytes |
5000, 10000, 30000, 65536 | HTTP limits (an endpoint's timeoutSeconds overrides the read timeout) |
allowPlainHttp |
false | https only by default |
unhealthyAfterConsecutiveFailures, probationAfterMinutes, autoDisableAfterProbationFailures |
10, 60, 3 | Endpoint health |
encryptionKeyEnvName, rotationOverlapHours |
WEBHOOKS_SECRET_KEY, 24 |
Secret storage and rotation |
deliveredRetentionDays, attemptRetentionDays, deadLetterRetentionDays |
400, 400, 0 | 0 keeps forever |
snakeCaseColumnNames |
true | Column names as in docs/ddl |
appName, appLabel, appPath, tablePathPrefix |
webhooks, Webhooks |
The app and the links on its home |
Every request carries webhook-id, webhook-timestamp, webhook-signature (v1,<base64 HMAC-SHA256>, more than one during a rotation) and webhook-attempt. Verify the signature, reject old timestamps, and ignore an id you have already processed. Delivery is at least once. See docs/RECEIVERS.md for the format and code in Java, Node.js and Python.
- Add the dependency and run docs/ddl/webhooks-postgres.sql (as a Liquibase changeset).
- Produce the qbit (above) after your tables exist. Add its app under your admin app if you have one.
- Grant the permissions to your admins, and set
WEBHOOKS_SECRET_KEYin the environment. - Optional hooks, through
WebhooksIntegrations:- publish
esbTableNames()to your ESB; - index
quickSearchTables()in Quick Search; - expose
apiTableNames()in your QQQ API.
- publish
- Other qbits deliver through
Webhooks.deliver(endpointName, type, data, idempotencyKey)(the channel API), and code fires domain events withWebhooks.fire(...).
Upgrading from 0.x: see docs/migration-0.x-to-1.0.md.
./mvnw verify # tests, Checkstyle and the JaCoCo gate (always use the wrapper)The producer registers passive checks with QQQ central health monitoring (requires QQQ 4.1.0-RC.8 or later):
| Name | Kind | Category | Interval |
|---|---|---|---|
webhooks.endpoint.<id> |
DEPENDENCY | INTEGRATION | 300 seconds |
webhooks.dispatcher |
DEPENDENCY | INTEGRATION | 300 seconds |
Both belong to component webhooks. Endpoint checks read the existing state machine: HEALTHY means UP; UNHEALTHY or PROBATION means DEGRADED; automatically disabled means DOWN. Active and automatically disabled endpoints are discovered dynamically; paused and manually disabled endpoints are excluded. Details contain only healthStatus, consecutiveFailures and unhealthySince when present.
The dispatcher reports oldestPendingSeconds, measured from the oldest queued delivery's createDate across PENDING, RETRY_WAIT and SENDING. More than 15 minutes is DEGRADED; more than 60 minutes is DOWN. An empty queue reports UP with zero age. Checks do not send requests or change endpoint state.
The home shows one native central status line linking to the webhooks checks in System Health. Disable checks centrally with health_check.enabled. The endpoint state machine, alerts and Send Test Event action remain unchanged.