Webhooks and HTTP
Fermix's built-in web server: its URLs, how it verifies that WhatsApp and Slack webhooks are genuine, how it processes them in the background without duplicates, its health-check endpoints, and the FERMIX_HTTP_BIND option for which network address it listens on.
This page covers the small built-in web server Fermix runs so messaging platforms can deliver messages to it and so you can check its health. It handles webhooks (HTTP requests a platform sends to Fermix when something happens, like a new message) for WhatsApp and Slack, serves three health probes (URLs you can call to check whether Fermix is up and ready), and hosts the web setup wizard. Telegram, Discord, and Signal do not use HTTP webhooks; they use long-poll, gateway, or subprocess transports respectively.
HTTP endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
LiveView daemon status dashboard (HomeLive) |
GET |
/setup |
LiveView setup wizard (SetupLive), gated by a one-time launch token (403 without one) |
GET |
/health |
JSON readiness summary (backward-compatible alias for /health/ready) |
GET |
/health/live |
Liveness probe. Returns 200 if the Fermix runtime (the BEAM VM, the virtual machine Fermix runs on) is running |
GET |
/health/ready |
Readiness probe. Checks provider auth, memory, and configured channels |
GET |
/webhook/whatsapp |
WhatsApp hub.challenge verification |
POST |
/webhook/whatsapp |
WhatsApp event delivery |
POST |
/webhook/slack |
Slack Events API delivery and URL verification |
Signature verification
Signature verification confirms an incoming webhook really came from the platform and was not forged or altered, by checking a cryptographic signature the platform attaches.
Verification (GET) checks that hub.mode is subscribe, then compares the hub.verify_token query parameter against the configured token using a constant-time secure_compare (a comparison that takes the same time whether or not it matches, so an attacker cannot learn the token by timing it). On success it echoes back the hub.challenge value.
Event delivery (POST) verifies the X-Hub-Signature-256 header with HMAC-SHA256 (a keyed signature, computed from a shared secret, that proves the body is authentic) over the raw request body. The raw body is preserved through a CacheBodyReader plug (a small request-processing step) that stashes the unparsed bytes before JSON decoding, so signature verification always has access to the exact bytes the platform sent.
Relevant credentials. Each is a key in the [fermix_channels.whatsapp] section of config.toml and can also be supplied through the environment variable shown (see auth and secrets for how secrets are stored and resolved):
| Config key | Environment variable | Purpose |
|---|---|---|
access_token |
WHATSAPP_ACCESS_TOKEN |
WhatsApp Cloud API access token (used to send replies) |
verify_token |
WHATSAPP_VERIFY_TOKEN |
Webhook verification token (used in the GET challenge) |
app_secret |
WHATSAPP_APP_SECRET |
HMAC signing secret (used in POST verification) |
The sending phone number’s ID is not a secret, so it is not stored in the OS keyring — set it as the phone_number_id key in the same config section, or via the WHATSAPP_PHONE_NUMBER_ID environment variable.
Slack
POST requests are verified using Slack’s standard signing scheme:
X-Slack-Request-Timestampis checked against the current time. Requests older than 300 seconds or with a future-skewed timestamp are rejected with401 stale_timestamp.- The signing base string
v0:<timestamp>:<raw_body>is HMAC-SHA256’d with theSLACK_SIGNING_SECRET. - The result is compared with the
X-Slack-Signatureheader using a constant-time comparison.
url_verification challenges (Slack’s initial endpoint validation) are handled directly in the controller without dispatching to the agent.
Relevant credentials, as keys in the [fermix_channels.slack] config section or the environment variables shown:
| Config key | Environment variable | Purpose |
|---|---|---|
bot_token |
SLACK_BOT_TOKEN |
Slack bot token for sending replies |
signing_secret |
SLACK_SIGNING_SECRET |
Request signing secret for verifying inbound events |
Async dispatch
Async dispatch means Fermix accepts the webhook and replies right away, then does the slower work (running the agent, downloading media) in the background. After signature verification, the webhook controller checks message IDs via an idempotency gate (a duplicate check, so a message redelivered by the platform is not processed twice), then hands the work off to a supervised background task:
Task.Supervisor.start_child(FermixCore.TaskSupervisor, work)
The controller returns 200 {"ok": true} immediately. Agent processing, media downloads, and transcription happen asynchronously, so they no longer block within the platform’s retry window. Each accepted batch emits a [:fermix, :channel, :webhook] telemetry event counting fresh and duplicate messages.
A 200 means the request was authentic and accepted — not that the message will be answered. Sender authorization happens afterwards in the gateway: a message from a sender who is not the configured owner and not on the allowlist is silently dropped there, with no reply (see ingress and trust). The webhook routes themselves are always mounted, even when the channel is disabled or unconfigured; a request for an unconfigured channel fails signature verification (not_configured) and gets 401.
If start_child fails, Idempotency.forget/2 rolls back the recorded message IDs so the platform’s automatic retry succeeds on the next attempt.
WhatsApp multi-image albums
WhatsApp delivers a multi-image album as separate per-image webhook messages (there is no shared group ID on the wire). After idempotency filtering, WhatsApp messages are routed through a per-channel album buffer (AlbumBuffer) rather than dispatched directly to the gateway. The buffer coalesces parts that arrive within a short debounce window (a brief wait to let the related parts arrive — 3 seconds) into a single merged turn (captions joined in arrival order, attachments concatenated) before forwarding to the gateway. Any non-image WhatsApp message from the same sender flushes their pending album first, so ordering is preserved. If the buffer’s dispatch fails at flush time, the recorded idempotency IDs for all buffered parts are forgotten so the platform’s re-delivery can re-run. An inbound image on a media-capable channel is passed to the model as image content (not a placeholder); audio attachments are transcribed before the agent sees them. See channels for the full media ingestion flow.
Idempotency
Idempotency (the guarantee that a redelivered message is processed at most once) is backed by an in-memory table whose updates are serialized so two copies of the same message cannot both slip through. Keys are {channel, platform_message_id}.
| Scope | TTL (how long an entry is remembered) |
|---|---|
| Inbound messages | 24 hours |
| Outbound media | 60 seconds |
Body limits and raw body access
The endpoint parser enforces a 1,000,000-byte (1 MB) body limit. Requests exceeding this limit receive 413. The CacheBodyReader plug preserves the raw body for HMAC verification before the JSON parser consumes the stream.
Error responses
| Reason | Status | Body |
|---|---|---|
invalid_signature, invalid_token, missing_signature, missing_timestamp, missing_token, missing_raw_body, not_configured, stale_timestamp |
401 |
{"error": "Unauthorized"} |
unsupported_transport, invalid_webhook_payload |
400 |
{"error": "Invalid webhook"} |
| Async task supervisor at capacity | 503 |
{"error": "Webhook handoff unavailable"} |
| Unexpected dispatch error | 500 |
{"error": "Webhook dispatch failed"} |
Health endpoint detail
GET /health/ready (and /health) calls FermixCore.Health.report/0, which aggregates:
- Overall boot status plus app metadata (
app,version,timestamp,config_path,restart_required?, and resolvedconfigpaths). - Per-provider status: one entry per configured provider with its
status,auth_mode, and which one isprimary. - Memory backend status: the ConversationStore and ETS Store process statuses.
- Per-channel status: each channel’s
statuswithenabled,mode, andprocess_alive. - Realtime voice readiness (
enabled/statusplus provider, model, and socket details).
GET /health/live performs only a process-level liveness check and does not inspect provider or channel state. Use it as a container or load-balancer liveness probe; use /health/ready as the readiness probe.
All three health responses accept a ?pretty=1 query parameter (for example GET /health/ready?pretty=1), which returns indented JSON for easier reading by a human.
The endpoint sends the full Health.report/0 map verbatim. providers and channels are arrays of objects, not flat string maps, and memory/realtime are nested objects. Example (some config.workspace keys abbreviated):
{
"status": "ready",
"app": "fermix",
"version": "<installed version>",
"timestamp": "2026-06-27T12:00:00Z",
"failures": [],
"config_path": "/home/you/.fermix/config.toml",
"restart_required?": false,
"config": {
"home": "/home/you/.fermix",
"path": "/home/you/.fermix/config.toml",
"workspace": {
"workspace": "/home/you/.fermix/workspace",
"grants": "/home/you/.fermix/grants"
}
},
"providers": [
{ "name": "openai", "status": "ready", "auth_mode": "api_key", "primary": true }
],
"channels": [
{ "name": "telegram", "status": "ready", "enabled": true, "mode": "polling", "process_alive": true },
{ "name": "whatsapp", "status": "disabled", "enabled": false, "mode": null, "process_alive": null }
],
"memory": {
"conversation_store": "ready",
"store": "ready"
},
"realtime": {
"enabled": false,
"status": "disabled",
"provider": null,
"model": null,
"socket_path": null,
"socket_alive": null,
"active_sessions": 0,
"active_clients": 0,
"companion_connected?": false
}
}
The top-level status is ready (HTTP 200) when everything is healthy, otherwise degraded or setup_required (HTTP 503).
Channels that do not use HTTP webhooks
Telegram, Discord, and Signal have no HTTP routes in the router:
- Telegram uses long-poll (
getUpdates): Fermix repeatedly asks the platform for new messages rather than being pushed them. - Discord uses the Gateway WebSocket (a persistent connection the platform pushes events over) for inbound messages and the REST API for replies.
- Signal uses a
signal-clisubprocess (a separate helper program Fermix launches) to receive and send.
See channels for details on each transport.
Bind address (FERMIX_HTTP_BIND)
The web server binds to 127.0.0.1 (loopback, meaning it only accepts connections from the same machine) by default. To receive webhook traffic from the internet or a reverse proxy on another interface, set:
FERMIX_HTTP_BIND=0.0.0.0 fermix run
Binding to 0.0.0.0 also exposes the status dashboard (/), the health probes, and the setup wizard (/setup) on the network. The setup wizard is itself gated by a one-time launch token: a request without a valid ?t=... token (or an already-authorized session) is refused with 403 setup authorization required, so an anonymous caller on the network cannot open it to reconfigure the daemon. Even so, place Fermix behind a trusted network boundary or a reverse proxy with appropriate access controls before using this setting in an exposed environment.
The default port is 4030. It can be overridden at runtime via the PORT environment variable:
PORT=8080 fermix run
Related pages
- Channels: per-channel configuration, transports, and allowlist setup.
- Ingress and trust: how the dispatcher filters and routes inbound messages.
- Configuration: full environment variable and
config.tomlreference. - Traces and telemetry: telemetry events emitted during webhook ingress and agent dispatch.