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.
Configuration File
Section titled “Configuration File”[app]name = "Whatomate"environment = "development" # development, staging, productiondebug = trueencryption_key = "" # AES-256 key (32+ chars) for encrypting secrets at rest. Required in production.
[server]host = "0.0.0.0"port = 8080read_timeout = 30write_timeout = 30base_path = "" # Set to "/subpath" if behind nginx proxy_pass at a sub-pathallowed_origins = "" # Comma-separated CORS origins. Empty = allow all (dev only).
[database]host = "localhost" # Use "db" inside Docker composeport = 5432user = "whatomate"password = "your-password"name = "whatomate"ssl_mode = "disable" # disable, require, verify-ca, verify-fullmax_open_conns = 25max_idle_conns = 5conn_max_lifetime = 300 # seconds
[redis]host = "localhost" # Use "redis" inside Docker composeport = 6379username = "" # Redis 6+ ACL username. Empty = default user.password = ""db = 0tls = false # true for managed Redis (Upstash, Redis Cloud)
[jwt]secret = "change-me-in-production" # 32+ chars in productionaccess_expiry_mins = 15refresh_expiry_days = 1
[storage]type = "local" # local | s3local_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 Signupapp_secret = "" # Meta App Secretconfig_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 switchlogin_max_attempts = 10register_max_attempts = 10refresh_max_attempts = 30sso_max_attempts = 10window_seconds = 60trust_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 # secondsaudio_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 agentrecording_enabled = trueudp_port_min = 10000udp_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"Sections in detail
Section titled “Sections in detail”| 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. |
[server]
Section titled “[server]”| 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. |
[database]
Section titled “[database]”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.
[redis]
Section titled “[redis]”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.
[storage]
Section titled “[storage]”Controls media storage (template samples, uploaded files, call recordings).
type = "local"— files underlocal_path(default./uploads). Suitable for single-host deployments. The directory must be writable by the process and persisted (Docker volume).type = "s3"— fill ins3_bucket,s3_region,s3_key,s3_secret.
[whatsapp]
Section titled “[whatsapp]”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. |
[cookie]
Section titled “[cookie]”| 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. |
[rate_limit]
Section titled “[rate_limit]”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—.onnxvoice model from piper-voices.opusenc_binary—opusencfromopus-tools(apt install opus-tools/dnf install opus-tools). Defaults to looking upopusencinPATH.
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.
[calling]
Section titled “[calling]”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.
[default_admin]
Section titled “[default_admin]”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.
Environment variables
Section titled “Environment variables”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.
Database Setup
Section titled “Database Setup”PostgreSQL
Section titled “PostgreSQL”# Create databasecreatedb whatomate
# Or using psqlpsql -c "CREATE DATABASE whatomate;"Run Migrations
Section titled “Run Migrations”./whatomate server -migrateSchema 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.
WhatsApp API Configuration
Section titled “WhatsApp API Configuration”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:
-
Navigate to Settings → Accounts
-
Click Add Account
-
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-256on 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/webhookchecks the globalwhatsapp.webhook_verify_tokenfirst, 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_receiptandbusiness_calling_enabled.
Building
Section titled “Building”Development
Section titled “Development”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.
Production
Section titled “Production”make build-prod # Single binary with embedded frontend| Command | Frontend | Use Case |
|---|---|---|
make build | not embedded | Development |
make build-prod | embedded | Production |
CLI Reference
Section titled “CLI Reference”./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 |
server options
Section titled “server options”./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)worker options
Section titled “worker options”./whatomate worker [options]
-config string Path to config file (default "config.toml") -workers int Number of workers to run (default 1)Deployment Scenarios
Section titled “Deployment Scenarios”All-in-One
Section titled “All-in-One”API and workers in a single process — simplest, fine up to moderate campaign volume:
./whatomate serverSeparate API and Workers
Section titled “Separate API and Workers”Disable embedded workers on the API host:
./whatomate server -workers=0Run workers on separate machines:
./whatomate worker -workers=4Both API and worker processes need access to the same Postgres and Redis.
Docker Compose
Section titled “Docker Compose”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.
docker compose up -dIt 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.
Production Recommendations
Section titled “Production Recommendations”- Set
app.environment = "production"andapp.debug = false. - Set
app.encryption_keyto 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(andredis.usernamefor ACL) and enableredis.tls = truefor managed Redis. - Enable
rate_limit.enabled = trueand setrate_limit.trust_proxyto match your reverse proxy. - Restrict
server.allowed_originsto your actual frontend hostnames. - For voice calling: set
calling.public_ipon cloud hosts, ensure UDPudp_port_min–udp_port_maxis 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.