Maxx Webhook Ingestion

Webhook ingestion is a small, local-first HTTP listener that turns an external webhook into a visible Maxx tab launch. It is the network front door to the same pipeline the automation trigger runner exposes for polling and local scripts: a request is parsed by a configured connector adapter and launched through the Control API.

Maxx stays the visible terminal-native runtime/control plane, never the workflow brain. The listener accepts a request only on an explicitly configured route, validates the transport, parses the opaque payload with the route’s configured adapter, and launches exactly the configured command. Maxx never decides what a Linear/GitHub/CI event means — the route mapping and the launched command own that. See the no-inference rule.

The product boundary

Quick start

  1. Write a config file (webhook.json):

    {
      "bind": "127.0.0.1:8787",
      "routes": [
        {
          "path": "/hooks/linear-issue",
          "source": "linear",
          "command": "codex resume --prompt-file $MAXX_WEBHOOK_PAYLOAD_FILE",
          "title": "${issue.identifier}: ${title}",
          "caller": "linear-webhook",
          "group": "issue-${issue.identifier}",
          "prompt_delivery": "file",
          "auth": {
            "mode": "hmac",
            "secret_env": "MAXX_WEBHOOK_SECRET",
            "header": "X-Webhook-Signature",
            "prefix": "sha256="
          }
        }
      ]
    }
    
  2. Export the secret and start the listener (the running Maxx app serves the control socket the launches target):

    export MAXX_WEBHOOK_SECRET=$(openssl rand -hex 32)
    maxx +webhook serve --config webhook.json
    # maxx webhook listening on http://127.0.0.1:8787 (1 route(s))
    
  3. Deliver a signed request. The signature is the lowercase hex HMAC-SHA256 of the raw request body using the secret:

    body='{"action":"create","type":"Issue","data":{"id":"evt-1","identifier":"MAX-9","title":"Webhook ingestion","url":"https://linear.app/x/MAX-9"}}'
    sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$MAXX_WEBHOOK_SECRET" | awk '{print $2}')
    curl -sS -X POST http://127.0.0.1:8787/hooks/linear-issue \
      -H 'Content-Type: application/json' \
      -H "X-Webhook-Signature: sha256=$sig" \
      -d "$body"
    # {"ok":true,"outcome":"launched","session_id":"SID-..."}
    

A new visible tab appears in Maxx running the configured command, with the raw payload available to it.

JSON, not TOML. The issue that introduced this feature sketched a TOML config; the implementation uses JSON to match Maxx’s existing JSON-based control/connector tooling and reuse the same parser. The route-to-command model is identical.

Configuration

Top-level fields:

Field Required Default Meaning
bind no 127.0.0.1:8787 host:port (or [ipv6]:port) to listen on.
max_body_bytes no 1048576 (1 MiB) Default request-body cap; routes may override.
routes yes At least one route.

Each route:

Field Required Default Meaning
path yes Exact request path that selects this route (must start with /).
source yes Connector adapter that parses the payload (linear, github).
command yes Command to run. No ${...} placeholders (see security note below).
title no event title Tab title (templated).
cwd no Working directory (templated).
env no Extra env entries [{ "key", "value" }] (values templated).
prompt_delivery no env How the connector prompt reaches the command: env, stdin, or file.
caller no trusted-local Policy source the launch is attributed to (not templated).
group no Supervisor group label (templated).
trigger no path Display name recorded as the trigger.
max_body_bytes no global default Per-route body cap.
dedup_header no Request header carrying a per-delivery id for dedup (see below).
predicates no Exact checks over explicit adapter fields (see below).
auth yes Authentication (below).

${field} placeholders in title, cwd, group, and env values are filled only from explicit event fields (e.g. ${title}, ${issue.identifier}, ${repo.full_name}, ${url}). A required placeholder the payload does not provide fails the request (HTTP 422); use ${field?} for an optional one. caller is deliberately not templated — a policy identity is a fixed deployment decision, never derived from an untrusted payload. See connector adapters for the field set each source provides and the templating rules.

