Extending the API
Events
Events #
Husaria's events subsystem dispatches webhook calls in response to row changes or scheduled (cron) triggers. The pipeline is end-to-end as of phase 2 — a row change or a cron tick lands an invocation in the queue, the dispatcher delivers it, retries it, and writes the terminal status.
Three internal components run together when HUSARIA_ENABLE_EVENTS=true:
| Component | Role |
|---|---|
| NOTIFY consumer | Holds one Postgres connection on LISTEN husaria_events. Decodes each NOTIFY payload and writes a pending invocation row. |
| Cron scheduler | Ticks once a second, scans enabled scheduled triggers, enqueues invocations when a cron expression is due. |
| Dispatcher | Polls the queue every second, claims due rows under an advisory lock, fans them out across a worker pool, POSTs the webhook with retry / SSRF guard / HMAC signing. |
When to use an event #
- You need to notify an external service when a row changes (audit log, CDC, search index, email).
- You need a recurring webhook on a cron schedule.
- You want at-least-once delivery semantics with retries.
- The receiver is OK seeing the same event ID more than once and idempotency-keying on it.
For request-response synchronous flows, use actions instead. For querying an external REST API as a GraphQL field, use delegations.
Not sure which surface fits? See the side-by-side comparison of Actions vs Delegations vs Events on the introduction page.
Trigger kinds #
Two trigger_type values are accepted:
| Kind | Required fields | Notes |
|---|---|---|
database (default) | table_name, operations (≥1) | Operations come from {INSERT, UPDATE, DELETE, TRUNCATE}. No duplicates. schema_name defaults to public when empty. The NOTIFY producer installs an AFTER row trigger on the target table when the trigger row is created. |
scheduled | cron_schedule | 5 or 6 whitespace-separated fields (six-field optional for sub-minute schedules). cron_timezone honoured by the scheduler. |
Common rules: name and webhook_url are required; webhook_url must be http:// or https:// with a non-empty host. Validation happens before the row touches the DB — half-built rows can't persist.
Enabling the subsystem #
export HUSARIA_ENABLE_EVENTS=true
export HUSARIA_EVENTS_SIGNING_SECRET=$(openssl rand -hex 32)
When HUSARIA_ENABLE_EVENTS=true and the Postgres pool is wired, Husaria starts the Consumer, Scheduler, and Dispatcher together at boot. EnsureNotifyFunction runs once at startup so an existing-DB upgrade picks the plpgsql function up without a separate migration step.
Without the env var, the console Events tab returns 503 — it's not just hidden, it's actively disabled. The 503 body is {"error": "events backend not wired"} so operators get a clear "feature not wired" signal rather than a silent fake.
HUSARIA_EVENTS_SIGNING_SECRET is optional but strongly recommended in production. Without it, outbound webhooks carry no signature; receivers can't verify the body wasn't tampered with on the way through your network.
Storage #
Two tables in the public schema (not husaria_data):
public.husaria_event_triggers #
| Column | Notes |
|---|---|
id | PK (UUID). Embedded into the per-table trigger name (husaria_evt_<uuid>) so Manager can locate + drop without a metadata scan. |
name | Unique |
trigger_type | Enum: database or scheduled |
schema_name, table_name, operations (JSONB) | Required for database kind. operations is an array drawn from {INSERT, UPDATE, DELETE, TRUNCATE}; no duplicates. |
cron_schedule, cron_timezone | Required for scheduled kind |
webhook_url | Where the dispatcher POSTs the delivery body |
headers (JSONB) | Per-trigger headers attached to the request |
retry_config (JSONB) | {max_retries, interval_seconds, timeout_seconds, backoff_strategy} — null uses dispatcher defaults |
payload_template (text) | Optional. Null = post the row delta as-is. |
enabled (boolean) | Disabled triggers land at status skipped, no retries |
created_at, updated_at | Audit |
A partial index on (enabled) WHERE enabled = TRUE keeps the enabled-rows query fast even when most triggers are off.
public.husaria_event_invocation_logs #
| Column | Notes |
|---|---|
id | PK; surfaced to receivers as X-Husaria-Event-Id |
trigger_id | FK to husaria_event_triggers.id |
payload (JSONB) | What was enqueued |
status | pending / in_flight / delivered / failed / skipped / exhausted (in_flight is the claimed state — set when the dispatcher claims a batch, before delivery resolves) |
attempts (int) | Retry counter |
next_retry_at (timestamptz) | When the row becomes due (for retries) |
request_headers, response_headers, response_body, http_status, latency_ms | Captured per attempt |
created_at, updated_at | Audit |
Two indexes: (trigger_id, created_at DESC) for the per-trigger history view, and (status, next_retry_at) WHERE status = 'pending' so the dispatcher's claim query is index-served.
Database-kind producer — NOTIFY pipeline #
When you create a database-kind trigger, the Manager:
- INSERTs the row in
husaria_event_triggers. - Installs a per-table AFTER row trigger named
husaria_evt_<uuid>pointing athusaria_event_notify(). - Rolls the row back on install failure so a failed DDL never leaves a phantom EventTrigger.
Schema and table names are validated against a tight regex (^[A-Za-z_][A-Za-z0-9_]*$, ≤63 bytes) before splicing into DDL. CREATE TRIGGER requires real identifiers — there's no parameter binding to lean on — so this is the trust boundary.
The husaria_event_notify() plpgsql function #
Installed once at startup via EnsureNotifyFunction() (idempotent CREATE OR REPLACE). On each fire it builds a JSONB envelope and pg_notify('husaria_events', payload):
{
"trigger_id": "…uuid…",
"trigger_name": "…",
"op": "INSERT|UPDATE|DELETE|TRUNCATE",
"schema": "public",
"table": "orders",
"old": { ... } , // null on INSERT
"new": { ... } , // null on DELETE
"ts": "2026-05-17T..."
}
8000-byte truncation guard. pg_notify caps the payload at 8000 bytes; wide rows would otherwise error the source transaction. When the envelope exceeds ~7500 bytes the function falls back to a slim envelope marked "truncated": true (no old/new body) — the consumer still gets the trigger metadata and can fetch the full row by (schema, table, pk) if it needs the body.
LISTEN consumer #
internal/events/consumer.go holds one pool connection on LISTEN husaria_events, decodes each payload, and calls Manager.Enqueue to write a pending row.
- Multi-pod safe. Postgres broadcasts every NOTIFY to every listening session, so every pod's consumer receives every notification. The dispatcher's existing advisory lock dedupes the actual delivery — duplicates never surface to the receiver.
- Reconnect. Session drops (brief Postgres restart, network blip) are caught and retried with a 5s backoff. The loop exits cleanly on context cancel.
- Backpressure. Each consumer processes payloads serially on one connection. Scaling horizontally adds more sessions and more headroom — Postgres' per-session NOTIFY queue is in-memory and bounded.
Scheduled-kind producer — cron scheduler #
internal/events/scheduler.go ticks once a second:
- Takes
pg_try_advisory_lockon a dedicated key so only one pod's scheduler runs at a time. - Calls
Manager.Listfor enabledscheduledtriggers. - Parses
cron_schedulewithrobfig/cron/v3(six-field optional — five or six fields accepted). - Honours
cron_timezonewhen computing the next fire time. - Enqueues an invocation when a trigger is due, recording the tick in an in-memory
firedmap to dedupe within the same pod session.
Cross-pod dedupe rides on the advisory lock — if a pod can't take it, the tick is a no-op and the leader handles it. A pod restart re-acquires the lock on the next tick; nothing is stranded.
Dispatcher loop #
Step 1. Single-flight across pods #
Every poll tick (default 1s, a fixed code default — PollInterval in DispatcherConfig, not env-configurable) the dispatcher takes a pg_try_advisory_lock with a fixed key. A multi-pod deployment sees N dispatchers running but only the lock-holder polls and claims; the rest skip until it releases. The lock auto-releases on session close, so a crashed pod doesn't strand the queue.
Step 2. Claim a batch #
The claim query is a CTE that wraps UPDATE … RETURNING with FOR UPDATE SKIP LOCKED:
WITH due AS (
SELECT id FROM public.husaria_event_invocation_logs
WHERE status = 'pending' AND next_retry_at <= now()
ORDER BY next_retry_at
LIMIT $batch_size
FOR UPDATE SKIP LOCKED
)
UPDATE public.husaria_event_invocation_logs
SET attempts = attempts + 1
FROM due
WHERE husaria_event_invocation_logs.id = due.id
RETURNING husaria_event_invocation_logs.*;
Even if two leaders raced past the advisory lock (defence in depth), SKIP LOCKED ensures they pick disjoint rows.
Step 3. Fan out #
A bounded worker pool (default 16, a fixed code default — MaxWorkers in DispatcherConfig, not env-configurable) processes the claimed batch in parallel. Each worker:
- Re-reads
webhook_url + enabled + retry_config + headersfromhusaria_event_triggers. Catches deletes / disables between enqueue and dispatch. - Runs the SSRF guard against the URL (see Security below).
- Builds the outbound HTTP request with the outbound headers listed below.
- POSTs the JSON body.
- Records the outcome.
Step 4. Block before lock release #
The tick blocks on worker drain before releasing the advisory lock. Deliveries never outlive the leadership window — if a pod is rotating out, it finishes the in-flight batch first.
Graceful shutdown ordering #
On SIGTERM, Husaria stops producers (consumer + scheduler) first, then the dispatcher. No new rows can land mid-shutdown, and the dispatcher gets to drain its in-flight batch cleanly.
Outcomes #
| Outcome | status | Retry behaviour |
|---|---|---|
| HTTP 2xx | delivered | Terminal. Body + latency captured. |
| HTTP non-2xx | pending → eventually exhausted | Exponential backoff, capped at 1h. |
| Transport error (DNS / connect / TLS / timeout) | Same | Same |
| Trigger disabled at dispatch | skipped | Terminal, no retries |
| Trigger deleted between enqueue and dispatch | failed | Terminal |
| Max retries reached | exhausted | Terminal |
Outbound headers #
| Header | Purpose |
|---|---|
X-Husaria-Event-Id | The invocation row's id. Receivers must idempotency-key on this — retries on transport errors / non-2xx mean receivers see duplicates when the upstream times out then succeeds. |
X-Husaria-Event-Trigger | The trigger name. Helps a single webhook endpoint multiplex multiple triggers. |
X-Husaria-Signature | sha256=<hex> — HMAC-SHA256 of the raw body as a hex digest, prefixed with sha256=, when HUSARIA_EVENTS_SIGNING_SECRET is set. Receivers should verify. |
Content-Type: application/json | Always |
Plus whatever the trigger's headers JSONB carries |
Security #
The dispatcher inherits the SSRF allowlist shared with actions and delegations:
SSRFGuardrejects loopback (127.0.0.0/8,::1), RFC1918 (10/8, 172.16/12, 192.168/16), link-local including169.254.169.254cloud-metadata, CGNAT (100.64.0.0/10), and non-http(s) schemes.- A single audit trail covers all three subsystems.
- The opt-out for private hosts is
HUSARIA_ALLOW_PRIVATE_WEBHOOKS=true(off by default). When unset, blocking stays on. The check runs at both metadata time (the fast-failssrfGuardlookup) and dial time (the guarded transport re-resolves DNS and rejects blocked IPs at connection time, closing the rebind window). Leave it off in production unless you specifically need to deliver to a trusted in-cluster endpoint.
DDL identifier hardening #
Per-table trigger installation needs real SQL identifiers (no parameter binding). Husaria validates schema_name and table_name against ^[A-Za-z_][A-Za-z0-9_]*$ (≤63 bytes — the Postgres identifier limit) before splicing them into CREATE TRIGGER. The console's table picker rejects anything else; direct-API callers get a clear 400.
Body signing #
HMAC-SHA256 of the request body with HUSARIA_EVENTS_SIGNING_SECRET as the key. The header value is sha256= followed by the hex digest over the raw body bytes (e.g. X-Husaria-Signature: sha256=9f86d0...). Receivers verify with the same secret. Omitted when the secret is unset — Husaria doesn't refuse-to-start on a missing secret, but production deployments should set one. Example receiver-side verification (Node):
import crypto from 'node:crypto'
function verify(req: Request, secret: string): boolean {
const header = req.headers.get('X-Husaria-Signature')
if (!header || !header.startsWith('sha256=')) return false
const received = header.slice('sha256='.length)
const computed = crypto
.createHmac('sha256', secret)
.update(req.body as Buffer)
.digest('hex')
const a = Buffer.from(received, 'hex')
const b = Buffer.from(computed, 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
Console #
The Events tab in /console is backed by /console/api/events/triggers. CRUD operations route through there, persisted by internal/events.Manager. Creating a database trigger installs the per-table Postgres trigger; deleting one drops it (DROP IF EXISTS, idempotent). Renames re-install on the new table; flipping database→scheduled drops the per-table trigger.
The tab returns 503 with body {"error": "events backend not wired"} when HUSARIA_ENABLE_EVENTS=false or pgPool was nil at startup — operators get a clear "feature not wired" message rather than a quiet "no triggers found".
Manager responses pass through EventTrigger.Sanitized() before serialisation, which redacts the headers JSONB — those often carry auth tokens (Authorization: Bearer …, X-API-Key, etc.) and shouldn't echo through the admin API surface.
A future console addition will surface the husaria_event_invocation_logs history per trigger and let operators fire a one-off test delivery via Manager.Enqueue.
What's deliberately out of scope #
- Not transactional with the row change. The model is "row change → NOTIFY (best-effort) → consumer enqueues → dispatcher delivers". A consumer outage means the NOTIFY is lost — it does not block the original write. Use a database-side outbox table + a
database-kind trigger on it if you need transactional guarantees. - Not at-most-once. Retries on non-2xx + transport errors mean receivers see duplicates. Idempotency-key on
X-Husaria-Event-Id. - Not a private-network webhook gateway by default. SSRF allowlist refuses loopback / RFC1918 / link-local / CGNAT / non-http(s). Set
HUSARIA_ALLOW_PRIVATE_WEBHOOKS=trueonly for trusted in-cluster delivery. - No dedicated
husaria_events_*Prometheus series yet. General-purposehusaria_*metrics still apply; per-event metrics may land in a future phase. - No invocation-log surfacing in the console yet. The history rows persist; the UI panel currently lists triggers only.