| CORS_ORIGINS | http://localhost:3000 | Comma-separated list of allowed origins |
| RATE_LIMIT_MAX / WINDOW_MS | 100 / 60000 | Requests per window per IP, backed by Redis |
| WEBHOOK_RETRY_MAX_ATTEMPTS / BACKOFF_MS | 3 / 5000 | Read by the delivery path. A failed attempt is retried with exponential backoff (BACKOFF_MS × 2^(attempt−1)) until the attempt cap is spent, then the delivery is recorded as dead_letter. The cap is checked when the dispatcher claims a row, so lowering it also retires rows already over the new limit. What feeds the path is a shorter list than the mechanics suggest — one event today, connection.status.changed, from the connections PATCH route only; see Webhooks API |
| WEBHOOK_DELIVERY_TIMEOUT_MS | 10000 | Wall-clock ceiling on one attempt, enforced with an AbortSignal at the fetch; a receiver that has not returned response headers by then has that attempt aborted and recorded as failed. Not a budget for the whole delivery: on the defaults a delivery can take 3 × 10 s plus the backoffs (45 s), which POST /webhooks/:id/test awaits |
| WEBHOOK_DISPATCH_INTERVAL_MS | 5000 | How often the in-process dispatcher claims due rows from the webhook_deliveries outbox and makes one attempt per row. 0 disables the loop entirely — no deliveries and no retention purge; only the synchronous test endpoint still sends. Unset is not 0: unset means 5000. A negative value is refused at boot. GET /readyz reports the loop's last successful pass without gating readiness on it |
| WEBHOOK_DISPATCH_BATCH_SIZE / CONCURRENCY | 8 / 4 | Rows one pass claims, and how many of them are in flight at once. At most one row per webhook configuration per pass, so a stuck receiver cannot own the pass. A pass that claims a full batch with rows still due logs at warn — that line is the signal to raise these |
| WEBHOOK_CLAIM_LEASE_MS | 30000 | How long a claimed row is held out of every other pass. Boot refuses a lease that does not outlast the worst-case pass, TIMEOUT_MS × ⌈BATCH_SIZE ÷ CONCURRENCY⌉ (20 s on the defaults), and prints the arithmetic. It is also how long a row stays stuck after an unclean kill before any pass retries it, so a generous lease is a slow recovery |
| WEBHOOK_DELIVERY_RETENTION_DAYS | 90 | Enforced. Deletes delivered and dead_letter rows older than the window; pending rows are never purged. Runs on the dispatch loop's cadence, at most once an hour. 0 disables the purge; a negative or non-finite value is refused at boot. A purged row cannot be redelivered, so this is also the horizon on POST /webhooks/:id/deliveries/:deliveryId/redeliver |
| OBSERVABILITY_EVENT_RETENTION_DAYS | 90 | Enforced. Sets both Keycloak realm eventsExpiration and the Postgres purge of observability_events (same window via observabilityEventRetentionSeconds()). Purge runs once per login-events poll cycle. Does not prune AuditLog rows. |
| OBSERVABILITY_LOGIN_EVENTS_POLL_INTERVAL_MS | 60000 | How often the in-process poller reads Keycloak's own login events into observability_events. Each account's realm is polled separately, with its own cursor and its own failure isolation. 0 disables the in-process timer — set that when you run src/scripts/poll-login-events.ts from an external scheduler instead. Do not run both: there is no lock and no unique constraint, so events are double-counted. |
| REALM_CO_RESIDENCY_SCAN_INTERVAL_MS | 300000 | How often the server re-asks whether any ordinary Keycloak realm holds more than one account. The check also runs once at boot; 0 leaves only the boot run. It recurs because Account.keycloakRealm is written at runtime, so a co-residency created after boot would otherwise stay invisible until the next deploy. An audit row is written per detection, not per pass, so a standing finding does not flood the log. |