Security: command must not contain ${...} placeholders. Maxx launches a tab by shell-evaluating the command string, so interpolating a provider-controlled field (an issue/PR title or body — values an attacker can often set) directly into command would be a shell-injection vector even behind a valid signature. The config validator rejects any ${ in command. Get payload data to the command the safe way instead: put it in a templated env value and reference it as a quoted shell variable ("command": "claude \"$ISSUE\"", "env": [{"key": "ISSUE", "value": "${title}"}]) — the shell does not re-tokenize an expanded variable — or read $MAXX_WEBHOOK_PAYLOAD_FILE / the connector prompt. A plain $VAR (no braces) in command is a normal shell reference and is left untouched.

Predicates

predicates is an array of exact checks over fields copied by the configured connector adapter. All predicates must match. Each predicate object has a non-empty field and exactly one operation:

Operation Value type Meaning
equals string Field must be a string exactly equal to it.
equals_bool boolean Field must be a boolean exactly equal to it.
present true Field must be present with any value.

Predicates run after authentication and adapter parsing, before template resolution, raw-payload temp-file writes, duplicate suppression, or launch. A mismatch returns {"ok":true,"outcome":"filtered","field":...} and performs no launch side effects. Comparisons are structured field comparisons, not regexes over raw payload text.

Linear issue route that launches only when the payload explicitly carries the Todo state:

{
  "path": "/hooks/linear-todo",
  "source": "linear",
  "command": "codex resume --prompt-file $MAXX_WEBHOOK_PAYLOAD_FILE",
  "title": "${issue.identifier}: ${title}",
  "predicates": [
    { "field": "action", "equals": "update" },
    { "field": "issue.state.name", "equals": "Todo" }
  ],
  "auth": {
    "mode": "hmac",
    "secret_env": "LINEAR_SECRET",
    "header": "X-Webhook-Signature",
    "prefix": "sha256="
  }
}

GitHub route that launches cleanup only when a pull request payload explicitly says it was merged:

{
  "path": "/hooks/github-pr-merged",
  "source": "github",
  "command": "codex run cleanup-merged-pr",
  "title": "Merged PR #${number}: ${title}",
  "predicates": [
    { "field": "object.type", "equals": "pull_request" },
    { "field": "action", "equals": "closed" },
    { "field": "pull_request.merged", "equals_bool": true }
  ],
  "auth": {
    "mode": "hmac",
    "secret_env": "GITHUB_SECRET",
    "header": "X-Hub-Signature-256",
    "prefix": "sha256="
  }
}

Authentication

auth.mode is one of:

auth field Required for Meaning
mode always hmac, token, or none.
secret_env hmac, token Name of the env var holding the secret/HMAC key.
header hmac, token Request header carrying the signature/token.
prefix no Literal prefix stripped before comparison (e.g. sha256=).

Secrets are read from the environment at startup and never written to disk or logged. serve fails closed: if a route’s secret_env is unset or empty, the listener refuses to start.

Delivering the payload to the command

The launched command receives the event two ways, both explicit and documented:

Maxx never interpolates untrusted payload content into the command line itself — pass the payload through these mechanisms and let the command decide what it means.

Responses and behavior

The listener answers with a tiny JSON body that never echoes the payload or any secret:

Status When
200 Launched ({"ok":true,"outcome":"launched","session_id":…}), suppressed duplicate ("outcome":"duplicate"), or filtered predicate mismatch ("outcome":"filtered").
401 Missing/invalid signature or an unknown route (the two are intentionally indistinguishable — see below).
405 Method is not POST (only returned once the caller is authenticated).
413 Body exceeds the route’s cap (post-auth) or the global read cap.
415 Content-Type is not application/json (post-auth).
422 A required template field was absent from the payload.
400 Payload is not valid JSON / not a supported event.
500 Server-side problem (e.g. a configured secret is unavailable).
502 The launch was attempted but the Control API rejected it.

Route privacy. Authentication runs before any route-specific rejection, and an unknown path returns the same 401 as a known path with a bad/missing signature. So an unauthenticated caller cannot enumerate configured routes (which may encode provider/project names or act as part of a tunnel secret); the finer-grained 405/415/413/200 codes appear only after the caller proves the route’s secret. Use +webhook validate to see your own routes.

Duplicate suppression. Webhook dedup keys on a per-delivery id read from a route-configured dedup_header (e.g. "dedup_header": "X-GitHub-Delivery" or "Linear-Delivery"), persisted in <control-dir>/webhook-seen.json (override with --state-file, disable globally with --no-dedup). A redelivery of the same delivery id returns 200 duplicate and launches nothing, so provider retries are safe — while distinct events for the same issue/PR still launch. Without a dedup_header (or when a request omits it) dedup is off and every request launches: the adapters set event.id to the object id (the issue/PR), so keying on it would wrongly drop later legitimate events for the same object. The store is bounded by count, age, and size. The new dedup entry is persisted before the launch is acknowledged; if the store cannot be written (e.g. disk-full), the launch still returns 200 (the tab exists) but carries a "warning":"dedup_not_persisted" and logs the failure, rather than silently acking a key that would not survive a restart.

Predicate filtering. A filtered request does not resolve templates, write MAXX_WEBHOOK_PAYLOAD_FILE, check or record dedup state, or send a Control API request. It still logs an activity line with outcome=filtered.

Logging. Each request logs one redacted line (method, path, status, outcome, source, event id, session id, error code) under the webhook scope. The body, secret, and signature are never logged.

Tunnels and relays

The listener is loopback-only by default. To receive provider webhooks, place a tunnel or relay in front of it — the event-to-command model does not change:

# ngrok
ngrok http 8787
# Cloudflare Tunnel
cloudflared tunnel --url http://127.0.0.1:8787
# Tailscale Funnel
tailscale funnel 8787

Point the provider’s webhook at https://<tunnel-host>/hooks/linear-issue and configure the provider to send the matching signature header. A relay service can likewise forward normalized events to the local listener; from Maxx’s side it is just another HTTP client that must present a valid signature.

Always keep a signature on a tunneled route. A tunnel exposes the listener to the public internet; an unauthenticated (none) route is for loopback testing only and is refused on non-loopback binds for exactly this reason.

Relationship to +runner and +connector