website/docs/user-guide/messaging/webhooks.md
Receive events from external services (GitHub, GitLab, JIRA, Stripe, etc.) and trigger Hermes agent runs automatically. The webhook adapter runs an HTTP server that accepts POST requests, validates HMAC signatures, transforms payloads into agent prompts, and routes responses back to the source or to another configured platform.
The agent processes the event and can respond by posting comments on PRs, sending messages to Telegram/Discord, or logging the result.
hermes gateway setup or environment variablesconfig.yaml or create them dynamically with hermes webhook subscribehttp://your-server:8644/webhooks/<route-name>There are two ways to enable the webhook adapter.
hermes gateway setup
Follow the prompts to enable webhooks, set the port, and set a global HMAC secret.
Add to ~/.hermes/.env:
WEBHOOK_ENABLED=true
WEBHOOK_PORT=8644 # default
WEBHOOK_SECRET=your-global-secret
Once the gateway is running:
curl http://localhost:8644/health
Expected response:
{"status": "ok", "platform": "webhook"}
Routes define how different webhook sources are handled. Each route is a named entry under platforms.webhook.extra.routes in your config.yaml.
| Property | Required | Description |
|---|---|---|
events | No | List 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. |
secret | Yes | HMAC 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). |
prompt | No | Template string with dot-notation payload access (e.g. {pull_request.title}). If omitted, the full JSON payload is dumped into the prompt. |
skills | No | List of skill names to load for the agent run. |
deliver | No | Where 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_extra | No | Additional delivery config — keys depend on deliver type (e.g. repo, pr_number, chat_id). Values support the same {dot.notation} templates as prompt. |
deliver_only | No | If 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). |
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"
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.{key} string (no error)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.
When delivering webhook responses to Telegram, you can target a specific forum topic by including message_thread_id (or thread_id) in deliver_extra:
webhooks:
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.
This walkthrough sets up automatic code review on every pull request.
http://your-server:8644/webhooks/github-prapplication/jsongithub-webhook-secret)Add the github-pr route to your ~/.hermes/config.yaml as shown in the example above.
gh CLI is authenticatedThe github_comment delivery type uses the GitHub CLI to post comments:
gh auth login
Open a pull request on the repository. The webhook fires, Hermes processes the event, and posts a review comment on the PR.
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).
http://your-server:8644/webhooks/gitlab-mrplatforms:
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"
The deliver field controls where the agent's response goes after processing the webhook event.
| Deliver Type | Description |
|---|---|
log | Logs the response to the gateway log output. This is the default and is useful for testing. |
github_comment | Posts 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). |
telegram | Routes the response to Telegram. Uses the home channel, or specify chat_id in deliver_extra. |
discord | Routes the response to Discord. Uses the home channel, or specify chat_id in deliver_extra. |
slack | Routes the response to Slack. Uses the home channel, or specify chat_id in deliver_extra. |
signal | Routes the response to Signal. Uses the home channel, or specify chat_id in deliver_extra. |
sms | Routes the response to SMS via Twilio. Uses the home channel, or specify chat_id in deliver_extra. |
whatsapp | Routes the response to WhatsApp. Uses the home channel, or specify chat_id in deliver_extra. |
matrix | Routes the response to Matrix. Uses the home channel, or specify chat_id in deliver_extra. |
mattermost | Routes the response to Mattermost. Uses the home channel, or specify chat_id in deliver_extra. |
homeassistant | Routes the response to Home Assistant. Uses the home channel, or specify chat_id in deliver_extra. |
email | Routes the response to Email. Uses the home channel, or specify chat_id in deliver_extra. |
dingtalk | Routes the response to DingTalk. Uses the home channel, or specify chat_id in deliver_extra. |
feishu | Routes the response to Feishu/Lark. Uses the home channel, or specify chat_id in deliver_extra. |
wecom | Routes the response to WeCom. Uses the home channel, or specify chat_id in deliver_extra. |
weixin | Routes the response to Weixin (WeChat). Uses the home channel, or specify chat_id in deliver_extra. |
bluebubbles | Routes 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.
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.
Benefits:
200 OK once delivery succeeds, or 502 if the target rejects it, so your upstream service can retry intelligentlyplatforms:
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://your-server:8644/webhooks/antenna-matches. The webhook adapter validates the signature, renders the template from the payload, delivers to Telegram, and returns 200 OK.
hermes 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"
| Status | Meaning |
|---|---|
200 OK | Delivered 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 Unauthorized | HMAC signature invalid or missing. |
400 Bad Request | Malformed JSON body. |
404 Not Found | Unknown route name. |
413 Payload Too Large | Body exceeded max_body_bytes. |
429 Too Many Requests | Route rate limit exceeded. |
502 Bad Gateway | Target 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. |
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.skills field is ignored in direct delivery mode (no agent runs, so there's nothing to inject skills into).{dot.notation} syntax as agent mode, including the {__raw__} token.X-GitHub-Delivery / X-Request-ID header — retries with the same ID return status=duplicate and do NOT re-deliver.In addition to static routes in config.yaml, you can create webhook subscriptions dynamically using the hermes webhook CLI command. This is especially useful when the agent itself needs to set up event-driven triggers.
hermes 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.
hermes webhook list
hermes webhook remove github-issues
hermes webhook test github-issues
hermes webhook test github-issues --payload '{"issue": {"number": 42, "title": "Test"}}'
~/.hermes/webhook_subscriptions.jsonconfig.yaml always take precedence over dynamic ones with the same nameThe 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 hermes webhook subscribe command.
The webhook adapter includes multiple layers of security:
The adapter validates incoming webhook signatures using the appropriate method for each source:
X-Hub-Signature-256 header — HMAC-SHA256 hex digest prefixed with sha256=X-Gitlab-Token header — plain secret string matchX-Webhook-Signature header — raw HMAC-SHA256 hex digestIf a secret is configured but no recognized signature header is present, the request is rejected.
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.
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.
Delivery IDs (from X-GitHub-Delivery, X-Request-ID, or a timestamp fallback) are cached for 1 hour. Duplicate deliveries (e.g. webhook retries) are silently skipped with a 200 response, preventing duplicate agent runs.
Payloads exceeding 1 MB are rejected before the body is read. Configure this:
platforms:
webhook:
extra:
max_body_bytes: 2097152 # 2 MB
:::warning Webhook payloads contain attacker-controlled data — PR titles, commit messages, issue descriptions, etc. can all contain malicious instructions. Run the gateway in a sandboxed environment (Docker, VM) when exposed to the internet. Consider using the Docker or SSH terminal backend for isolation. :::
8644 (or your configured port) must be openhttp://your-server:8644/webhooks/<route-name>/health endpoint to confirm the server is runningX-Hub-Signature-256X-Gitlab-TokenInvalid signature warningsevents listpull_request, push, issues (the X-GitHub-Event header value)merge_request, push (the X-GitLab-Event header value)events is empty or not set, all events are acceptedhermes gateway runX-GitHub-Delivery or X-Request-ID)gh CLI errors (GitHub comment delivery)gh auth login on the gateway hostgh is installed and on the PATH| Variable | Description | Default |
|---|---|---|
WEBHOOK_ENABLED | Enable the webhook platform adapter | false |
WEBHOOK_PORT | HTTP server port for receiving webhooks | 8644 |
WEBHOOK_SECRET | Global HMAC secret (used as fallback when routes don't specify their own) | (none) |