welldun. offhook

Self-hosting

Configuration

Offhook is configured with environment variables, checked once at startup so a bad value fails immediately rather than on the first call. Set them on both the API and the worker. Provider keys don't go here; they live encrypted in the database as connections.

Core

Environment

FieldTypeDescription
ENVoptional dev | prod dev (the default) or prod. prod turns on the startup checks listed at the end of this page.
DATABASE_URLrequired string The Postgres connection string, e.g. postgresql+asyncpg://voice:…@host:5432/voice. It must use the +asyncpg driver.
PUBLIC_BASE_URLoptional url The public HTTPS address carriers call back on, for the answer webhook and the audio stream. Required in prod.
LOG_LEVELoptional string Defaults to DEBUG. Use INFO or WARNING in production.
RECORDINGS_DIRoptional string Where call recordings are saved on local disk (default recordings). On hosts that wipe the disk on redeploy, mount a volume here.

Encryption and keys

Provider keys are protected with envelope encryption. Each tenant has its own data key, which encrypts that tenant's secrets, and a master key encrypts the data keys. The master key never goes in the database.

Keys

FieldTypeDescription
MASTER_ENCRYPTION_KEYoptional base64 The master key: base64 of exactly 32 random bytes. Required in prod unless you use AWS KMS. If you lose it, every stored provider key is unrecoverable.
MASTER_KEY_IDoptional string A name for the current master key (default v1), recorded with each data key so older keys can still be used during rotation.
KEY_PROVIDERoptional env | kms env (the default) uses MASTER_ENCRYPTION_KEY. kms encrypts the data keys with AWS KMS instead.
KMS_KEY_IDoptional string The KMS key ID or ARN. Required when KEY_PROVIDER=kms.

To generate a master key:

Terminal
python -c "import os,base64; print(base64.b64encode(os.urandom(32)).decode())"

Row-level security (optional)

Every query is already limited to one tenant in the application code. Postgres row-level security adds a second layer, so a missed filter still can't return another tenant's data. It's off by default because it needs two database roles.

RLS

FieldTypeDescription
ENABLE_RLSoptional bool Turns on row-level security per transaction. Apply app/db/rls.sql first.

The API connects as a role that row-level security applies to. The worker and the carrier endpoints connect as a role that bypasses it, because they look calls up by job or token before they know the tenant. app/db/rls.sql explains the full setup.

Dashboard sign-in and CORS

Only needed if you host the optional dashboard.

Dashboard

FieldTypeDescription
CLERK_ISSUERoptional url The issuer of your Clerk app's session tokens, e.g. https://your-app.clerk.accounts.dev. The API checks tokens against its published keys.
CLERK_JWKS_URLoptional url Overrides where those keys are fetched from. By default it's derived from the issuer.
CORS_ORIGINSoptional comma-separated Browser origins allowed to call the API. Set it to your dashboard's address in production; the default only allows the local development ports.

Development settings

These are for local development. In prod, the first two must be off or the app won't start.

Development

FieldTypeDescription
ENABLE_PREBUILT_UIoptional bool Serves the browser playground, which starts calls without authentication. On by default; must be false in prod.
WEBHOOK_ALLOW_PRIVATEoptional bool Allows webhooks to private and loopback addresses, for testing a local receiver. It turns off a security check, so it must be false in prod.
DEV_AGENT_IDoptional uuid The agent the playground talks to.

Production checklist

With ENV=prod, the app refuses to start unless the master key (or KMS) and PUBLIC_BASE_URL are set and both development settings are off:

.env (prod)
ENV=prod
DATABASE_URL=postgresql+asyncpg://voice_api:…@db:5432/voice
PUBLIC_BASE_URL=https://api.your-domain.com    # required in prod
MASTER_ENCRYPTION_KEY=…                         # required in prod (or KEY_PROVIDER=kms)
ENABLE_PREBUILT_UI=false                        # must be false in prod
WEBHOOK_ALLOW_PRIVATE=false                     # must be false in prod
LOG_LEVEL=INFO
CORS_ORIGINS=https://console.your-domain.com

Before you go live, also check that:

  • The master key is in a secrets manager or KMS, not on the server and not in git.
  • HTTPS is in front of the API, and PUBLIC_BASE_URL matches its address.
  • Migrations are applied. /readyz reports "migrations": "ok".
  • Postgres is backed up, and you have tested a restore. The database holds every encrypted key and your whole call history.
  • CORS_ORIGINS lists only your real dashboard address.
  • At least one worker is running, with time to shut down gracefully. Without a worker, calls stay queued.
  • Optionally, ENABLE_RLS=true with the two-role setup.

Day-to-day operations and troubleshooting are covered in RUNNING.md in the repository.