Production
Dry-run to live calls is a pure environment change. Same binary, same code, same tests.
1. API key
export CALLHOOK_API_KEY=iams_live_...
Without it callhook fabricates results locally (dry-run). With it, every call is real and bills your CALL-E balance. There is no other switch.
2. Public webhook via tunnel
CALL-E POSTs terminal results to /callhook/webhook — it needs to reach you.
$ cloudflared tunnel --url http://localhost:8080
# or: ngrok http 8080
export CALLHOOK_PUBLIC_URL=https://your-tunnel.example.com
callhook automatically sets webhook_url on every call it creates.
3. Lock it down (required on any public URL)
export CALLHOOK_INTAKE_TOKEN=<random> # ALL /api/* endpoints require: Authorization: Bearer <token>
export CALLHOOK_WEBHOOK_SECRET=<random> # POST /callhook/webhook requires: X-Callhook-Secret: <secret>
The token gates every API endpoint (including the ones the war room uses — the Connect screen asks for it); /api/health stays open as the connect probe. Rate limiting (60 events/min per source) and CORS are built in.
4. Persistence
export CALLHOOK_JOURNAL=/var/lib/callhook/sessions.jsonl
An append-only JSONL journal. On boot, callhook replays it: sessions, armed retries, schedules and dedup keys all survive restarts — a crash mid-retry-cycle never silently drops or double-dials a customer.
Configuration reference
| Variable | Default | Meaning |
|---|---|---|
CALLHOOK_API_KEY | — | CALL-E API key. Empty = dry-run mode. |
CALLHOOK_API_BASE | https://api.heycall-e.com | API base override (e.g. a proxy). |
CALLHOOK_PUBLIC_URL | — | Public URL CALL-E uses to deliver terminal webhooks. |
CALLHOOK_ADDR | :8080 | HTTP listen address. |
CALLHOOK_INTAKE_TOKEN | — | Bearer token for event intake. |
CALLHOOK_WEBHOOK_SECRET | — | Shared secret for the CALL-E webhook. |
CALLHOOK_JOURNAL | data/sessions.jsonl | Session journal path. |
CALLHOOK_ENFORCE_WINDOWS | true | Defer calls outside polite local hours. |
CALLHOOK_RETRY_DELAY | 2h | Redial delay after no_answer. |
CALLHOOK_RETRY_TICK | 15s | Scheduler poll interval. |
Calling behavior defaults
Polite hours
9:00–20:00 recipient-local, weekdays only. Per-region timezone map; override per event with tz. Deferrals never consume retry attempts.
Retry ladder
no_answer and transient failures redial up to 2 times. Blocked, invalid, and out-of-region numbers are never redialed. Refusals are terminal — answered-and-said-no is final.
One attempt = one idempotency key
Each dial attempt gets its own key at CALL-E — a crash between "redial armed" and "redial placed" can never produce two calls.
Connecting real business data
The demo ships a mock store. Production implements one interface — business.Store: GetCustomer, GetOpenInvoice, MarkPromise, Escalate, RecordContact — against your CRM or billing system. Read tools feed the call context; writes land only from structured outcomes, policy-gated and audit-logged.