docs/book/src/channels/webhook.md
A generic inbound HTTP channel. Any service that can POST a JSON payload (GitHub, Linear, Sentry, Zapier, cron-job.org, your own scripts) can hand work to the agent via a webhook URL.
Webhooks live inside the gateway — if the gateway is running, webhooks are reachable at /webhook/<name>.
[channels.webhooks.github-issues]
enabled = true
secret = "..." # HMAC secret for signature verification
dispatch_to = "sop:triage-issue" # or a conversation ID, or a system prompt
allow_list = ["github.com"] # enforce source IP allowlist
[channels.webhooks.grafana]
enabled = true
secret = "..."
dispatch_to = "cron:alert-handler"
The dispatch_to field points the inbound event at one of:
sop:<name> — runs a Standard Operating Procedure with the payload as inputcron:<name> — triggers a scheduled job off-scheduleconversation:<id> — appends to a named conversation (agent responds normally)system:<prompt> — runs a one-shot agent call with the payload appended to a system promptGitHub, Stripe, Slack, and most major sources sign webhooks with HMAC. Set secret to the shared secret; the channel verifies X-Hub-Signature-256 / X-Signature / equivalent before dispatching.
Sources without signing (open webhooks) should at minimum set allow_list to restrict by source IP. A webhook URL with no auth and no IP allowlist is an open ingress — don't.
By default the agent receives the raw JSON payload as a user message. For structured sources, use template:
[channels.webhooks.sentry]
enabled = true
secret = "..."
dispatch_to = "sop:investigate-error"
template = """
New Sentry alert:
Level: {{ payload.level }}
Title: {{ payload.title }}
URL: {{ payload.web_url }}
Stack:
{{ payload.exception.values[0].stacktrace }}
"""
Templates use MiniJinja; payload is the full JSON body, plus headers and query for URL params.
Webhook channels default to one-shot: the agent handles the event, writes to the receipts log, and returns 200. For webhook sources that wait for a reply (interactive bots, slash commands), set reply_mode:
[channels.webhooks.slack-slash]
enabled = true
secret = "..."
reply_mode = "inline-json" # respond to POST with {"text": "..."}
template = "{{ payload.text }}"
Supported reply modes: status-only (default, 200 OK), inline-json, inline-text.
By default the gateway binds to localhost only. To expose webhooks publicly:
[tunnel] provider = "ngrok" | "cloudflare" | "tailscale" and restart the daemon. Tunnels start alongside the gateway.ZEROCLAW_ALLOW_PUBLIC_BIND=1 and [gateway] host = "0.0.0.0". Not recommended without a firewall in front.The webhook channel applies a per-webhook-name rate limit (default 10/s). Override:
[channels.webhooks.noisy]
rate_limit_per_sec = 50
Excess requests return 429.
crates/zeroclaw-channels/src/webhook.rs (plus gateway ingress in crates/zeroclaw-gateway/)crates/zeroclaw-runtime/src/templating.rs