Using Hookdeck with Hermes: Reliable Webhooks for Your AI Agent
Hermes is Nous Research's self-hosted AI agent. It keeps persistent memory across sessions, creates its own reusable skills, and talks to you over Telegram, Discord, Slack, WhatsApp, Signal, or your terminal through a single gateway process. Its webhook adapter extends that reach further: any service that can send an HTTP request (GitHub, GitLab, Supabase, a cron job, your own application) becomes a trigger for your agent.
Because Hermes is self-hosted, though, its webhook endpoint is only as reliable as the machine and process it runs on. The gateway is a single process on your server or home machine. If it's restarting, crashed, or unreachable, webhooks sent during that window get connection errors and are gone. Most providers won't try again. Add rate limits that drop bursts, deduplication that only covers GitHub, and log-file-only visibility into what arrived, and you have a set of rough edges that show up exactly when you start depending on your agent.
This guide covers how Hermes webhooks work, how to put the Hookdeck Event Gateway in front of them, and how doing so addresses the most common gaps. The Event Gateway sits between your webhook producers and your Hermes gateway, giving you a secure tunnel (no port exposure), persistent URLs, durable queuing with automatic retries, deduplication for every provider, and full observability.
Understanding Hermes' webhook system
Hermes treats webhooks as one of its messaging platforms: the webhook adapter runs inside the same gateway process as Telegram, Discord, and the rest. Before configuring anything, it's worth understanding two distinctions.
Static routes vs. dynamic subscriptions. Routes can be defined statically in ~/.hermes/config.yaml under platforms.webhook.extra.routes, or created on the fly with the hermes webhook subscribe CLI command (stored in ~/.hermes/webhook_subscriptions.json and hot-reloaded on each request). Static routes always override dynamic ones with the same name.
Agent mode vs. direct delivery. By default, a webhook triggers an agent run: the payload is rendered into a prompt template and handed to the model, which can load skills and act. With deliver_only: true, Hermes skips the agent entirely, renders the template, and delivers the message straight to a platform like Telegram. Direct delivery is sub-second and costs no LLM tokens, which makes it a good fit for notification-style webhooks.
Hermes also has an A2A (agent-to-agent) endpoint, which is a separate mechanism for agents calling other agents. This guide covers the webhook adapter, meaning external services triggering your agent.
How Hermes webhooks work
Enabling the webhook adapter
You can enable webhooks through the interactive wizard (hermes gateway setup), environment variables in ~/.hermes/.env:
WEBHOOK_ENABLED=true
WEBHOOK_PORT=8644 # default
WEBHOOK_SECRET=your-global-secret
Or directly in ~/.hermes/config.yaml:
platforms:
webhook:
enabled: true
extra:
port: 8644
secret: "global-fallback-secret"
Confirm the adapter is up with a health check:
curl http://localhost:8644/health
# {"status": "ok", "platform": "webhook"}
Authentication
Every route must have a secret, either set directly on the route or inherited from the global secret. Routes without one cause the adapter to fail at startup. Hermes verifies incoming requests using one of four schemes:
| Scheme | Header(s) | Notes |
|---|---|---|
| GitHub | X-Hub-Signature-256 | HMAC-SHA256, sha256=<hex> format |
| GitLab | X-Gitlab-Token | Plain token comparison |
| Generic V2 (recommended) | X-Webhook-Signature-V2 + X-Webhook-Timestamp | HMAC-SHA256 of <timestamp>.<body>; timestamp must be within ±300 seconds |
| Generic V1 (legacy) | X-Webhook-Signature | HMAC-SHA256, no replay protection |
For local development only, a route secret of INSECURE_NO_AUTH disables verification, and Hermes restricts this to loopback hosts.
Routes and endpoints
Each route gets its own URL:
http://your-server:8644/webhooks/<route-name>
A route defines what the webhook does, combining event filters, a prompt template with {dot.notation} access into the payload, skills to load, and a delivery target:
platforms:
webhook:
enabled: true
extra:
port: 8644
routes:
github-pr:
events: ["pull_request"]
secret: "github-webhook-secret"
prompt: |
Review this pull request:
Repository: {repository.full_name}
PR #{number}: {pull_request.title}
URL: {pull_request.html_url}
skills: ["github-code-review"]
deliver: "github_comment"
deliver_extra:
repo: "{repository.full_name}"
pr_number: "{number}"
Delivery targets include github_comment, telegram, discord, slack, signal, whatsapp, sms, email, matrix, mattermost, and log (the default, useful for testing). Declarative filters and Python filter scripts let you drop or transform payloads before the agent sees them.
Dynamic subscriptions do the same from the CLI:
hermes webhook subscribe github-issues \
--events "issues" \
--prompt "New issue #{issue.number}: {issue.title}" \
--deliver telegram \
--deliver-chat-id "-100123456789"
hermes webhook list
hermes webhook test github-issues
hermes webhook remove github-issues
Response codes
| Status | Meaning |
|---|---|
| 200 | Delivered, or a duplicate X-GitHub-Delivery ID within the 1-hour cache (silently skipped) |
| 400 | Malformed JSON body |
| 401 | Invalid or missing signature |
| 404 | Unknown route name |
| 413 | Body exceeded max_body_bytes (default 1 MB) |
| 429 | Rate limit exceeded (default 30 requests/minute per route) |
| 502 | Delivery target rejected the message |
Security recommendations
The Hermes docs are clear that HMAC validation authenticates the sender, not the content: webhook payloads are untrusted input to your agent, so template narrowly using specific fields like {pull_request.title} rather than dumping {__raw__} into the prompt. When the gateway is exposed to the internet, run it in a Docker or VM sandbox, disable terminal, file, and outbound tools on webhook routes that don't need them, and keep approvals on for destructive operations.
Setting up the Event Gateway with Hermes
Instead of pointing GitHub, Supabase, or any other producer directly at your Hermes server, you point them at the Event Gateway. It receives and verifies each webhook, persists it, and forwards it to Hermes — through a CLI tunnel if your gateway isn't publicly reachable, or over HTTPS if it is. Setup takes about five minutes.
If you'd rather not wire this up by hand, there's now an official Hookdeck plugin for Hermes: hermes-hookdeck replaces the built-in webhook ingestion with the Event Gateway and adds a restart-safe run ledger, operator commands (hermes hookdeck status, pause, retry, doctor), and agent tools for triaging failed deliveries. See the announcement for details. The manual setup below is still worth understanding — it's what the plugin automates.
Step 1: Install the Hookdeck CLI
macOS:
brew install hookdeck
Linux, Windows (Scoop), npm, and Docker installs are covered in the CLI docs.
Step 2: Authenticate
hookdeck login
This opens a browser to sign in or create a free account. For headless servers or CI, use hookdeck ci --api-key $HOOKDECK_API_KEY.
Step 3: Create connections
Create one connection per Hermes route. For a GitHub route, use the GitHub source type so the Event Gateway verifies GitHub's HMAC signature upstream:
hookdeck gateway connection upsert hermes-github \
--source-name hermes-github --source-type GITHUB \
--source-webhook-secret "$GITHUB_WEBHOOK_SECRET" \
--destination-name cli-hermes --destination-type CLI \
--destination-cli-path /webhooks/github-pr
For any other producer, a generic webhook source works:
hookdeck gateway connection upsert hermes-supabase \
--source-name hermes-supabase --source-type WEBHOOK \
--destination-name cli-hermes --destination-type CLI \
--destination-cli-path /webhooks/antenna-matches
The Event Gateway forwards the original request headers and body, so Hermes' own signature verification (X-Hub-Signature-256, X-Gitlab-Token, or a generic signature) still passes at the route level. You get verification at both layers.
Step 4: Start listening
hookdeck listen 8644 '*'
This opens a WebSocket tunnel from the Event Gateway to your local Hermes webhook adapter on port 8644, without opening a port or requiring a public IP. The CLI prints a persistent public URL per source:
Dashboard
👉 Inspect and replay events: https://dashboard.hookdeck.com/cli/events
Sources
👉 hermes-github URL: https://hkdk.events/xxxxxxxxxxxx
👉 hermes-supabase URL: https://hkdk.events/yyyyyyyyyyyy
Connections
hermes-github -> cli-hermes forwarding to /webhooks/github-pr
hermes-supabase -> cli-hermes forwarding to /webhooks/antenna-matches
If your Hermes gateway runs as a systemd service on a server with a stable public HTTPS endpoint, you can skip the CLI tunnel in production and create the connection with an HTTP destination (--destination-type HTTP --destination-url https://hermes.example.com/webhooks/github-pr) instead. The queuing, retry, and observability behaviour is identical; only the last hop changes.
Step 5: Register the URLs with your providers
Use the hkdk.events URLs anywhere you would have used your server's address. For the GitHub example, go to your repository's Settings > Webhooks and set the payload URL to https://hkdk.events/xxxxxxxxxxxx, the content type to application/json, and the secret to the same value as your route's secret. The URLs are persistent: if you move Hermes to a new machine, re-run hookdeck listen there and every registration keeps working.
Common CLI commands
| Command | Purpose |
|---|---|
hookdeck login | Authenticate the CLI with your Hookdeck account |
hookdeck listen 8644 '*' | Tunnel all sources to the Hermes webhook adapter |
hookdeck listen 8644 hermes-github | Tunnel a single source |
hookdeck gateway connection upsert | Create or update a connection |
hookdeck whoami | Show the active project |
hookdeck ci --api-key $KEY | Authenticate non-interactively on servers/CI |
Hermes webhook limitations and how the Event Gateway helps
Hermes' webhook adapter is well designed for a self-hosted agent: per-route secrets, HMAC verification, rate limits, and payload filtering are all there. The gaps are the ones inherent to any single self-hosted process receiving webhooks from the internet.
Webhooks sent during restarts and crashes are lost
The problem: When the gateway process is down (a hermes update, a config change restart, a crash, a server reboot), port 8644 stops answering. Providers sending webhooks during that window get connection errors, and most won't retry: GitHub, for example, attempts delivery once and only redelivers if you notice and click redeliver manually.
Why it happens: The webhook adapter lives inside the single gateway process, and there's no durable inbound queue in front of it. Hermes' delivery ledger (state.db) is solid engineering, but it protects the other direction: agent replies that were mid-send when the gateway died get redelivered on boot with a "Recovered reply" prefix. Inbound webhooks that never reached the process have nothing to recover. Restart reliability is also an active area of development: issues like stale gateway.pid restart loops and hermes gateway restart failing from a crashed state mean downtime windows can be longer than a clean restart.
Workarounds: Install the gateway as a service (hermes gateway install) with sudo loginctl enable-linger $USER, enable the systemd watchdog (systemd_watchdog_seconds: 120), and batch config changes to minimise restarts. On WSL2, where systemd is unreliable, the Hermes FAQ suggests tmux or nohup, which helps the process stay up but does nothing for webhooks that arrive while it's down.
With the Event Gateway: Webhooks are received and persisted to durable storage before the producer gets its 200 OK. If Hermes is unreachable, delivery is retried automatically with configurable backoff, up to 50 attempts by default, until the gateway is back. Anything that exhausts retries stays in the dashboard, where you can replay it with one click. A restart becomes a delivery delay instead of data loss.
Exposing a self-hosted agent to the internet
The problem: For providers to reach Hermes directly, your machine needs a public address. For a home server, that means port forwarding, dynamic DNS, and an open port that portscanners will find, pointed at a process that can execute terminal commands. The Hermes FAQ's guidance for webhook-based platforms is simply to "ensure your server is publicly accessible."
Why it happens: Self-hosting is the point of Hermes; your memory and data stay in ~/.hermes/ on your machine. But self-hosting also means you own the network exposure problem that hosted platforms solve for you.
Workarounds: A reverse proxy with TLS on a VPS, a Cloudflare Tunnel, or a tailnet. Each is another piece of infrastructure to set up and maintain, and tailnets don't help when the sender is GitHub rather than one of your own devices.
With the Event Gateway: hookdeck listen establishes an outbound WebSocket connection from your machine, so there are no inbound ports to open at all. Providers only ever see the hkdk.events URL. Your Hermes gateway stays entirely private while remaining reachable by any webhook producer on the internet, and the URL survives moves between machines and networks.
Rate limiting turns bursts into drops
The problem: The webhook adapter enforces 30 requests/minute per route by default, answering excess requests with 429. Bursts are normal webhook behaviour: push a branch with a dozen commits, run a bulk update in Supabase, or receive a provider's retry sweep, and you can clear 30 events in seconds. Whether a provider retries on 429 varies; many treat it like any other failure, and GitHub doesn't retry at all.
Why it happens: The rate limit is a sensible defence for a process that feeds an LLM, where each webhook costs real tokens and compute. The limit protects Hermes, but the excess traffic has nowhere to go.
Workarounds: Raise rate_limit in the route config (which trades away the protection), or use deliver_only routes where the work is light.
With the Event Gateway: Set a maximum delivery rate on the connection, and the Event Gateway absorbs the burst into its queue and forwards events to Hermes at a pace it can handle. The burst is smoothed out rather than truncated, and nothing hits the 429 path. Your Hermes rate limit can stay strict as a second line of defence.
Deduplication only covers GitHub
The problem: Hermes caches X-GitHub-Delivery IDs for one hour and silently skips duplicates, but only for GitHub. Every other producer's retries and redeliveries reach your agent as fresh events. An agent-mode route means paying twice for the same LLM run; a deliver_only route means your Telegram gets the same notification twice; a route that comments on PRs comments twice.
Why it happens: General-purpose deduplication needs per-provider knowledge of which header or field identifies an event, plus a shared cache. Hermes implemented it for the provider where it matters most and left the general case open.
Workarounds: Add idempotency logic in a filter script (track seen event IDs in a file and print [SILENT] for repeats), or write prompts defensively so duplicate runs are less costly.
With the Event Gateway: Deduplication rules work for any source. Match on the full payload within a time window, on a provider's unique event ID header, or on a composite key built from payload fields for producers without one:
{
"type": "deduplicate",
"window": 300000,
"include_fields": ["headers.x-gitlab-event-uuid"]
}
Windows range from 1 second to 1 hour, and duplicates are filtered before they ever reach Hermes, so there are no filter scripts to write and no token spend on repeat runs.
Limited webhook observability
The problem: When your agent doesn't respond to an event, the question "did the webhook arrive, get filtered, fail verification, or never get sent?" has no single place to find an answer. Visibility into the webhook adapter is journalctl --user -u hermes-gateway -f plus whatever the provider's delivery log shows, and correlating the two by timestamp is the debugging experience.
Why it happens: Hermes' observability (gateway logs, the web dashboard, hermes doctor) is built around agent sessions and platform health, not webhook delivery infrastructure. That's a reasonable scope for the project; it just leaves webhook debugging to the logs.
Workarounds: Set deliver: log temporarily to confirm a route fires, use hermes webhook test <route> to validate templates and filters locally, and check the provider's delivery dashboard for its view of each attempt.
With the Event Gateway: Every request, event, and delivery attempt is logged and searchable in the dashboard. Click into any event to see the headers and body received, the response Hermes returned, and each retry. Recurring failures automatically open Issues (with notifications), so a route that starts 401-ing after a secret rotation pings you before you notice the silence.
Signature verification stops at four schemes
The problem: Hermes verifies GitHub, GitLab, and its two generic HMAC formats. Producers that sign differently (Stripe's Stripe-Signature, Shopify's X-Shopify-Hmac-Sha256, Twilio, Svix-style signatures) can't be verified natively, leaving you to choose between the generic scheme (if the producer lets you customise headers, which most don't) or a shared secret in the URL path with no cryptographic verification. There's also a subtle interaction: Generic V2's ±300-second timestamp window is good replay protection, but it means any legitimately delayed delivery, such as a provider's retry sweep an hour later, fails verification.
Why it happens: Supporting every provider's signature scheme is a large, ongoing maintenance surface. Four schemes cover Hermes' primary use cases.
Workarounds: Terminate provider-specific verification in your own middleware (a small proxy that verifies, then re-signs with Generic V2), or accept unverified payloads on trusted-network routes.
With the Event Gateway: Source verification is pre-configured for 160+ providers, including Stripe, Shopify, Twilio, GitLab, and Linear, so each producer's signature is checked upstream with its native scheme. Verified events are then forwarded to Hermes carrying their original headers, where your route secret provides the second layer. You get defence in depth without waiting for adapter support for each new provider.
Get started
You don't need a paid plan (or, to begin with, an account) to try any of this. The Hookdeck Console gives you a webhook URL to inspect requests with no signup, which is a quick way to see exactly what GitHub or Supabase sends before writing your Hermes route templates. The CLI works with a temporary guest session, so hookdeck listen 8644 is a single command away from a tunnelled, replayable webhook setup.
When your agent graduates from experiment to something you rely on, create a free account so your event history, connections, and URLs persist, and follow the receiving webhooks quickstart to move to a production configuration.