Skip to main content

Webhooks

The webhook connector lets other software talk to your Claway agent over HTTP:

  • Ask the agent and get the answer back: n8n, Zapier, Make or your own code POSTs a message to the built-in agent route and receives the agent's reply, in the same HTTP response or later on a callback URL. Start with the built-in agent route and the n8n recipe.
  • Trigger agent runs from events: GitHub, GitLab, Stripe, monitoring tools and so on POST events to routes you configure; the agent processes each event and posts its answer to a PR, a chat, or the log. See Configuring routes.

Enable the connector​

On a Claway Box or hosted Claway: in OPS, open Connectors → Webhook, run the setup wizard and set the secret: at least 16 random characters (for example the output of openssl rand -hex 32). The wizard stores it as WEBHOOK_SECRET. Anyone holding the secret can run the agent, so treat it like a password.

Your webhook URL is then:

Where Claway runsURL of the built-in route
Claway Boxhttps://<box>/webhooks/agent
Hosted Clawayhttp://<server-ip>:8443/webhooks/agent
Your own install (claway gateway)http://<host>:8644/webhooks/agent

On a Box and on hosted Claway the address in front (port 443 on a Box, 8443 hosted) forwards /webhooks/ to the webhook connector on 127.0.0.1:8644 without the OPS login; the connector checks the secret on every call. While the connector is off, that address answers 503 {"error": "connector_not_enabled", ...}.

On your own install, set the same values in ~/.claway/.env (or run claway gateway setup) and start the gateway:

WEBHOOK_ENABLED=true
WEBHOOK_PORT=8644 # default; keep it on a Box or hosted server
WEBHOOK_SECRET=<16+ random characters>

Check that the connector is up (from the machine itself):

curl http://127.0.0.1:8644/health
# {"status": "ok", "platform": "webhook"}

The built-in agent route (n8n, Zapier, Make, your own apps)​

With WEBHOOK_SECRET set, the connector serves POST /webhooks/agent without any route configuration. It is made for HTTP clients that want the agent's answer back.

