# 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.

### WhatsApp

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](/docs/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:

1. `X-Slack-Request-Timestamp` is checked against the current time. Requests older than 300 seconds or with a future-skewed timestamp are rejected with `401 stale_timestamp`.
2. The signing base string `v0:<timestamp>:<raw_body>` is HMAC-SHA256'd with the `SLACK_SIGNING_SECRET`.
3. The result is compared with the `X-Slack-Signature` header 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:

```elixir
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](/docs/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](/docs/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 resolved `config` paths).
- Per-provider status: one entry per configured provider with its `status`, `auth_mode`, and which one is `primary`.
- Memory backend status: the ConversationStore and ETS Store process statuses.
- Per-channel status: each channel's `status` with `enabled`, `mode`, and `process_alive`.
- Realtime voice readiness (`enabled`/`status` plus 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):

```json
{
  "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-cli` subprocess (a separate helper program Fermix launches) to receive and send.

See [channels](/docs/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:

```bash
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:

```bash
PORT=8080 fermix run
```

## Related pages

- [Channels](/docs/channels): per-channel configuration, transports, and allowlist setup.
- [Ingress and trust](/docs/ingress-and-trust): how the dispatcher filters and routes inbound messages.
- [Configuration](/docs/configuration): full environment variable and `config.toml` reference.
- [Traces and telemetry](/docs/traces-and-telemetry): telemetry events emitted during webhook ingress and agent dispatch.
