Skip to content

Configuration

Whatomate uses a TOML configuration file for all settings. By default, it looks for config.toml in the current directory; pass -config /path/to/config.toml to override.

A complete commented example lives in config.example.toml at the repository root — copy it to config.toml and edit. The reference below documents every section.

[app]
name = "Whatomate"
environment = "development" # development, staging, production
debug = true
encryption_key = "" # AES-256 key (32+ chars) for encrypting secrets at rest. Required in production.
[server]
host = "0.0.0.0"
port = 8080
read_timeout = 30
write_timeout = 30
base_path = "" # Set to "/subpath" if behind nginx proxy_pass at a sub-path
allowed_origins = "" # Comma-separated CORS origins. Empty = allow all (dev only).
[database]
host = "localhost" # Use "db" inside Docker compose
port = 5432
user = "whatomate"
password = "your-password"
name = "whatomate"
ssl_mode = "disable" # disable, require, verify-ca, verify-full
max_open_conns = 25
max_idle_conns = 5
conn_max_lifetime = 300 # seconds
[redis]
host = "localhost" # Use "redis" inside Docker compose
port = 6379
username = "" # Redis 6+ ACL username. Empty = default user.
password = ""
db = 0
tls = false # true for managed Redis (Upstash, Redis Cloud)
[jwt]
secret = "change-me-in-production" # 32+ chars in production
access_expiry_mins = 15
refresh_expiry_days = 1
[storage]
type = "local" # local | s3
local_path = "./uploads"
s3_bucket = ""
s3_region = ""
s3_key = ""
s3_secret = ""
[whatsapp]
webhook_verify_token = "random-string-change-this"
api_version = "v24.0"
base_url = "https://graph.facebook.com"
app_id = "" # Meta App ID for Embedded Signup
app_secret = "" # Meta App Secret
config_id = "" # WhatsApp Config ID for frontend login
[cookie]
domain = "" # e.g. ".example.com" to share across subdomains. Empty = current host only.
secure = false # Sets the Secure flag. Auto-forced to true when environment=production.
[rate_limit]
enabled = false # Master switch
login_max_attempts = 10
register_max_attempts = 10
refresh_max_attempts = 30
sso_max_attempts = 10
window_seconds = 60
trust_proxy = false # true if behind a reverse proxy (uses X-Forwarded-For / X-Real-IP)
api_max_requests = 200 # Cap across all /api routes, keyed by user (IP when unauthenticated)
api_window_seconds = 60
[tts]
# piper_binary = "/usr/local/bin/piper"
# piper_model = "/opt/piper/models/en_US-lessac-medium.onnx"
# opusenc_binary = "opusenc"
[calling]
max_call_duration = 300 # seconds
audio_dir = "./audio"
# hold_music_file = "hold_music.opus"
# ringback_file = "ringback.opus"
transfer_timeout_secs = 120
# per_agent_timeout_secs = 20 # Per-agent ring time before trying the next agent
recording_enabled = true
udp_port_min = 10000
udp_port_max = 10100
# public_ip = "1.2.3.4" # Required on AWS / cloud where the host doesn't see its public IP
# relay_only = true # Force all media through TURN
# Configure STUN, TURN, or both. Repeat the [[calling.ice_servers]] block for each.
# [[calling.ice_servers]]
# urls = ["stun:stun.l.google.com:19302"]
# [[calling.ice_servers]]
# urls = ["turn:your-turn.example.com:3478"]
# username = "user"
# credential = "password"
# TURN with a shared secret (coturn "use-auth-secret" / TURN REST mode). When
# `secret` is set, short-lived credentials are generated per request instead of
# using a static username/credential.
# [[calling.ice_servers]]
# urls = ["turn:your-turn.example.com:3478"]
# secret = "your-coturn-static-auth-secret"
# credential_ttl = 86400 # Generated-credential lifetime in seconds (default 86400 = 24h)
[default_admin]
email = "admin@admin.com"
password = "admin"
full_name = "Admin"
FieldRequiredNotes
namenoDisplay name. Defaults to "Whatomate".
environmentyesdevelopment | staging | production. Sets debug behavior, forces cookie.secure=true in production.
debugnoVerbose logging.
encryption_keyproductionAES-256 key used to encrypt access tokens, SSO client secrets, and other secrets at rest in the DB. Must be 32+ chars. Lose it and you lose stored credentials.
FieldNotes
host, portDefault 0.0.0.0:8080.
read_timeout, write_timeoutSeconds.
base_pathLeave empty unless serving at a sub-path (https://app.example.com/whatomate) behind a proxy.
allowed_originsComma-separated list, e.g. "https://app.example.com,https://admin.example.com". Empty allows all origins — only safe in development.

PostgreSQL only. The max_open_conns / max_idle_conns / conn_max_lifetime knobs map directly to database/sql pool settings — defaults are tuned for a single API process; raise max_open_conns if you run many workers against the same DB.

ssl_mode accepts standard libpq values: disable, require, verify-ca, verify-full. Use require or stricter in production.

Required. Used for:

  • Job queue (Redis Streams) for campaign sends
  • Pub/Sub for campaign-stats fan-out between workers and the API process
  • Rate-limit counters (fixed window)
  • OAuth state during the SSO login round-trip (5-minute TTL)
  • Caches: chatbot settings, flows, keyword rules, AI contexts, WhatsApp accounts, outbound webhooks, user/role permissions, and tags

CSRF protection does not use Redis — it is a stateless double-submit cookie check (whm_csrf cookie vs. X-CSRF-Token header).

Set tls = true and provide username + password for managed services like Upstash or AWS ElastiCache Serverless.

FieldNotes
secretHMAC signing key. Must be 32+ chars in production. Rotating invalidates all sessions.
access_expiry_minsDefault 15.
refresh_expiry_daysDefault 1.

JWTs are stored in httpOnly cookies — the frontend never sees them.

Controls media storage (template samples, uploaded files, call recordings).

  • type = "local" — files under local_path (default ./uploads). Suitable for single-host deployments. The directory must be writable by the process and persisted (Docker volume).
  • type = "s3" — fill in s3_bucket, s3_region, s3_key, s3_secret.

App-level keys used for Embedded Signup and Meta Cloud API calls. These are global (not per-org) and do live in config.toml. Per-organization account access tokens are stored separately via Settings → Accounts, not here.

FieldNotes
webhook_verify_tokenToken Meta echoes back when verifying the /api/webhook subscription. Set it to a random string and enter the same value in the Meta App webhook config.
api_versionMeta Graph API version, e.g. v24.0. Defaults to v18.0 if unset — set it explicitly.
base_urlGraph API base URL. Defaults to https://graph.facebook.com.
app_idMeta App ID, used by the frontend Embedded Signup flow.
app_secretMeta App Secret.
config_idWhatsApp Embedded Signup configuration ID used by the frontend login.
FieldNotes
domainSet to .example.com to share auth cookies across subdomains. Leave empty for single-host.
secureThe Secure cookie flag (HTTPS-only). Automatically forced to true when app.environment = "production", regardless of this setting.

Off by default. Turn on with enabled = true for any internet-facing deployment.

FieldNotes
login_max_attemptsPer IP, per window_seconds.
register_max_attemptsPer IP, per window. Throttles sign-up attempts.
refresh_max_attemptsPer IP, per window. Higher because legitimate clients refresh often.
sso_max_attemptsPer IP, per window for /api/auth/sso/*.
api_max_requestsCap across all /api routes, keyed by authenticated user ID (falling back to IP for unauthenticated requests). Defaults to 200 when unset or 0.
api_window_secondsWindow for api_max_requests. Defaults to 60 when unset or 0.
trust_proxySet to true only if you run behind a trusted reverse proxy (nginx, Cloudflare, ALB) — otherwise clients can spoof their IP via X-Forwarded-For.

Implemented as Redis fixed-window counters. The limiter fails open: if Redis is unreachable the error is logged and the request is allowed through.

Optional, only used when an IVR step needs to synthesize speech (welcome prompt, menu read-out). All three keys are paths to external binaries / models:

  • piper_binarypiper standalone TTS executable.
  • piper_model.onnx voice model from piper-voices.
  • opusenc_binaryopusenc from opus-tools (apt install opus-tools / dnf install opus-tools). Defaults to looking up opusenc in PATH.

If unset, IVR flows still work but cannot synthesize new audio prompts on the fly — only pre-rendered Opus files in calling.audio_dir will play.

Required only if you use WhatsApp voice calling / IVR.

FieldNotes
max_call_durationHard cap in seconds; calls beyond this are forcibly terminated. Default 300.
audio_dirWhere prompt audio files (hold_music.opus, ringback.opus, IVR prompts) live. Default ./audio.
hold_music_file, ringback_fileFilenames inside audio_dir. hold_music_file defaults to hold_music.opus; ringback_file has no default (hold music is used instead). Both can be overridden per organization under Settings → Calling.
transfer_timeout_secsHow long to wait for an agent to accept a transferred call before falling through. Default 120.
per_agent_timeout_secsPer-agent ring time when routing to a team; if an agent doesn't answer within this window, the next agent is tried. A team's own per_agent_timeout_secs wins when set; otherwise this value; otherwise 15.
recording_enabledRecords both directions per call and uploads the OGG/Opus file to S3. Requires a configured [storage] S3 bucket — with no S3 client, recording is skipped entirely.
udp_port_min, udp_port_maxRTP/SRTP UDP port range. Whatever range you set must be open in your firewall / security group. Default 10000–10100 gives 100 concurrent streams.
public_ipRequired on AWS / GCP / Azure, where the host's network interface does not have its public IP attached. Pion uses this for ICE candidate generation.
relay_onlyIf true, advertises only TURN candidates (no host/srflx). Useful when the network is too restricted for direct UDP.
[[calling.ice_servers]]One block per STUN/TURN server. STUN-only works in permissive networks; configure TURN for guaranteed connectivity behind strict NAT or corporate firewalls. Each block takes urls plus either a static username/credential, or a secret (+ optional credential_ttl) for coturn use-auth-secret / TURN REST mode — see below.

For a TURN server running coturn's use-auth-secret (TURN REST API) mode, set secret to coturn's static-auth-secret instead of a static username/credential. Whatomate then generates short-lived credentials per request (username = expiry timestamp, credential = base64 HMAC-SHA1 of the username keyed by the secret). credential_ttl (seconds) sets their lifetime and defaults to 86400 (24h). If both secret and a static username/credential are set, secret takes priority.

The credentials seeded the first time whatomate server -migrate runs against an empty user table. Subsequent runs are no-ops. Always change these on first login.

Any key in config.toml can be overridden by an environment variable. The mapping is:

WHATOMATE_<SECTION>__<KEY>

The separator between the section and the key is a double underscore (__); single underscores are preserved as part of a name. This matters because both section names and field names contain single underscores (default_admin, rate_limit, ssl_mode, s3_bucket, app_id), so collapsing every _ into a level separator would mangle them (e.g. rate_limit.enabled would become rate.limit.enabled). The __ disambiguates the section boundary from underscores inside a name. Examples:

VariableTOML field
WHATOMATE_APP__ENVIRONMENTapp.environment
WHATOMATE_APP__ENCRYPTION_KEYapp.encryption_key
WHATOMATE_DATABASE__HOSTdatabase.host
WHATOMATE_DATABASE__SSL_MODEdatabase.ssl_mode
WHATOMATE_REDIS__HOSTredis.host
WHATOMATE_REDIS__TLSredis.tls
WHATOMATE_JWT__SECRETjwt.secret
WHATOMATE_STORAGE__S3_BUCKETstorage.s3_bucket
WHATOMATE_RATE_LIMIT__ENABLEDrate_limit.enabled
WHATOMATE_CALLING__PUBLIC_IPcalling.public_ip
WHATOMATE_DEFAULT_ADMIN__PASSWORDdefault_admin.password

Env vars take precedence over the config file, so the typical pattern is to commit a non-secret config.toml and inject secrets (*_PASSWORD, *_KEY, *_SECRET, JWT_SECRET) via the deployment environment.

Terminal window
# Create database
createdb whatomate
# Or using psql
psql -c "CREATE DATABASE whatomate;"
Terminal window
./whatomate server -migrate

Schema is generated from GORM struct tags in internal/models/; there are no SQL migration files. Re-running with -migrate is safe — it adds new tables/columns and is a no-op for existing ones.

App-level Embedded Signup keys (app_id, app_secret, config_id, webhook_verify_token) live in the [whatsapp] section of config.toml. Each organization's WhatsApp account credentials are not in config.toml — configure them per organization in the UI:

  1. Navigate to Settings → Accounts

  2. Click Add Account

  3. Enter:

    FieldRequiredNotes
    NameyesLabel for the account inside Whatomate.
    Phone Number ID (phone_id)yesFrom Meta Business Suite.
    Business Account ID (business_id)yesWABA ID from Meta Business Suite.
    Access TokenyesGenerated in Meta for Developers. Encrypted at rest.
    App IDnoMeta App ID for this account.
    App SecretnoUsed to verify X-Hub-Signature-256 on inbound webhooks. Encrypted at rest. Verification only runs when both the header is present and the matching account has an App Secret — set it, or inbound webhooks are accepted unverified.
    Webhook Verify TokennoAn additional accepted verify token. GET /api/webhook checks the global whatsapp.webhook_verify_token first, then any account's token.
    API VersionnoPer-account override of whatsapp.api_version.

    Accounts also carry the toggles is_default_incoming, is_default_outgoing, auto_read_receipt and business_calling_enabled.

Terminal window
make build # Backend only (no frontend embedded)

For development, run the backend and frontend separately:

  • Backend: make run-migrate (or ./whatomate server -migrate)
  • Frontend: cd frontend && npm run dev

The Vite dev server proxies /api to :8080.

Terminal window
make build-prod # Single binary with embedded frontend
CommandFrontendUse Case
make buildnot embeddedDevelopment
make build-prodembeddedProduction
Terminal window
./whatomate <command> [options]
CommandDescription
serverStart the API server (with optional embedded workers)
workerStart background workers only (no API server)
versionShow version information
helpShow help message
Terminal window
./whatomate server [options]
-config string Path to config file (default "config.toml")
-migrate Run database migrations on startup
-workers int Number of embedded workers, 0 to disable (default 1)
Terminal window
./whatomate worker [options]
-config string Path to config file (default "config.toml")
-workers int Number of workers to run (default 1)

API and workers in a single process — simplest, fine up to moderate campaign volume:

Terminal window
./whatomate server

Disable embedded workers on the API host:

Terminal window
./whatomate server -workers=0

Run workers on separate machines:

Terminal window
./whatomate worker -workers=4

Both API and worker processes need access to the same Postgres and Redis.

The bundled docker/docker-compose.yml runs three services — app, db (postgres:17-alpine) and redis (redis:7-alpine). The app service runs server -migrate -config /app/config.toml, i.e. API plus one embedded worker.

Terminal window
docker compose up -d

It mounts ./config.toml read-only into the container and ./uploads read-write, and reads .env for TZ and the POSTGRES_* credentials (see docker/.env.example). TZ matters — IVR timing nodes evaluate business hours in the container's local time.

There is no separate worker service in the bundled file. To scale workers, either raise -workers on the app command, or add your own service running worker -workers=N against the same db and redis.

  • Set app.environment = "production" and app.debug = false.
  • Set app.encryption_key to a 32+ char random string. Back it up — losing it means losing all stored credentials.
  • Use strong, unique values for jwt.secret (32+ chars).
  • Set database.ssl_mode = "require" (or stricter).
  • Configure redis.password (and redis.username for ACL) and enable redis.tls = true for managed Redis.
  • Enable rate_limit.enabled = true and set rate_limit.trust_proxy to match your reverse proxy.
  • Restrict server.allowed_origins to your actual frontend hostnames.
  • For voice calling: set calling.public_ip on cloud hosts, ensure UDP udp_port_minudp_port_max is open in your security group, configure at least one [[calling.ice_servers]] (TURN strongly recommended for traversal across strict NATs).
  • Terminate TLS at a reverse proxy (nginx, Caddy, or a cloud load balancer) — the binary serves HTTP only.
  • Run API and workers as separate processes so a stuck campaign send doesn't block API responses.