OpenClaw Webhooks Guide: /hooks/agent vs /hooks/wake vs Mappings, and Why GitHub Needs a Relay
OpenClaw webhooks give an outside service three ways into your Gateway. Use POST /hooks/agent for anything carrying third-party content, because it starts a separate, safety-wrapped agent run and supports retries with an Idempotency-Key. Use POST /hooks/wake only for a short line of text you wrote yourself, and a mapping under hooks.mappings when the sender's payload shape is fixed and you can't change it.
The search results for this topic are the official Inbound webhooks page, its config reference, and three mirrors (docs.claw.so, a Hugging Face space, openclawlab.com). At least one mirror still says query-string tokens work with a deprecation warning. They don't. A current Gateway answers ?token= with a 400, even when a valid header rides along. I worked from the Inbound webhooks page and the hooks config reference as published on November 2, 2026.
This is HTTP ingress, separate from event hooks
OpenClaw uses the word "hooks" for two systems. The hooks guide covers HOOK.md handlers that react to things inside a session, configured under hooks.internal. This page covers the other hooks.* keys: an HTTP listener on the Gateway port (18789 by default) that lets a CI job, a monitoring tool or a form backend call in. Turning one on does nothing to the other.
The three endpoints compared
| Endpoint | Required field | What runs | Content treatment | Retry safety | Key trade-off |
|---|---|---|---|---|---|
POST /hooks/agent | message | A fresh isolated agent run (sessionMode: "isolated" by default) | Safety-wrapped as external content | Idempotency-Key replays the same run | Costs a full model turn per request; you choose the payload shape |
POST /hooks/wake | text | A system event queued on the agent's main session | Trusted. No wrapper | None; duplicate pending wakes coalesce | Cheap and lands in real context, but whatever you send is read as trusted |
POST /hooks/<name> with a template mapping | Whatever the mapping matches | An agent or wake action built from messageTemplate or textTemplate | Wrapped for agent actions unless allowUnsafeExternalContent is on | Agent actions replay; wake actions do not | Accepts a vendor's fixed JSON; admission-only, no waitForCompletion |
| Mapping with a JS/TS transform | Whatever the transform accepts | Zero, one or many actions; null returns 204 | Same as templates | Same as templates, if the transform is deterministic | Filters noise before the model sees it, but runs as trusted Gateway code outside any sandbox |
Turning it on
hooks: {
enabled: true,
token: "<long-random-hook-token>",
path: "/hooks",
allowedAgentIds: ["hook_reader"],
allowRequestSessionKey: false,
}Then openclaw config validate, openclaw gateway restart, and openclaw logs --follow in a second terminal.
One line there matters more than the rest. allowedAgentIds is unrestricted when you leave it out, which means any caller holding the token can name any agent you have, including main with its shell. Set it on day one. (An empty list denies everything, which is a handy kill switch.) The token can't be a SecretRef, and it should differ from gateway.auth.token; reuse it and openclaw security audit reports a critical finding. path can be anything except /.
If you write the token as ${HOOK_TOKEN} and the Gateway service can't see that variable, startup carries on with HTTP hooks switched off and one warning in the log. Your curl gets a 404, and you go looking at the path for twenty minutes. Check the warning first. A nice side effect of the env-var form is that the Gateway withholds that variable from agent exec children, so an agent's shell command can't simply echo the ingress secret.
/hooks/agent is the default I would pick
curl --include http://127.0.0.1:18789/hooks/agent \
-H 'Authorization: Bearer <long-random-hook-token>' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: deploy-4412' \
--data '{"message":"Deploy 4412 failed on step migrate. Log tail follows: ...",
"name":"CI failure","agentId":"hook_reader","deliver":false,
"waitForCompletion":true}'A plain 200 with a runId means admission. The run got a slot. It says nothing about whether the model finished or a message went out. Add waitForCompletion: true and the response stays open until the run settles, then returns a completion object with status (ok, error or skipped) and delivery facts. It never includes the model's output. For a CI step that needs a pass or fail, that is exactly enough.
Post-admission failures still come back as HTTP 200, with status: "error" inside. If your caller only checks the status code, it will report success for runs that crashed.
Two defaults surprise people. deliver is true, so with no destination the result lands as a completion event in the agent's main session, which means your main chat slowly fills with CI chatter. I set deliver: false on everything automated and read results from logs. And deliver: false stops the announcement only. An agent with a message tool can still post wherever it likes, so trim tools with the allow and deny lists if that matters.
Direct delivery needs both channel and to. Send only one, or channel: "last", and you get a 400 before anything dispatches. Direct hooks don't inherit the main session's last recipient, which I consider the correct paranoid choice.
Wake is for text you wrote
/hooks/wake takes a text string and drops it into the agent's main session as a system event. No isolation. No wrapper. The agent reads it with the same trust as its own system messages, sitting next to your real conversation. "Nightly backup finished, 41 GB" from your own cron script is a perfect wake. A GitHub issue title is a terrible one, because anyone on the internet can type "ignore prior instructions" into an issue title.
mode: "now" (the default) queues the event behind whatever the session is doing. The legacy next-heartbeat value now hands the event to a scheduled automation for that session and errors if none exists, so for delayed work use the cron and automations commands directly. A full queue returns 503 instead of dropping an older event. Back off and retry.
Mappings, for payloads you can't reshape
Most SaaS tools send their own JSON to a URL you give them, and you don't get to rename fields. A mapping matches the subpath (and optionally a source field in the payload) and builds an action from templates:
hooks: {
mappings: [{
id: "grafana-alerts",
match: { path: "grafana" },
action: "agent",
agentId: "hook_reader",
name: "Grafana {{status}}",
messageTemplate: "Alert {{title}} is {{status}}. Details: {{alerts}}",
deliver: false,
}],
}Templates read {{payload.field}} or the shorthand {{field}}, array indexes like {{alerts[0].labels}}, request headers, query values and {{now}}. Missing values render as empty strings, and objects serialize as JSON. That last part is how {{alerts}} above turns into something the model can read.
Mappings run in order and the first match owns the request, even if its transform returns null. A catch-all mapping with no match placed first will swallow every custom path you add later, Gmail included.
Transforms are the escape hatch. A module under ~/.openclaw/hooks/transforms receives { payload, headers, url, path } and returns an action, or null to skip. Dropping Grafana "resolved" notifications before they cost a model turn is the classic use. Keep in mind what a transform is: code running inside the Gateway process, with the Gateway's permissions, cached until the next hooks reload. Review it like you would review a plugin.
forEach fans one request out over a top-level array, capped at 200 items. Item 201 and beyond are dropped with a log warning and an HTTP success, so split big batches at the sender.
GitHub and Stripe need a relay in front
This is the gap nothing in the official pages spells out, and the reason I wrote this guide. OpenClaw authenticates hooks with a shared token in the Authorization: Bearer or x-openclaw-token header. GitHub webhooks can't send a custom header; you get a URL, a content type and a signing secret. Stripe is the same. The query-string workaround returns 400. And nothing under hooks.* checks a provider signature like GitHub's X-Hub-Signature-256.
So put a small relay between them. A Cloudflare Worker, a 30-line handler on a box you already run, whatever you like. It verifies the provider's HMAC against the raw body, drops anything that fails, trims the payload to the fields the agent needs, and POSTs to /hooks/agent with the Bearer header and the delivery ID as Idempotency-Key (X-GitHub-Delivery fits perfectly).
What I would avoid is a reverse proxy rule that just injects the Bearer header on every request to a public path. That turns your hook token into decoration, since anyone who finds the URL is now authenticated. Senders that can set headers (n8n, Zapier, your own scripts, most monitoring tools) can skip the relay and call the Gateway over your tailnet; the Tailscale guide covers keeping the port off the public internet.
Retries, and what Idempotency-Key actually buys you
A lost response is the normal failure for a webhook sender, and most senders retry. Send the same Idempotency-Key with the same token, path and payload, and the Gateway hands back the original runId (or the original completion, if you asked to wait) without running the agent twice. Keys are capped at 256 characters. After a run finishes, its replay entry lives for 5 minutes, inside a memory bound of 1,000 finished entries.
A Gateway restart wipes all of it.
That is fine for a GitHub retry that arrives 30 seconds later, and useless for a sender that retries hours later. Stripe keeps retrying failed deliveries for up to three days. If duplicates would hurt (opening a ticket, charging something), have the agent check for prior work too. And when you change the message text, the key no longer matches, by design, and you get a new run.
What each status code means
| Status | Usual cause | Retry? |
|---|---|---|
| 200 | Admitted. With waitForCompletion, settled; read completion.status | No |
| 204 | Mapping produced no actions (a null transform or empty forEach array) | No |
| 400 | Bad JSON, unknown agentId, session-key policy, half a destination, or a ?token= in the URL | Only after fixing the request |
| 401 / 429 | Wrong token; after 20 failures in 60 seconds the client is throttled, loopback included | After Retry-After, with the right token |
| 404 | Hooks disabled (often an unresolved token variable), wrong path, or no matching mapping | No |
| 408 / 413 | Body slower than 30 seconds, or larger than 256 KiB | Only with a smaller or faster body |
| 500 | Your transform threw (hook mapping failed in the log) | After fixing the transform |
| 503 | No admission within 15 seconds, a full wake queue, or a restarting Gateway | Yes, with the same Idempotency-Key |
The 503 has a cause people create on purpose. Requests that share a logical session key are processed one at a time, even in isolated mode, so setting hooks.defaultSessionKey to a fixed value lines every hook up behind the slowest run. One 40-second agent turn and the next request times out at 15. Leave the default unset unless you actually want ordering. The 15-second admission deadline is fixed, and timeoutSeconds on the turn does nothing to extend it.
For any 200 that seemed to do nothing, search openclaw logs --follow for hook agent run completed with the same runId. Successful runs log at info level, everything else at warn. The logging guide shows how to filter that stream with jq down to one run.
The reader agent is the real boundary
The hook token proves a caller knows a secret. It says nothing about who wrote the payload, and the safety wrapper on agent content doesn't remove a single tool. So the agent you name in allowedAgentIds should be built for hostile text: a sandboxed hook_reader with no workspace access and a short tool list, not main. The Gmail webhooks guide has a full mail_reader config that works for any hook source with the name changed, and the sandbox guide covers the container side. If you would rather start from a reviewed tool permission matrix and prompt-injection defenses for agents that read untrusted content than assemble them from scattered doc pages, the Security Hardening Kit is built for this step.
Where each option fits (and where it does not)
/hooks/agent fits CI failures, form submissions, support tickets, and anything else a stranger could have typed part of. It is the only endpoint with waitForCompletion, so it also fits callers that need an answer. It is overkill for a one-line status ping, since every request pays for a model turn.
/hooks/wake fits your own scripts reporting facts you control. It is the wrong tool for any field a third party wrote.
Template mappings fit vendors with a fixed payload that can at least send a header: Grafana, Uptime Kuma, n8n. They give up waitForCompletion and can't pick a channel account.
Transforms earn their risk when most incoming events should never reach the model. For two or three event types, a template plus a sentence in the agent's instructions is less code to trust.
A relay is required for GitHub, Stripe and any sender that only signs requests. Sometimes skipping webhooks wins. A scheduled job that checks a repo with the GitHub skill every 15 minutes needs no public endpoint at all, and for a low-volume repo I think that is the better trade.
Final verdict
Send untrusted payloads to /hooks/agent with deliver: false, an Idempotency-Key, and an agentId that points at a restricted reader listed in allowedAgentIds. Keep /hooks/wake for text you wrote. Reach for mappings when the vendor's JSON is fixed, and put a signature-checking relay in front of GitHub and Stripe instead of a header-injecting proxy rule. Then judge success by the completion log line for the runId, since the 200 only tells you the run got in the door.
Ready to build?
Get the OpenClaw Starter Kit — config templates, 5 production-ready skills, deployment checklist. Go from zero to running in under an hour.
$14 $6.99
Get the Starter Kit →Also in the OpenClaw store
Get the free OpenClaw quickstart guide
Step-by-step setup. Plain English. No jargon.