The callhook project story
How a one-line idea — "a webhook goes in, a phone call comes out" — became a single-binary product with 19 doc-verified integrations, a live demo, a documentation system that finds you when you're stuck — and a first real phone call that connected on attempt four.
1. Origin: calle → callhook
The project started as calle — event-driven AI phone calls on top of
the CALL-E Developer API. The first commit
(de914a7) was a single pipeline: webhook in, Call-E call, structured
outcome back.
The rename to callhook (24d24d7) was a positioning decision made
from the name itself: call + hook. It describes the entire
architecture in one word, and it sets up the sentence every developer
already understands — "if you've ever built a webhook, you already know
how to use this." Every subsequent design choice was checked against that
sentence: does this make callhook more like a webhook (familiar,
stateless, boring) and less like a telephony integration (bespoke,
fragile, clever)?
The etymology callouts from the calle era (a stale Spanish-language
explanation) were deliberately deleted (7958b20) rather than rewritten —
the name no longer needed a story.
2. Design language: Gothic noir
The brand was built before the product surfaces, and never changed after.
Palette (b3b60a02, b060172):
| Token | Value | Role |
|---|---|---|
--bg |
#000000 |
true black, not "dark gray" |
--brand-silver |
#D1D0D0 |
primary text |
--brand-mauve |
#988686 |
secondary |
--brand-umber |
#5C4E4E |
tertiary / accents |
Rules that held for the whole project:
- No UI framework. The war room is hand-rolled CSS on brand tokens — React + TypeScript + Vite, zero component libraries. The docs site and the app share the identical token set, so they read as one product.
- Monospace for anything machine-owned. Customer IDs, phone numbers,
env vars, session IDs, routes, outcomes — all
--font-mono. Human copy is the only thing in proportional type. - No emojis, anywhere. Enforced across several commits (
8a752d5,97b4e58,3f99aec) including the architecture diagrams. The tone is instrument panel, not marketing site. - Hand-built SVG marks. The logo (
icon-tight.svg,logo-full.svg,wordmark-tight.svg) went through iterations including a lowercasecx-height experiment (e9b21e8) that was reverted (dbfa128) because the capital preserved the wordmark's baseline rhythm. - Status colors carry meaning, not decoration. Amber = dry-run / attention, green = live / success, red = error. A yellow dot in the header means fabricated calls — that's a safety signal, not a theme accent.
The docs site got its own redesign (3eda39a): three-column layout,
left sidebar, scroll-spy TOC, theme toggle, client-side search — still
zero dependencies, plain JS in main.js.
3. Backend architecture: the decisions that mattered
Single Go binary, zero external dependencies (go.mod has an empty
require block and stays that way). Five decisions shaped everything:
3.1 No LLM in the orchestration path
CALL-E's voice agent is the conversational brain. callhook's job is deterministic: compose the task, prefetch the context, gate the actions. A hallucinated amount or date spoken on a real call is a real-world failure, so task composition is templated Go code with live business data baked in — auditable, diffable, testable. The README states this as a deliberate non-decision.
3.2 Read/write separation
Prefetched context is given to the voice agent (customer plan, invoice,
payment history). Business writes happen only post-call, from the
JSON-Schema-validated structured result — never from raw conversation
transcript. Every write is policy-gated: unambiguous outcomes write
(mark_promise), uncertain ones escalate to a human.
3.3 Crash-safe by journal, not by database
Sessions and campaigns persist to append-only JSONL journals
(internal/store). On boot, the journal replays: in-flight sessions,
armed retries, and scheduled calls all resume. No database dependency,
no migrations — a demo you can Ctrl-C mid-campaign and restart intact.
3.4 The polite-hours gate
internal/callwindow defers calls outside 9:00–20:00 recipient-local,
weekdays, per-region timezone, overridable per event. A voice product
that rings phones at 3am is a lawsuit, not a product. Deferrals are
first-class states (call_deferred), not retries.
3.5 Idempotency everywhere
Every event carries an ID that is also its idempotency key, persisted
across restarts. Every retry attempt gets its own attempt-scoped
idempotency key (<session>-a<n>) so a crash mid-retry can never
double-dial. "Never double-dial" is guarantee #1 in the README table.
3.6 Campaigns: goals over dial-lists
The campaign engine (e424c11) was the biggest architectural addition:
declare a goal (count N successes, or reach_all) and an audience;
the engine runs waves, requeues no_answer entries, hard-stops on
budget, and stops the moment the goal is met. The E2E-verified demo:
goal met in 12 of 15 budgeted calls — 18 people never called. That
result is quoted in the README because it's the product thesis: never
waste a call.
4. Event blueprints: 3 → 8
internal/events/router.go maps event types to blueprints (task composer
- result schema). The core three (
invoice.due,account.warning,promo.offer) were joined by five more (28efc2c):delivery.window,appointment.reminder,payment.failed,subscription.expiring,feedback.request. Every blueprint follows the same contract, and the intake API, session handling, retries, windows, persistence, and outcome engine were already generic — adding a type is one file, no plumbing.
The presentation layer lagged (the docs and UI still said "three event
types") until commit 9387de6 pushed all 8 everywhere: a shared
web/src/lib/events.ts catalog, per-type campaign presets, outcome
tables in the docs, and the search index.
5. MCP: agents operate the voice channel
2981781 added a Model Context Protocol server over Streamable HTTP at
POST /mcp — stdlib only, JSON-RPC 2.0. Six tools: fire_event,
launch_campaign, get_campaign, list_sessions, list_event_types,
run_demo. Any MCP client (Claude Code, Cursor) can operate callhook.
Alongside it, f44d18c added the SSE stream (GET /api/stream) so the
war room updates instantly rather than polling.
6. Live-fire: the first real call
Dry-run proves the pipeline; only live fire proves the integration. The test session against the real CALL-E API — with a real phone number — is the part of the story no mock can tell.
6.1 Method: read-only first, balance second
The API key was validated against GET /v1/goals (read-only, zero
balance) before any call was placed. Only then did testing touch the 20
free calls the account provides.
6.2 The carrier saga
The first real call failed instantly with a provider 404. The
diagnosis came from CALL-E's own event stream (GET /v1/calls/{id}/events):
the task had run — bot created, robot resolved, dial attempted — but the
recipient was routed with region: "US" while the number was Indian
(+91…). CALL-E routes by region; the mismatch killed the call before
the carrier. Attempt two, correctly routed IN/en-IN, failed with a
provider 500; attempt three with SIP 480 (temporarily unavailable) —
international lines into India are filtered aggressively, and CALL-E's
own docs mark international lines as "primarily intended for testing."
Attempt four connected. A real conversation, on a real phone:
AGENT: is this Priya Sharma?
AGENT: I'm calling for your service provider to thank you for being a
loyal Pro plan customer since March twenty twenty-four, and to
offer you 20% off your next invoice, valid until the end of this
week; are you interested?
USER: Hello. No.
AGENT: No problem at all—thanks for being a loyal Pro plan customer.
Take care.
Every rule from the blueprint was obeyed — the loyalty framing, the
single offer, the never-push-twice exit. The structured result came back
schema-valid ({"outcome": "declined"} at 0.92 confidence) and the
terminal webhook arrived through the tunnel, was deduped, and drove the
outcome engine. Four calls of twenty spent on the truth.
6.3 What live fire caught that tests couldn't
- CALL-E redelivers terminal webhooks. The same call was processed
twice — a business write could have applied twice. Fix (
66846e1): dedupe by the webhook event id, exactly as their docs recommend. - Webhook payloads omit transcripts. The full transcript exists on
GET /v1/calls/{id}but not in the webhook body. Fix (same commit): the outcome engine re-fetches the call to enrich the session before running actions.
Both are invisible in dry-run and in mocks — they only exist in the contract between two real systems.
7. The integrations saga: 19 platforms, doc-verified
This was the largest single effort, and the methodology matters more than the count.
6.1 The rule
No adapter ships without its signature scheme verified against the vendor's own documentation. Not from memory, not from a blog post — from the vendor's docs (or, for WooCommerce, the plugin source). Each adapter's header comment cites the URL it was verified against.
6.2 What the research corrected
Assumptions that would have produced silently-broken integrations:
| Platform | Assumption | Reality (verified) |
|---|---|---|
| Intercom | HMAC-SHA256 | HMAC-SHA1, 40 hex chars, sha1= prefix — a SHA256 implementation rejects 100% of real deliveries |
| Shopify | hex digest | base64 digest |
| WooCommerce | hex digest | base64 digest (confirmed from class-wc-webhook.php) |
| Typeform | hex digest | sha256= + base64 |
| Datadog | HMAC signing | no signing at all — custom header or basic auth |
| Pipedrive | HMAC signing | no signing — HTTP Basic auth or URL token |
| Chargebee | HMAC signing | no signing — HTTP Basic auth |
| Zapier/Heroku | — | Zapier uses a Custom auth scheme, not the default |
These aren't nitpicks — each one is the difference between "works on the first webhook" and "mysteriously 401s forever."
6.3 The adapter contract
Every adapter does exactly three things: verify the signature, map
the payload to an Event, forward through the shared Fire function.
Shared crypto primitives live in verify.go (hex/base64/SHA1 HMAC,
Stripe's t=.v1=, Slack's v0:ts:body, HubSpot v3's
method+URI+body+timestamp, Paddle's ts;h1, basic auth, bearer) —
stdlib crypto/* only.
6.4 The one that needed real work: AWS SNS
SNS doesn't HMAC — it signs with RSA over a field-ordered string-to-sign,
using an X.509 cert fetched from a URL inside the (untrusted) message. The
adapter implements the full documented procedure: validate the cert URL
(https, *.amazonaws.com, SNS path pattern) → fetch and cache the cert →
build the exact newline-delimited field string (different for
Notification vs SubscriptionConfirmation) → verify PKCS1v15 with SHA256
(SigV2) or SHA1 (SigV1). The tests generate a self-signed cert and sign
real messages, including a tamper test (sign, then mutate the message).
6.5 Testing philosophy
36 tests in internal/integrations. Each test computes a valid
signature the way the platform does, asserts the adapter accepts it,
and asserts 401 on forged, stale, or replayed input. GitHub's documented
test vector (It's a Secret to Everybody / Hello, World!) is asserted
verbatim. The tests caught two real bugs during development — HubSpot's
URI reconstruction and Pipedrive's numeric webhook_id — which is
exactly what they're for.
6.6 The 19
Stripe · Slack · Shopify · HubSpot · PagerDuty · GitHub · Calendly · Typeform · WooCommerce · Xero · QuickBooks · Paddle · Grafana · Datadog · Intercom · Chargebee · Pipedrive · AWS SNS · Generic HTTP
Plus outbound packages: an n8n community node (TypeScript + credentials), a Zapier CLI app, agent tools for OpenAI function calling, LangChain, AutoGen/CrewAI, and recipes for Salesforce Apex, Airtable, and Google Forms Apps Script.
6.7 The catalog
GET /api/integrations serves the registry with live env-var status; the
war room's Integrations page renders it. An unconfigured platform answers
401 loudly on every delivery — a missing env var is caught the first
time a hook fires, not after a silent week.
8. Documentation as a product surface
Docs were never an afterthought. The insight that shaped the final iteration: documentation should find you at the moment you're stuck.
7.1 Three layers
- Errors carry their own fix. Every integration 401 and every
pipeline error includes a
docsfield pointing at the exact page and anchor.{"error": "STRIPE_WEBHOOK_SECRET not configured", "docs": ".../integrations.html#native-adapters"}. - The UI renders the pointer. A
DocsHint/ErrorDocsHintcomponent wired into Connect (connection failures), Fire (error/deferred/ scheduled states), Settings (dry-run, unauthenticated warnings), War Room (empty state), CampaignWizard, and Integrations — backed by onelib/docs.tsmap so every page behaves consistently. - A troubleshooting page built for real failures.
troubleshooting.htmlcovers: war-room connect failures, webhook 401s by cause (hex-vs-base64 per platform, HubSpot's tunnel URI gotcha, replay windows + clock sync, basic-auth-not-API-key), every/api/eventserror, "events fire but no calls happen" diagnosis, campaign problems, and install/startup issues.
7.2 The corpus
INTEGRATIONS.md (master table + env vars + honest notes) · 19 per-platform
guides under integrations/ (dashboard steps, env vars, mappings) ·
8-page docs site (quickstart, install, API, events, campaigns,
integrations, production, architecture, troubleshooting) · root README ·
backend README · web README. Search index covers all of it.
9. The war room: UX iterations
The frontend went through several honest iterations, each fixing a real observed problem:
| Commit | Change | Why |
|---|---|---|
7f7dfa1 |
Full React war room | From a Go string-literal dashboard to a real app, embedded via go:embed |
5c30739 |
Mobile drawer | The sidebar table is unusable on a phone |
9a82c45 |
Dry-run badge → colored dot | The text badge crowded the header; the dot + click-popover keeps it compact while still being explicit |
eefb61b |
Connect proves the token | The live-demo failure: connect only checked /api/health (always open), so a stale empty token "connected" fine and every action 401'd invisibly |
b26a73a |
Progress bar + dim + skeletons | Loading feedback |
707ed83 |
Google-style indeterminate bar + shimmer on slow refetches | The first bar grew-and-stalled; the first skeletons only appeared on cold load |
6dfc2ae |
Bar only on user actions; SSE-aware polling | The bar pulsed on every 2s background poll — the app looked permanently stuck. Reads now show local shimmer; only mutations dim the app. Polling backs off to a 20s heartbeat while SSE is connected |
8100dc8 |
Minimum 600ms skeleton display | A 100ms ghost flash reads as a glitch. Skeletons now hold long enough to be perceived |
The through-line: honest state over decoration. Dry-run is a dot because it's a safety signal. Loading is a skeleton where the data lives and a bar only when you caused the work. Errors are loud, with the fix.
10. Deployment: Render
26f9910 made the repo Render-ready: a three-stage Dockerfile (node
web build → Go embed+build → alpine runtime, non-root), a render.yaml
blueprint (health check /api/health, generated secrets, disk for the
journal), and a PORT convention fix in main.go (Render/Railway/Fly
inject PORT; callhook previously ignored it).
The Dockerfile bug worth remembering (9d10a5d): the first version
copied the web build with RUN cp -r /web/dist/* — but BuildKit prunes
unreferenced stages, so the web stage never ran, dist/ didn't exist,
and the build failed. The fix is COPY --from=web, which creates the
stage dependency; the web stage also now asserts dist/index.html exists
so a silent vite failure can't slip through. CI gained a docker build
job (1cd5ec8) so this class of regression is caught pre-push.
Live: callhook.onrender.com — dry-run mode (real pipeline, fabricated calls, zero balance). Free tier sleeps after inactivity; the first request warms in ~50s.
11. Honest mistakes log
Kept because the fixes are the story:
- Intercom SHA256 assumption — caught in docs research; would have rejected every real delivery.
- Dockerfile unreferenced stage —
RUN cpdoesn't trigger a build; CI added to catch it. - Connect didn't verify the token — a stale empty token produced a
broken-but-"connected" session showing
—everywhere with no reason. Now connect proves the token, 401s clear the session back to Connect, and the war room shows the error explicitly. - Global loading on every request — background polls pulsed the progress bar; the app looked permanently loading. Now mutations only.
- Skeletons too fast to see — added a minimum display time.
- Test flake from a shared clock — a test overrode the package-level
nowwithout restoring it, failing later tests. Fixed witht.Cleanup. - Duplicate JSX block during the shimmer refactor — caught by re-reading the file after the edit, removed.
- CORS preflight returned 405 — Go's method-based routing rejected
OPTIONSbefore the middleware could answer it. Caught by the new httptest suite; fixed by answering preflights at the router level. Routes()rebuilt the rate limiter on every call — harmless in production (called once) but a footgun; routes are now cached on first build.- Session store handed out live pointers —
Get/FindByCallIDreturned the shared*Session, so webhook-path reads could race scheduler writes. CI's-racecaught it in a test; the fix makes both methods return copies (62cedd0) — a production hardening, not a test patch.
12. What was deliberately not built
- No extra LLM in orchestration (see §3.1).
- No inbound calls / telephony — that's CALL-E's job (~45 countries, IVR, voicemail, transfer handling). callhook is the layer above.
- No database — the JSONL journal is enough for crash-safety at this scale, and keeps the zero-dependency promise.
- No fake integrations — every adapter does real signature verification against the vendor's documented scheme, or it doesn't ship.
13. Timeline (commit archaeology)
| Era | Commits | What happened |
|---|---|---|
| Origin | de914a7 → 2874e9d |
calle: pipeline, retries, auth, persistence, windows, Goals API, CLI |
| Brand | b060172 → 7134197 |
Gothic noir theme, hand-built SVG marks, emoji purge |
| Rename | 24d24d7 |
calle → callhook |
| Frontend | 9fb1a3f, 7f7dfa1, 5c30739 |
Monorepo restructure; the React war room; mobile drawer |
| Campaigns | e424c11 |
Goal-driven engine, waves, early-stop, budgets |
| Hardening | d11e673, 28efc2c, 62cedd0 |
Test coverage 4→10 files; 5 new blueprints (8 total); CI race caught → session store returns copies |
| Live-fire | 66846e1 |
First real call: carrier saga, region fix, real transcript; webhook dedup + transcript enrichment |
| Agent surface | 2981781, f44d18c |
MCP server; SSE stream |
| Integrations | 17fe0a1 |
19 doc-verified adapters + packages + UI + docs |
| Doc |
Built With
- calle
- golang
- html
- typescript
Log in or sign up for Devpost to join the conversation.