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:

ComponentRole
NOTIFY consumerHolds one Postgres connection on LISTEN husaria_events. Decodes each NOTIFY payload and writes a pending invocation row.
Cron schedulerTicks once a second, scans enabled scheduled triggers, enqueues invocations when a cron expression is due.
DispatcherPolls 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:

KindRequired fieldsNotes
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.
scheduledcron_schedule5 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 #

ColumnNotes
idPK (UUID). Embedded into the per-table trigger name (husaria_evt_<uuid>) so Manager can locate + drop without a metadata scan.
nameUnique
trigger_typeEnum: 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_timezoneRequired for scheduled kind
webhook_urlWhere 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_atAudit

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 #

ColumnNotes
idPK; surfaced to receivers as X-Husaria-Event-Id
trigger_idFK to husaria_event_triggers.id
payload (JSONB)What was enqueued
statuspending / 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_msCaptured per attempt
created_at, updated_atAudit

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:

  1. INSERTs the row in husaria_event_triggers.
  2. Installs a per-table AFTER row trigger named husaria_evt_<uuid> pointing at husaria_event_notify().
  3. 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:

  1. Takes pg_try_advisory_lock on a dedicated key so only one pod's scheduler runs at a time.
  2. Calls Manager.List for enabled scheduled triggers.
  3. Parses cron_schedule with robfig/cron/v3 (six-field optional — five or six fields accepted).
  4. Honours cron_timezone when computing the next fire time.
  5. Enqueues an invocation when a trigger is due, recording the tick in an in-memory fired map 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:

  1. Re-reads webhook_url + enabled + retry_config + headers from husaria_event_triggers. Catches deletes / disables between enqueue and dispatch.
  2. Runs the SSRF guard against the URL (see Security below).
  3. Builds the outbound HTTP request with the outbound headers listed below.
  4. POSTs the JSON body.
  5. 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 #

OutcomestatusRetry behaviour
HTTP 2xxdeliveredTerminal. Body + latency captured.
HTTP non-2xxpending → eventually exhaustedExponential backoff, capped at 1h.
Transport error (DNS / connect / TLS / timeout)SameSame
Trigger disabled at dispatchskippedTerminal, no retries
Trigger deleted between enqueue and dispatchfailedTerminal
Max retries reachedexhaustedTerminal

Outbound headers #

HeaderPurpose
X-Husaria-Event-IdThe 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-TriggerThe trigger name. Helps a single webhook endpoint multiplex multiple triggers.
X-Husaria-Signaturesha256=<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/jsonAlways
Plus whatever the trigger's headers JSONB carries

Security #

The dispatcher inherits the SSRF allowlist shared with actions and delegations:

  • SSRFGuard rejects loopback (127.0.0.0/8, ::1), RFC1918 (10/8, 172.16/12, 192.168/16), link-local including 169.254.169.254 cloud-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-fail ssrfGuard lookup) 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 databasescheduled 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=true only for trusted in-cluster delivery.
  • No dedicated husaria_events_* Prometheus series yet. General-purpose husaria_* 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.