# 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](/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`:

```bash
WEBHOOK_ENABLED=true
WEBHOOK_PORT=8644        # default
WEBHOOK_SECRET=your-global-secret

```

Or directly in `~/.hermes/config.yaml`:

```yaml
platforms:
  webhook:
    enabled: true
    extra:
      port: 8644
      secret: "global-fallback-secret"

```

Confirm the adapter is up with a health check:

```bash
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:

```yaml
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:

```bash
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](https://github.com/hookdeck/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](/blog/hookdeck-for-hermes-agent) for details. The manual setup below is still worth understanding — it's what the plugin automates.

### Step 1: Install the Hookdeck CLI

macOS:

```bash
brew install hookdeck

```

Linux, Windows (Scoop), npm, and Docker installs are covered in the [CLI docs](/docs/cli).

### Step 2: Authenticate

```bash
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:

```bash
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:

```bash
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

```bash
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.

### 1. 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](https://github.com/NousResearch/hermes-agent/issues/13655) and [`hermes gateway restart` failing from a crashed state](https://github.com/NousResearch/hermes-agent/issues/12438) 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](/docs/retries) 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.

### 2. 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.

### 3. 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](/docs/destinations#set-a-max-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.

### 4. 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](/docs/deduplication) 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:

```json
{
  "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.

### 5. 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](/docs/issues) (with notifications), so a route that starts 401-ing after a secret rotation pings you before you notice the silence.

### 6. 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](/docs/guides/how-to-verify-incoming-webhooks) 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](https://console.hookdeck.com/) 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](https://dashboard.hookdeck.com/signup) so your event history, connections, and URLs persist, and follow the [receiving webhooks quickstart](/docs/use-cases/receive-webhooks/quickstart) to move to a production configuration.