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"
Field Required Notes
name no Display name. Defaults to "Whatomate".
environment yes development | staging | production. Sets debug behavior, forces cookie.secure=true in production.
debug no Verbose logging.
encryption_key production AES-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.
Field Notes
host, port Default 0.0.0.0:8080.
read_timeout, write_timeout Seconds.
base_path Leave empty unless serving at a sub-path (https://app.example.com/whatomate) behind a proxy.
allowed_origins Comma-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.

Field Notes
secret HMAC signing key. Must be 32+ chars in production. Rotating invalidates all sessions.
access_expiry_mins Default 15.
refresh_expiry_days Default 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.

Field Notes
webhook_verify_token Token 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_version Meta Graph API version, e.g. v24.0. Defaults to v18.0 if unset — set it explicitly.
base_url Graph API base URL. Defaults to https://graph.facebook.com.
app_id Meta App ID, used by the frontend Embedded Signup flow.
app_secret Meta App Secret.
config_id WhatsApp Embedded Signup configuration ID used by the frontend login.
Field Notes
domain Set to .example.com to share auth cookies across subdomains. Leave empty for single-host.
secure The 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.

Field Notes
login_max_attempts Per IP, per window_seconds.
register_max_attempts Per IP, per window. Throttles sign-up attempts.
refresh_max_attempts Per IP, per window. Higher because legitimate clients refresh often.
sso_max_attempts Per IP, per window for /api/auth/sso/*.
api_max_requests Cap 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_seconds Window for api_max_requests. Defaults to 60 when unset or 0.
trust_proxy Set 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_binary — piper standalone TTS executable.
  • piper_model — .onnx voice model from piper-voices.
  • opusenc_binary — opusenc 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.

Field Notes
max_call_duration Hard cap in seconds; calls beyond this are forcibly terminated. Default 300.
audio_dir Where prompt audio files (hold_music.opus, ringback.opus, IVR prompts) live. Default ./audio.
hold_music_file, ringback_file Filenames 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_secs How long to wait for an agent to accept a transferred call before falling through. Default 120.
per_agent_timeout_secs Per-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_enabled Records 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_max RTP/SRTP UDP port range. Whatever range you set must be open in your firewall / security group. Default 10000–10100 gives 100 concurrent streams.
public_ip Required 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_only If 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:

Variable TOML field
WHATOMATE_APP__ENVIRONMENT app.environment
WHATOMATE_APP__ENCRYPTION_KEY app.encryption_key
WHATOMATE_DATABASE__HOST database.host
WHATOMATE_DATABASE__SSL_MODE database.ssl_mode
WHATOMATE_REDIS__HOST redis.host
WHATOMATE_REDIS__TLS redis.tls
WHATOMATE_JWT__SECRET jwt.secret
WHATOMATE_STORAGE__S3_BUCKET storage.s3_bucket
WHATOMATE_RATE_LIMIT__ENABLED rate_limit.enabled
WHATOMATE_CALLING__PUBLIC_IP calling.public_ip
WHATOMATE_DEFAULT_ADMIN__PASSWORD default_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:

    Field Required Notes
    Name yes Label for the account inside Whatomate.
    Phone Number ID (phone_id) yes From Meta Business Suite.
    Business Account ID (business_id) yes WABA ID from Meta Business Suite.
    Access Token yes Generated in Meta for Developers. Encrypted at rest.
    App ID no Meta App ID for this account.
    App Secret no Used 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 Token no An additional accepted verify token. GET /api/webhook checks the global whatsapp.webhook_verify_token first, then any account’s token.
    API Version no Per-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
Command Frontend Use Case
make build not embedded Development
make build-prod embedded Production
Terminal window
./whatomate <command> [options]
Command Description
server Start the API server (with optional embedded workers)
worker Start background workers only (no API server)
version Show version information
help Show 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_min–udp_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.