Configuration
Reference for every Hogsend environment variable, the data-plane key, the two-track migration workflow, and the engine version pin.
A scaffolded Hogsend app is configured entirely through environment variables. Both the HTTP API (src/index.ts) and the worker (src/worker.ts) read from the same .env at your app's root. The engine validates them at startup with @t3-oss/env-core — a missing or malformed required variable fails the boot with a clear error.
cp .env.example .envpnpm bootstrap does this for you and more: it copies .env.example, generates a real BETTER_AUTH_SECRET, auto-mints your HATCHET_CLIENT_TOKEN, runs the migrations, and mints an ingest-scoped HOGSEND_API_KEY data-plane key — see Installation. The .env.example shipped by the scaffold already points DATABASE_URL and REDIS_URL at the local Docker Compose ports, so for local development you mostly just fill in RESEND_API_KEY when you're ready to send real email.
Only three variables are required at boot: DATABASE_URL, BETTER_AUTH_SECRET, and HATCHET_CLIENT_TOKEN — and pnpm bootstrap supplies all three for you locally. RESEND_API_KEY is optional (the email provider is swappable, so a Postmark-only deploy boots with no Resend key). Everything else is optional with a sensible default.
Variable reference
The Process column notes which process actually uses each value. They share one .env; set everything in both deployed processes.
Core
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
NODE_ENV | No | development | API, Worker | Set to production when deployed. Disables /docs and /openapi.json. |
PORT | No | 3002 | API | HTTP port for the API server. Railway sets this automatically. |
LOG_LEVEL | No | info | API, Worker | Logging verbosity: error, warn, info, http, or debug. Use debug locally, info/warn in production. |
DATABASE_URL | Yes | — | API, Worker | PostgreSQL connection string (postgresql://user:pass@host:port/dbname). Local Docker default: postgresql://growthhog:growthhog@localhost:5434/growthhog. |
Authentication
Hogsend uses Better Auth for session management and admin authentication.
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
BETTER_AUTH_SECRET | Yes | — | API | Session encryption secret. Minimum 32 characters. Generate with openssl rand -hex 32 — pnpm bootstrap does this for you locally. |
BETTER_AUTH_URL | No | http://localhost:3002 | API | Public URL for auth callbacks. Set to your production API URL (e.g. https://api.yourdomain.com). |
BETTER_AUTH_TRUSTED_ORIGINS | No | — | API | Comma-separated list of extra origins allowed to call the auth endpoints, beyond BETTER_AUTH_URL. Needed when Studio is served from a different origin than the API — e.g. the hogsend studio CLI pointing at a remote instance. |
STUDIO_ADMIN_EMAIL | No | — | API | First-admin bootstrap. Public sign-up is disabled, so the first Studio admin is created from the server. When this is set and the user table is empty, the API mints this admin on boot (idempotent — only on a zero-user DB; race-safe across replicas). Unset → the CLI (hogsend studio admin create) is the only creation path. See Operating → Studio. |
STUDIO_ADMIN_PASSWORD | No | auto-generated | API | Password for the STUDIO_ADMIN_EMAIL bootstrap. Min 8 chars. When set, it is used verbatim and never logged. When omitted (but STUDIO_ADMIN_EMAIL is set), the engine auto-generates a strong password and prints it to the server log once ("save this, shown once") — rotate it immediately via the Studio forgot/reset flow. |
Email provider
Hogsend sends email through a swappable provider — Resend by default. The provider is a dumb EmailProvider; the engine owns render → preferences → tracking → email_sends, so swapping providers never costs you those features. Postmark ships as an installable opt-in, and any other provider works behind the EmailProvider contract.
The active provider resolves as email.defaultProvider (passed to createHogsendClient) → EMAIL_PROVIDER → "resend". Registering a provider never activates it; you flip the active id with EMAIL_PROVIDER. An unregistered active id throws at boot (it never silently falls back for a non-Resend id).
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
EMAIL_PROVIDER | No | resend | API, Worker | Active provider id (lowercase resend, postmark, …). Absent → resend. Set to postmark to make an installed Postmark provider the active wire. |
EMAIL_FROM | No | — | Worker | Neutral default-from address. The mailer's default-from resolves as EMAIL_FROM ?? RESEND_FROM_EMAIL. Use this (not RESEND_FROM_EMAIL) on a non-Resend deploy. Accepts a bare address or a display name: Doug at Hogsend <doug@hogsend.com>. |
EMAIL_DOMAIN | No | host part of EMAIL_FROM (falling back to RESEND_FROM_EMAIL) | API, Worker | The sending domain the domain-status service reports on, and the domain test mode's auto arming keys on. create-hogsend --domain writes it for you. Set it explicitly when you send from a subaddress domain that differs from the one registered at the provider. |
HOGSEND_TEST_MODE | No | auto | API, Worker | auto | true | false. Controls the test-mode send redirect. auto redirects only while the active provider supports domains and EMAIL_DOMAIN is set and unverified (fail-open: a provider outage never silently redirects prod mail; no EMAIL_DOMAIN ⇒ stays live). true always redirects; false never does. |
HOGSEND_TEST_EMAIL | No | falls back to STUDIO_ADMIN_EMAIL | API, Worker | The safe inbox every redirected send is delivered to while test mode is active. When neither resolves while active, the send is blocked (recorded as failed, never delivered to the real recipient). |
Resend (default)
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
RESEND_API_KEY | No | — | Worker | Resend API key for sending emails (starts with re_). From the Resend dashboard. Required only when Resend is the active provider — a Postmark-only deploy boots without it. The scaffold ships a placeholder; set a real key before sending. |
RESEND_FROM_EMAIL | No | noreply@hogsend.com | Worker | Default sender address when EMAIL_FROM is unset. Must be on a domain verified in your Resend account. Accepts a bare address or a display name: Doug at Hogsend <doug@hogsend.com>. |
RESEND_WEBHOOK_SECRET | No | — | API | Signing secret for verifying Resend webhook payloads. Enables delivery tracking (delivered, opened, clicked, bounced, complained). From Resend → Webhooks. Starts with whsec_. |
Without RESEND_WEBHOOK_SECRET, Hogsend still sends email but receives no delivery status updates — sends stay in sent and never progress to delivered, opened, etc. First-party open/click tracking is engine-owned and the single source of truth regardless of the provider; the webhook only adds delivery/bounce/complaint status.
Postmark (opt-in)
Postmark ships as @hogsend/plugin-postmark (install with pnpm add @hogsend/plugin-postmark@latest). Setting the server token only builds the preset — it never changes the active provider on its own; activate it with EMAIL_PROVIDER=postmark. Postmark has no HMAC: webhook authenticity is HTTP Basic credentials in the URL, and verifyWebhook fails closed when they're unset.
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
POSTMARK_SERVER_TOKEN | No | — | Worker | Postmark server API token. Setting it builds the Postmark provider preset (activate with EMAIL_PROVIDER=postmark). |
POSTMARK_MESSAGE_STREAM | No | — | Worker | Optional Postmark message stream to send on. |
POSTMARK_WEBHOOK_USER | No | — | API | HTTP Basic username for verifying inbound Postmark webhooks. |
POSTMARK_WEBHOOK_PASS | No | — | API | HTTP Basic password for verifying inbound Postmark webhooks. Webhook verification fails closed when this pair is unset. |
Provider delivery events arrive at POST /v1/webhooks/email/:providerId (e.g. /v1/webhooks/email/postmark) and are normalized to a provider-neutral EmailEvent. POST /v1/webhooks/resend is kept as a thin deprecated alias. See Email for the full provider-swap walkthrough and the defineEmailProvider contract.
Hatchet
Hatchet handles durable task execution — journey orchestration, email sends, and background jobs. These three values are the whole connection.
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
HATCHET_CLIENT_TOKEN | Yes | — | API, Worker | Bearer JWT for connecting to the Hatchet engine. The SDK hard-throws if empty. pnpm bootstrap auto-mints this against the local hatchet-lite container for you. For production, paste a Hatchet Cloud token. See Get a Hatchet token. |
HATCHET_CLIENT_HOST_PORT | No | localhost:7077 | API, Worker | Hatchet gRPC endpoint. In-compose: hatchet-lite:7077. On Railway, the internal address of the Hatchet-Lite service. |
HATCHET_CLIENT_TLS_STRATEGY | No | tls | API, Worker | TLS strategy for the gRPC connection: none | tls | mtls. Secure by default. The scaffold's local .env overrides to none for internal-network hatchet-lite; keep tls for Hatchet Cloud and the public internet. |
HATCHET_CLIENT_NAMESPACE | No | (empty) | API, Worker | Optional per-tenant isolation namespace on a shared engine. Default-empty; documented so it stays part of the contract. |
Locally, the bootstrap script mints the token automatically — you never bring your own for local dev. The bring-your-own-token contract applies to production / Hatchet Cloud. See Get a Hatchet token.
Data plane
The public data plane (/v1/contacts, /v1/events, /v1/emails, /v1/lists, /v1/campaigns) is how your own product code writes into Hogsend via @hogsend/client and the hogsend CLI. Every call needs a Bearer API key carrying the ingest scope.
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
API_PUBLIC_URL | No | http://localhost:3002 | API, Worker | Public-facing API URL. Used to generate unsubscribe and click/open tracking links in emails, and as the base URL for the data-plane client in src/lib/hogsend.ts. Must be reachable by end users (e.g. https://api.yourdomain.com). |
HOGSEND_API_KEY | No | — | API, Worker | Ingest-scoped data-plane API key for @hogsend/client / the CLI. Format hsk_<base64url(32 bytes)>. pnpm bootstrap auto-mints one locally (step 7) and writes it here. In production, mint one with POST /v1/admin/api-keys and scopes ["ingest"] (a full-admin key also satisfies the scope). |
ADMIN_API_KEY | No | — | API | Legacy full-admin Bearer key for /v1/admin/* endpoints and the hogsend CLI's admin commands. Resolves to scopes ["full-admin"]. Without it (and without a Studio session), admin endpoints are inaccessible. |
Don't ship a full-admin key (or ADMIN_API_KEY) to client-side code — it can mutate contacts and journeys. Use an ingest-scoped HOGSEND_API_KEY on the data plane. Generate a strong admin key: openssl rand -hex 32.
HOGSEND_API_KEY is read by @hogsend/client and the CLI — it is not part of the engine's validated boot env, so an empty value won't fail startup; data-plane calls just fail auth until it's set.
PostHog
Optional — connects to your PostHog instance for event ingestion and person property fetching.
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
POSTHOG_API_KEY | No | — | Worker | PostHog project API key for fetching person properties (Redis-cached) and event capture. Without it, container.analytics is a no-op and journeys can't read PostHog person properties. |
POSTHOG_HOST | No | — | Worker | PostHog instance URL. Cloud (US): https://us.i.posthog.com. Self-hosted: your instance URL. Required if POSTHOG_API_KEY is set. |
POSTHOG_WEBHOOK_SECRET | No | — | API | Shared secret for verifying incoming PostHog webhook payloads. Set this and send the same value in the destination's x-posthog-webhook-secret header. |
ENABLE_POSTHOG_DESTINATION | No | false | API | When true and POSTHOG_API_KEY is set, the engine idempotently auto-seeds one kind="posthog" outbound destination subscribed to the email funnel, so the full email lifecycle fans out to PostHog durably on the delivery spine. |
Without POSTHOG_WEBHOOK_SECRET, any request to /v1/webhooks/posthog with valid JSON is accepted unverified. Always set it in production.
Integration presets
Hogsend ships built-in inbound webhook sources for Clerk, Supabase, Stripe, and Segment, served at POST /v1/webhooks/{id}. Setting a preset's signing secret auto-enables that source — no code change. A signature-verified preset fails closed: if its secret is unset, the source is never mounted; a request with a bad signature is rejected with 401. See Integrations for the per-provider event mappings.
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
CLERK_WEBHOOK_SECRET | No | — | API | Svix signing secret for the Clerk preset (POST /v1/webhooks/clerk). Setting it mounts the source. From Clerk → Webhooks. Starts with whsec_. |
SUPABASE_WEBHOOK_SECRET | No | — | API | Signing secret for the Supabase preset (POST /v1/webhooks/supabase). Verified as Svix, with a plain-equality x-supabase-webhook-secret header fallback. Setting it mounts the source. |
STRIPE_WEBHOOK_SECRET | No | — | API | Stripe endpoint signing secret for the Stripe preset (POST /v1/webhooks/stripe). Verified against the stripe-signature header (5-minute tolerance). Setting it mounts the source. Starts with whsec_. |
SEGMENT_WEBHOOK_SECRET | No | — | API | Shared secret for the Segment preset (POST /v1/webhooks/segment). Verified as an HMAC-SHA256 hex digest of the raw body in the x-signature header. Setting it mounts the source. |
ENABLED_WEBHOOK_PRESETS | No | — | API | Preset enablement override. Absent or * auto-enables every preset whose secret is set. A comma-separated list of ids (e.g. stripe,clerk) mounts exactly those (each still requires its secret). none disables all presets. |
A preset never mounts without its secret. ENABLED_WEBHOOK_PRESETS only narrows the auto-enabled set — it can't turn on a preset whose signing secret is missing. A consumer-defined defineWebhookSource with the same id overrides the built-in preset.
Outbound webhooks
Hogsend emits a signed event stream to your subscriber URLs and delivers it with a durable, at-least-once retry task. Endpoints are managed through the admin API — these variables only tune the delivery engine. All are optional; the durable task carries the listed defaults. See Outbound webhooks for the event catalog and signing scheme.
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
OUTBOUND_WEBHOOK_REAPER_CRON | No | */1 * * * * | Worker | Cadence for the engine-owned delivery reaper — re-drives due retries and recovers orphaned sending rows. |
OUTBOUND_WEBHOOK_MAX_ATTEMPTS | No | 8 | Worker | Maximum delivery attempts per (event × endpoint) before the delivery is marked failed and dead-lettered. |
OUTBOUND_WEBHOOK_TIMEOUT_MS | No | 15000 | Worker | Per-request HTTP timeout (ms) for a delivery POST. A timeout is retryable. |
OUTBOUND_WEBHOOK_BASE_DELAY_MS | No | 5000 | Worker | Base delay (ms) for the exponential-backoff-with-jitter retry schedule. |
OUTBOUND_WEBHOOK_MAX_DELAY_MS | No | 21600000 | Worker | Ceiling (ms) on the backoff delay between retries. Default is 6 hours. |
OUTBOUND_WEBHOOK_STUCK_AFTER_MS | No | 300000 | Worker | Age (ms) after which a sending row is considered orphaned and recovered by the reaper. Default is 5 minutes. |
Outbound destinations
A destination is a delivery-time transform keyed by webhook_endpoints.kind that fans the outbound event stream out to a product or data tool (PostHog, Segment, Slack, a CRM, a warehouse), reusing the same durable delivery as outbound webhooks. Credentials are not env vars — they live per-endpoint in webhook_endpoints.config, managed via the admin API / hs.webhooks SDK.
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
ENABLED_DESTINATION_PRESETS | No | — | API | Destination preset enablement override. Absent or * auto-enables every preset (the webhook + posthog presets are always registered regardless). A comma-separated list of ids (webhook,posthog,segment,slack) registers exactly those. none falls back to the always-on webhook + posthog only. |
webhook (the default signed POST) and posthog are the always-on set — they can never be turned off by misconfiguration. Use ENABLED_DESTINATION_PRESETS to also register segment and/or slack.
Features
These mirror the *-or-CSV contract: * registers everything, or pass a comma-separated list of ids. Anything not listed is not registered. Each can also be passed explicitly to createHogsendClient({ ... }) / createWorker({ ... }).
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
ENABLED_JOURNEYS | No | * | API, Worker | Which journeys are registered. * enables all journeys in your journeys array; or a comma-separated list of journey IDs (e.g. welcome,test-onboarding). Journeys not listed cannot be triggered. Pass to createHogsendClient({ enabledJourneys }) / createWorker({ enabledJourneys }). |
ENABLED_BUCKETS | No | * | API, Worker | Which buckets are registered. * enables all buckets in your buckets array; or a comma-separated list of bucket IDs. Buckets not listed are not registered. Evaluated at worker boot — a toggle requires a worker restart. |
ENABLED_LISTS | No | * | API, Worker | Which lists are registered. * enables all lists in your lists array; or a comma-separated list of list IDs. Lists not listed are not registered. |
BUCKET_RECONCILE_CRON | No | */5 * * * * | Worker | Cron expression for the engine-owned bucket reconciliation task — membership re-evaluation, time-based leaves, and dwell detection over the existing population. |
Infrastructure
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
REDIS_URL | No | redis://localhost:6379 | Worker | Redis connection string for caching PostHog person properties. Local Docker default: redis://localhost:6380. |
The local docker-compose.yml host ports are parameterized and default to 5434 (Postgres), 6380 (Redis), and 8888/7077 (Hatchet dashboard/gRPC). pnpm bootstrap auto-remaps any that are already in use — writing the chosen POSTGRES_PORT / REDIS_PORT / HATCHET_DASHBOARD_PORT / HATCHET_GRPC_PORT into a root .env (for Compose) and syncing the matching DATABASE_URL / REDIS_URL / HATCHET_CLIENT_HOST_PORT — so several stacks can run side by side. You rarely set these by hand.
Schema & boot
| Variable | Required | Default | Process | Description |
|---|---|---|---|---|
CLIENT_MIGRATIONS_FOLDER | No | — | API, Worker | Optional path to a client-migration track folder, distinct from the standard migrations/ directory. Read by @hogsend/db's client migrator. |
SKIP_SCHEMA_CHECK | No | false | API | Set to true to bypass the engine-track boot guard (src/index.ts). Dev/emergency only — the API normally exits if the engine schema is behind the build. |
Generating secrets
# BETTER_AUTH_SECRET (>= 32 chars) and ADMIN_API_KEY
openssl rand -hex 32
# POSTHOG_WEBHOOK_SECRET
openssl rand -hex 16The ingest-scoped HOGSEND_API_KEY is minted by pnpm bootstrap locally; in production mint one with POST /v1/admin/api-keys and scopes ["ingest"] (see Authentication) — it is shown once.
Migrations — two tracks
A scaffolded app has two independent migration streams against the same database. A single pnpm db:migrate runs both, engine first:
| Track | Where the files live | Ledger | Gates boot? |
|---|---|---|---|
| Engine | bundled in @hogsend/db | drizzle.__drizzle_migrations | Yes — fatal if behind |
| Client | your repo's migrations/ | drizzle.__client_migrations | No — surfaced non-fatally in health |
- Your own tables live in
src/schema/index.ts. pnpm db:generategenerates a client-track migration from yoursrc/schemachanges.pnpm db:migraterunsscripts/migrate.ts:migrateEngine()thenmigrateClient()(it skips an empty client track). Both tracks serialize behind the same Postgres advisory lock.- The boot guard in
src/index.tsgates on the engine track only. A pending client migration surfaces asstatus: "migration_pending"on/v1/healthfor you to resolve — it never takes the API down.
db:push ledger gotcha. pnpm db:push writes schema objects directly without recording a ledger row, leaving the ledger behind the real schema. The boot guard then sees pending migrations and refuses to start even though the tables exist. Use db:generate + db:migrate from day one for anything you intend to deploy.
The engine version pin
The scaffold pins every @hogsend/* package to a single version line in package.json (substituted from ENGINE_VERSION at scaffold time). Keep them in lockstep — the API, worker, and engine migrations all ship together. Upgrade within a major with:
pnpm up "@hogsend/*" # bump engine + db + core + email + plugins together
pnpm db:migrate # apply any new engine migrationsThen verify /v1/health shows both tracks inSync: true. This is the upgrade path — never a git merge. See Upgrading & Customizing.
Validating configuration
curl http://localhost:3002/v1/healthIf the API fails to start, check its logs for @t3-oss/env-core validation errors indicating which variable is missing or malformed.
Environment-specific recommendations
Local development
NODE_ENV=development
PORT=3002
LOG_LEVEL=debug
DATABASE_URL=postgresql://growthhog:growthhog@localhost:5434/growthhog
REDIS_URL=redis://localhost:6380
HATCHET_CLIENT_HOST_PORT=localhost:7077
HATCHET_CLIENT_TLS_STRATEGY=nonepnpm bootstrap writes a working version of all of these (plus BETTER_AUTH_SECRET, HATCHET_CLIENT_TOKEN, and HOGSEND_API_KEY) into .env for you.
Production
NODE_ENV=production
LOG_LEVEL=info
API_PUBLIC_URL=https://api.yourdomain.com
BETTER_AUTH_URL=https://api.yourdomain.com
RESEND_FROM_EMAIL=noreply@yourdomain.com # or EMAIL_FROM; "Name <addr>" works too
STUDIO_ADMIN_EMAIL=you@yourdomain.com # bootstraps the first Studio admin on a fresh DB
ENABLED_JOURNEYS=*On Railway, DATABASE_URL, REDIS_URL, and PORT are auto-wired by linking the Postgres and Redis services. See Deployment.
Redis is required in production. Better Auth's secondaryStorage is wired to the shared engine Redis (sessions, cross-replica rate limits, and password-reset tokens) — gated on a real REDIS_URL in the environment. Without it the rate limiters are per-instance rather than global, and reset tokens don't survive a restart. The PostHog property cache and worker heartbeat share the same pool.
Next steps
PostHog Webhook Setup
Forward the right PostHog events to your running app via PostHog's Destinations pipeline — into POST /v1/webhooks/posthog, where they trigger journeys. With screenshots.
How It Works
The mental model behind Hogsend — events in, journeys and emails out, engagement back — and how the scaffold maps that model to the files you edit, so every recipe clicks into place.