Method and URLPOST https://<box>/webhooks/agent (Box) or POST http://<server-ip>:8443/webhooks/agent (hosted)
AuthAuthorization: Bearer <WEBHOOK_SECRET>, or X-Webhook-Signature: <hex HMAC-SHA256 of the raw body, keyed with the secret>
BodyJSON. The prompt is the message field; prompt, text and chatInput (what n8n's Chat Trigger sends) also work. Any other JSON is shown to the agent as-is
AnswerThe request waits for the agent and returns 200 {"status": "completed", "response": "...", "route": "agent", "delivery_id": "..."}
Failure502 {"status": "failed", "error": "..."} if the run failed; 504 {"status": "timeout"} if it is still running after sync_timeout (default 600 s). The run carries on after a timeout; use a callback to get long answers
AsyncAdd "callback_url": "https://..." to the body. You get 202 {"status": "accepted", "reply": "callback", ...} at once, and Claway POSTs {"status": "completed", "response": "...", "route": "agent", "delivery_id": "..."} to that URL when the agent is done ("status": "failed" with "error" otherwise)
curl -k https://<box>/webhooks/agent \
-H "Authorization: Bearer $WEBHOOK_SECRET" \
-H "Content-Type: application/json" \
-d '{"message": "Summarise today'"'"'s open tickets"}'
{"status": "completed", "response": "There are 3 open tickets ...", "route": "agent", "delivery_id": "4f9c..."}

(-k accepts the Box's self-signed certificate. On hosted Claway, use the http://<server-ip>:8443/... URL and drop -k.)

The callback POST carries:

  • X-Webhook-Signature: hex HMAC-SHA256 of the callback body, keyed with your WEBHOOK_SECRET, so the receiver can check it came from your agent;
  • X-Claway-Delivery: the delivery_id.

A callback that fails or gets a 5xx answer is retried up to 3 times. A callback_url must be an absolute http:// or https:// URL (otherwise 400).

Things to know:

  • Each call is its own conversation. The agent does not remember earlier webhook calls. Put everything it needs into the message, or use the API server for multi-turn conversations.
  • Webhook runs have a limited toolset. A webhook payload is untrusted input, so by default a webhook-triggered run can search the web, read web pages and look at images, but has no terminal and no file tools.
  • Retries are de-duplicated. If you send an X-Request-ID header, a second call with the same value within one hour returns 200 {"status": "duplicate"} instead of running the agent again. Leave the header out, or make it unique per call.
  • Limits: 30 calls per minute per route and a 1 MB body by default (rate_limit and max_body_bytes, see Security).
  • Secret rules: a WEBHOOK_SECRET shorter than 16 characters, or a placeholder, leaves the route switched off (the OPS wizard refuses it). Turn the built-in route off with platforms.webhook.extra.agent_route: false.
  • Any route can opt in to the same reply behaviour with http_reply: true, and ?wait=1 on any route's URL makes that call wait for the answer.

n8n recipe​

Ask the agent and use the answer (synchronous)​

  1. Add an HTTP Request node:
    • Method: POST
    • URL: https://<box>/webhooks/agent (Box) or http://<server-ip>:8443/webhooks/agent (hosted)
    • Authentication: Generic Credential Type → Bearer Auth, token = your WEBHOOK_SECRET (or Header Auth with name Authorization and value Bearer <WEBHOOK_SECRET>)
    • Send Body: on, JSON, for example {"message": "{{ $json.text }}"}
    • Options → Ignore SSL Issues: on, for a Claway Box (its certificate is self-signed). Alternatively start n8n with NODE_EXTRA_CA_CERTS pointing at the Box certificate.
  2. The node's output is the JSON answer; the agent's text is in {{ $json.response }}.

The node waits until the agent has answered. For tasks that can take minutes, raise the node's Timeout option or use the asynchronous pattern below.

Long-running tasks (callback)​

  1. HTTP Request node as above, with the body {"message": "...", "callback_url": "{{ $execution.resumeUrl }}"}. It returns 202 immediately.
  2. Then a Wait node with Resume: On Webhook Call and HTTP Method: POST. When the agent is done, Claway POSTs the answer to the resume URL and the workflow continues; the answer is in {{ $json.body.response }}.

The resume URL must be reachable from the Box or hosted server: an n8n on your laptop that the server cannot reach never gets the callback.

Use the agent as an OpenAI model in n8n​

For n8n's OpenAI Chat Model or AI Agent nodes, use the API server instead: an OpenAI credential with Base URL https://<box>/v1 (Box) or http://<server-ip>:8443/v1 (hosted).

From Claway to n8n​

  • Ask the agent (in chat, the App or over the API server) to POST to an n8n Webhook trigger URL; it uses its terminal to do so. A plain-http:// n8n URL (n8n on the LAN) is held for approval by the terminal's security scan: approve it in the chat, or over the API with POST /v1/runs/<id>/approval {"choice": "once"}. An https:// n8n URL is not held.
  • Or send OPS events to n8n from OPS → Webhooks.
  • A webhook-triggered run has no terminal, so to hand an answer back to n8n from a webhook call, use the response or callback_url.

Configuring Routes​

Routes define how different webhook sources are handled. Each route is a named entry under platforms.webhook.extra.routes in your config.yaml.

Route properties​

PropertyRequiredDescription
eventsNoList of event types to accept (e.g. ["pull_request"]). If empty, all events are accepted. Event type is read from X-GitHub-Event, X-GitLab-Event, or event_type in the payload.
secretYesHMAC secret for signature validation. Falls back to the global secret if not set on the route. Set to "INSECURE_NO_AUTH" for testing only (skips validation).
promptNoTemplate string with dot-notation payload access (e.g. {pull_request.title}). If omitted, the full JSON payload is dumped into the prompt. Payload fields are untrusted — see Authenticated does not mean trusted.
skillsNoList of skill names to load for the agent run.
deliverNoWhere to send the response: github_comment, telegram, discord, slack, signal, sms, whatsapp, matrix, mattermost, homeassistant, email, dingtalk, feishu, wecom, weixin, bluebubbles, qqbot, or log (default).
deliver_extraNoAdditional delivery config — keys depend on deliver type (e.g. repo, pr_number, chat_id). Values support the same {dot.notation} templates as prompt.
deliver_onlyNoIf true, skip the agent entirely — the rendered prompt template becomes the literal message that gets delivered. Zero LLM cost, sub-second delivery. See Direct Delivery Mode for use cases. Requires deliver to be a real target (not log).

Full example​

platforms:
webhook:
enabled: true
extra:
port: 8644
secret: "global-fallback-secret"
routes:
github-pr:
events: ["pull_request"]
secret: "github-webhook-secret"
prompt: |
Review this pull request:
Repository: {repository.full_name}
PR #{number}: {pull_request.title}
Author: {pull_request.user.login}
URL: {pull_request.html_url}
Diff URL: {pull_request.diff_url}
Action: {action}
skills: ["github-code-review"]
deliver: "github_comment"
deliver_extra:
repo: "{repository.full_name}"
pr_number: "{number}"
deploy-notify:
events: ["push"]
secret: "deploy-secret"
prompt: "New push to {repository.full_name} branch {ref}: {head_commit.message}"
deliver: "telegram"

Prompt Templates​

Prompts use dot-notation to access nested fields in the webhook payload:

  • {pull_request.title} resolves to payload["pull_request"]["title"]
  • {repository.full_name} resolves to payload["repository"]["full_name"]
  • {__raw__} — special token that dumps the entire payload as indented JSON (truncated at 4000 characters). Useful for monitoring alerts or generic webhooks where the agent needs the full context.
  • Missing keys are left as the literal {key} string (no error)
  • Nested dicts and lists are JSON-serialized and truncated at 2000 characters

You can mix {__raw__} with regular template variables:

prompt: "PR #{pull_request.number} by {pull_request.user.login}: {__raw__}"

If no prompt template is configured for a route, the entire payload is dumped as indented JSON (truncated at 4000 characters).

The same dot-notation templates work in deliver_extra values.

Forum Topic Delivery​

When delivering webhook responses to Telegram, you can target a specific forum topic by including message_thread_id (or thread_id) in deliver_extra:

platforms:
webhook:
extra:
routes:
alerts:
events: ["alert"]
prompt: "Alert: {__raw__}"
deliver: "telegram"
deliver_extra:
chat_id: "-1001234567890"
message_thread_id: "42"

If chat_id is not provided in deliver_extra, the delivery falls back to the home channel configured for the target platform.


GitHub PR Review (Step by Step)​

This walkthrough sets up automatic code review on every pull request.

1. Create the webhook in GitHub​

  1. Go to your repository → Settings → Webhooks → Add webhook
  2. Set Payload URL to https://<box>/webhooks/github-pr (hosted: http://<server-ip>:8443/webhooks/github-pr; own install: http://<host>:8644/webhooks/github-pr)
  3. Set Content type to application/json
  4. Set Secret to match your route config (e.g. github-webhook-secret)
  5. Under Which events?, select Let me select individual events and check Pull requests
  6. Click Add webhook

2. Add the route config​

Add the github-pr route to your ~/.claway/config.yaml as shown in the example above.

3. Ensure gh CLI is authenticated​

The github_comment delivery type uses the GitHub CLI to post comments:

gh auth login

4. Test it​

Open a pull request on the repository. The webhook fires, Claway processes the event, and posts a review comment on the PR.


GitLab Webhook Setup​

GitLab webhooks work similarly but use a different authentication mechanism. GitLab sends the secret as a plain X-Gitlab-Token header (exact string match, not HMAC).

1. Create the webhook in GitLab​

  1. Go to your project → Settings → Webhooks
  2. Set the URL to https://<box>/webhooks/gitlab-mr (hosted: http://<server-ip>:8443/webhooks/gitlab-mr)
  3. Enter your Secret token
  4. Select Merge request events (and any other events you want)
  5. Click Add webhook

2. Add the route config​

platforms:
webhook:
enabled: true
extra:
routes:
gitlab-mr:
events: ["merge_request"]
secret: "your-gitlab-secret-token"
prompt: |
Review this merge request:
Project: {project.path_with_namespace}
MR !{object_attributes.iid}: {object_attributes.title}
Author: {object_attributes.last_commit.author.name}
URL: {object_attributes.url}
Action: {object_attributes.action}
deliver: "log"

Delivery Options​

The deliver field controls where the agent's response goes after processing the webhook event.

Deliver TypeDescription
logLogs the response to the gateway log output. This is the default and is useful for testing.
github_commentPosts the response as a PR/issue comment via the gh CLI. Requires deliver_extra.repo and deliver_extra.pr_number. The gh CLI must be installed and authenticated on the gateway host (gh auth login).
telegramRoutes the response to Telegram. Uses the home channel, or specify chat_id in deliver_extra.
discordRoutes the response to Discord. Uses the home channel, or specify chat_id in deliver_extra.
slackRoutes the response to Slack. Uses the home channel, or specify chat_id in deliver_extra.
signalRoutes the response to Signal. Uses the home channel, or specify chat_id in deliver_extra.
smsRoutes the response to SMS via Twilio. Uses the home channel, or specify chat_id in deliver_extra.
whatsappRoutes the response to WhatsApp. Uses the home channel, or specify chat_id in deliver_extra.
matrixRoutes the response to Matrix. Uses the home channel, or specify chat_id in deliver_extra.
mattermostRoutes the response to Mattermost. Uses the home channel, or specify chat_id in deliver_extra.
homeassistantRoutes the response to Home Assistant. Uses the home channel, or specify chat_id in deliver_extra.
emailRoutes the response to Email. Uses the home channel, or specify chat_id in deliver_extra.
dingtalkRoutes the response to DingTalk. Uses the home channel, or specify chat_id in deliver_extra.
feishuRoutes the response to Feishu/Lark. Uses the home channel, or specify chat_id in deliver_extra.
wecomRoutes the response to WeCom. Uses the home channel, or specify chat_id in deliver_extra.
weixinRoutes the response to Weixin (WeChat). Uses the home channel, or specify chat_id in deliver_extra.
bluebubblesRoutes the response to BlueBubbles (iMessage). Uses the home channel, or specify chat_id in deliver_extra.

For cross-platform delivery, the target platform must also be enabled and connected in the gateway. If no chat_id is provided in deliver_extra, the response is sent to that platform's configured home channel.


Direct Delivery Mode​

By default, every webhook POST triggers an agent run — the payload becomes a prompt, the agent processes it, and the agent's response is delivered. This costs LLM tokens on every event.

For use cases where you just want to push a plain notification — no reasoning, no agent loop, just deliver the message — set deliver_only: true on the route. The rendered prompt template becomes the literal message body, and the adapter dispatches it directly to the configured delivery target.

When to use direct delivery​

  • External service push — Supabase/Firebase webhook fires on a database change → notify a user in Telegram instantly
  • Monitoring alerts — Datadog/Grafana alert webhook → push to a Discord channel
  • Inter-agent pings — Agent A notifies Agent B's user that a long-running task finished
  • Background job completion — Cron job finishes → post result to Slack

Benefits:

  • Zero LLM tokens — the agent is never invoked
  • Sub-second delivery — a single adapter call, no reasoning loop
  • Same security as agent mode — HMAC auth, rate limits, idempotency, and body-size limits all still apply
  • Synchronous response — the POST returns 200 OK once delivery succeeds, or 502 if the target rejects it, so your upstream service can retry intelligently

Example: Telegram push from Supabase​

platforms:
webhook:
enabled: true
extra:
port: 8644
secret: "global-secret"
routes:
antenna-matches:
secret: "antenna-webhook-secret"
deliver: "telegram"
deliver_only: true
prompt: "🎉 New match: {match.user_name} matched with you!"
deliver_extra:
chat_id: "{match.telegram_chat_id}"

Your Supabase edge function signs the payload with HMAC-SHA256 and POSTs to https://<box>/webhooks/antenna-matches. The webhook adapter validates the signature, renders the template from the payload, delivers to Telegram, and returns 200 OK.

Example: Dynamic subscription via CLI​

claway webhook subscribe antenna-matches \
--deliver telegram \
--deliver-chat-id "123456789" \
--deliver-only \
--prompt "🎉 New match: {match.user_name} matched with you!" \
--description "Antenna match notifications"

Response codes​

StatusMeaning
200 OKDelivered successfully. Body: {"status": "delivered", "route": "...", "target": "...", "delivery_id": "..."}
200 OK (status=duplicate)Duplicate X-GitHub-Delivery ID within the idempotency TTL (1 hour). Not re-delivered.
401 UnauthorizedHMAC signature invalid or missing.
400 Bad RequestMalformed JSON body.
404 Not FoundUnknown route name.
413 Payload Too LargeBody exceeded max_body_bytes.
429 Too Many RequestsRoute rate limit exceeded.
502 Bad GatewayTarget adapter rejected the message or raised. The error is logged server-side; the response body is a generic Delivery failed to avoid leaking adapter internals.

Configuration gotchas​

  • deliver_only: true requires deliver to be a real target. deliver: log (or omitting deliver) is rejected at startup — the adapter refuses to start if it finds a misconfigured route.
  • The skills field is ignored in direct delivery mode (no agent runs, so there's nothing to inject skills into).
  • Template rendering uses the same {dot.notation} syntax as agent mode, including the {__raw__} token.
  • Idempotency uses the same X-GitHub-Delivery / X-Request-ID header — retries with the same ID return status=duplicate and do NOT re-deliver.

Dynamic Subscriptions (CLI)​

In addition to static routes in config.yaml, you can create webhook subscriptions dynamically using the claway webhook CLI command. This is especially useful when the agent itself needs to set up event-driven triggers.

Create a subscription​

claway webhook subscribe github-issues \
--events "issues" \
--prompt "New issue #{issue.number}: {issue.title}\nBy: {issue.user.login}\n\n{issue.body}" \
--deliver telegram \
--deliver-chat-id "-100123456789" \
--description "Triage new GitHub issues"

This returns the webhook URL and an auto-generated HMAC secret. Configure your service to POST to that URL.

List subscriptions​

claway webhook list

Remove a subscription​

claway webhook remove github-issues

Test a subscription​

claway webhook test github-issues
claway webhook test github-issues --payload '{"issue": {"number": 42, "title": "Test"}}'

How dynamic subscriptions work​

  • Subscriptions are stored in ~/.claway/webhook_subscriptions.json
  • The webhook adapter hot-reloads this file on each incoming request (mtime-gated, negligible overhead)
  • Static routes from config.yaml always take precedence over dynamic ones with the same name
  • Dynamic subscriptions use the same route format and capabilities as static routes (events, prompt templates, skills, delivery)
  • No gateway restart required — subscribe and it's immediately live

Agent-driven subscriptions​

The agent can create subscriptions via the terminal tool when guided by the webhook-subscriptions skill. Ask the agent to "set up a webhook for GitHub issues" and it will run the appropriate claway webhook subscribe command.


Security​

The webhook adapter includes multiple layers of security:

HMAC signature validation​

The adapter validates incoming webhook signatures using the appropriate method for each source:

  • GitHub: X-Hub-Signature-256 header — HMAC-SHA256 hex digest prefixed with sha256=
  • GitLab: X-Gitlab-Token header — plain secret string match
  • Generic: X-Webhook-Signature header — raw HMAC-SHA256 hex digest
  • Bearer: Authorization: Bearer <secret> — plain secret match (for HTTP clients without a signing step, such as n8n's HTTP Request node)

If a secret is configured but no recognized signature header is present, the request is rejected.

Secret is required​

Every route must have a secret — either set directly on the route or inherited from the global secret. Routes without a secret cause the adapter to fail at startup with an error. For development/testing only, you can set the secret to "INSECURE_NO_AUTH" to skip validation entirely.

INSECURE_NO_AUTH is only accepted when the gateway is bound to a loopback host (127.0.0.1, localhost, ::1). If it is combined with a non-loopback bind such as 0.0.0.0 or a LAN IP, the adapter refuses to start — this prevents accidentally exposing an unauthenticated endpoint on a public interface.

Rate limiting​

Each route is rate-limited to 30 requests per minute by default (fixed-window). Configure this globally:

platforms:
webhook:
extra:
rate_limit: 60 # requests per minute

Requests exceeding the limit receive a 429 Too Many Requests response.

Idempotency​

Delivery IDs (from X-GitHub-Delivery, svix-id, X-Request-ID, or a random per-request fallback) are cached for 1 hour. Duplicate deliveries (e.g. webhook retries) are silently skipped with a 200 response, preventing duplicate agent runs.

Body size limits​

Payloads exceeding 1 MB are rejected before the body is read. Configure this:

platforms:
webhook:
extra:
max_body_bytes: 2097152 # 2 MB

Authenticated does not mean trusted​

warning

HMAC validation authenticates the sender, not the content. A valid signature only proves the request came from a party holding the route's secret (e.g. GitHub). It says nothing about who wrote the business fields inside the payload — PR titles, commit messages, issue descriptions, and any other upstream text are authored by arbitrary third parties and must be treated as untrusted.

This is the same trust model that applies to everything the agent reads: web pages, files, and tool output are all untrusted input. Claway does not — and cannot reliably — sanitize untrusted text with a blocklist; phrasing, encoding, and translation make that trivially bypassable. The trust boundary is the agent's capability surface, not the input channel. Harden there:

  • Sandbox the runtime. Run the gateway with the Docker or SSH terminal backend (or in a VM) when exposed to the internet, so a hijacked turn cannot touch the host.
  • Scope the toolset. Disable terminal, file, and outbound-action tools on webhook-triggered sessions if the route only needs to read and summarize. Fewer capabilities means a smaller blast radius if a payload field carries injected instructions.
  • Keep approvals on for any destructive or outbound operation, so an injected instruction cannot act unattended.
  • Template narrowly. Prefer a specific prompt with named fields ({pull_request.title}) over {__raw__} or an empty template that dumps the whole payload, so only the fields you intend reach the prompt.

Troubleshooting​

Webhook not arriving​

  • On a Box or hosted server, a 503 {"error": "connector_not_enabled"} answer means the Webhook connector is off, or its port was changed from 8644: fix it in OPS → Connectors → Webhook
  • Verify the URL path matches: https://<box>/webhooks/<route-name> (hosted: http://<server-ip>:8443/webhooks/<route-name>; own install: http://<host>:8644/webhooks/<route-name>)
  • On your own install, check firewall rules: port 8644 (or your configured port) must be reachable from the webhook source
  • Use the /health endpoint on the machine (curl http://127.0.0.1:8644/health) to confirm the connector is running

Signature validation failing​

  • Ensure the secret in your route config exactly matches the secret configured in the webhook source
  • For GitHub, the secret is HMAC-based — check X-Hub-Signature-256
  • For GitLab, the secret is a plain token match — check X-Gitlab-Token
  • Check gateway logs for Invalid signature warnings

Event being ignored​

  • Check that the event type is in your route's events list
  • GitHub events use values like pull_request, push, issues (the X-GitHub-Event header value)
  • GitLab events use values like merge_request, push (the X-GitLab-Event header value)
  • If events is empty or not set, all events are accepted

Agent not responding​

  • Run the gateway in foreground to see logs: claway gateway run
  • Check that the prompt template is rendering correctly
  • Verify the delivery target is configured and connected

Duplicate responses​

  • The idempotency cache should prevent this — check that the webhook source is sending a delivery ID header (X-GitHub-Delivery or X-Request-ID)
  • Delivery IDs are cached for 1 hour

gh CLI errors (GitHub comment delivery)​

  • Run gh auth login on the gateway host
  • Ensure the authenticated GitHub user has write access to the repository
  • Check that gh is installed and on the PATH

Environment Variables​

VariableDescriptionDefault
WEBHOOK_ENABLEDEnable the webhook platform adapterfalse
WEBHOOK_PORTHTTP server port for receiving webhooks8644
WEBHOOK_SECRETGlobal secret (used as fallback when routes don't specify their own); 16+ characters also enables the built-in agent route(none)