Inbound Webhooks: Wake an Agent from an External Service
Learn how Gateway HTTP hooks let an outside service wake an agent or submit a turn to OpenClaw. This page covers enabling, testing, and securing inbound agent hooks.
Read this when
- Letting an external service call OpenClaw over HTTP
- Enabling, authenticating, and smoke-testing hook endpoints
- Debugging a hook request status code
Gateway HTTP hooks: how an outside service calls OpenClaw to wake an agent or hand it a turn to run. This page is part of the Automations guide.
Webhooks
An external service can use Gateway HTTP hooks to wake an agent or submit an agent turn. They are off unless you enable them. These endpoints are not the same as internal event hooks (HOOK.md handlers). They also differ from webhook delivery used for outbound automation: in this case, it is the external service that calls OpenClaw.
Enable and test an agent hook
Before you begin, you need a Gateway that is running and an agent able to complete a normal turn. Merge the following into your config, swapping the token for a long random value and main for the agent you configured:
{
hooks: {
enabled: true,
token: "<long-random-hook-token>",
path: "/hooks",
allowedAgentIds: ["main"],
allowRequestSessionKey: false,
},
}
Pick a token reserved for hooks; do not reuse the Gateway auth token or password. On the Gateway host, run the commands below with its profile/config. Check that the configuration validates, restart the installed service so it picks up the change, and keep an eye on the logs:
openclaw config validate
openclaw gateway restart
openclaw logs --follow
Should you run the Gateway in the foreground instead of as an installed service, stop and start that process instead.
From a second terminal, fire a harmless test at the local Gateway. Adjust the token, agent id, and port to match your setup:
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: webhook-smoke-001' \
--data '{"message":"Summarize this test event: the sample import completed.","name":"Webhook smoke test","agentId":"main","deliver":false}'
HTTP 200 is the admission response you should get:
{ "ok": true, "runId": "<hook-request-run-id>" }
What this tells you is that the run obtained session/global placement admission. It does not mean the model finished, a tool succeeded, or a message was delivered. A single agent request may wait up to 15 seconds for admission, and the model runtime may still be preparing when the response comes back.
If a caller needs the terminal execution and delivery facts in the same request, add "waitForCompletion": true to the direct /hooks/agent payload. After admission the response stays open, then returns HTTP 200 once the admitted run settles:
{
"ok": true,
"runId": "<hook-request-run-id>",
"completion": {
"status": "ok",
"replyDisposition": "silent",
"delivered": false,
"deliveryAttempted": true,
"deliverySuppressionReason": "silent"
}
}
replyDisposition records whether the model's terminal reply was visible, silent, or empty, without exposing its text. Execution or delivery failures that happen after admission are terminal data in completion, not retryable HTTP failures. When present, deliveryError is the fixed categorical value "delivery-failed"; provider, runtime, model, target, session, and diagnostic details stay private. Model output and summaries are never part of the response. Supply an Idempotency-Key header so that a lost response can replay the same admitted run and completion result without dispatching again.
In openclaw logs --follow, search for hook agent run completed and the exact HTTP runId. Runs with status=ok and no explicit delivery error are logged at info level; every non-ok status (skipped runs included), thrown error, and explicit delivery error is logged at warn level. For this deliver: false test, expect status=ok with no successful announcement. A warning carrying status=ok and deliveryError means execution succeeded but delivery failed. No further announcement attempt is triggered by it.
Structured terminal records carry the accepted agentId, jobId, hook name and source path, and logicalSessionKey. When the runner returns them, sessionId correlates the run transcript and sessionKey identifies the runtime session key. Exact-run continuation aliases may be retired once completion happens; a separate durable session row is not guaranteed by the key. Session facts that are missing stay unknown. Diagnostics are redacted, kept to a single line, and bounded to 500 characters per string. Successful output is not logged: look at the agent's run session for it. The HTTP runId correlates hook logs; it is not an automation job ID to hand to openclaw automations runs.
sessionMode defaults to isolated, so this test receives a fresh run session and a generated logical hook:<uuid> key. The stored session may use a cron:...:run:... key; the logical hook key does not promise anything about the transcript's storage key. Requests sharing a fixed defaultSessionKey are serialized, even in isolated mode; use it only when you intend that ordering.
Authentication
A hook token is required on every request, supplied through one of these headers:
Authorization: Bearer <token>(recommended).x-openclaw-token: <token>.
Authentication via a query-string ?token=... is rejected. Send JSON with Content-Type: application/json. Hook endpoints accept POST only. Payload fields, limits, routing policy, and error responses are listed in the Hooks reference.
POST /hooks/wake
Queue a trusted notification for the selected agent's main session. Immediate wakes go through ordinary session execution:
curl --include http://127.0.0.1:18789/hooks/wake \
-H 'Authorization: Bearer <long-random-hook-token>' \
-H 'Content-Type: application/json' \
--data '{"text":"The sample import completed","mode":"now","agentId":"main"}'
HTTP 200 carries eventOutcome: "queued" when the queue accepts the wake, or eventOutcome: "coalesced" when that same wake is already the queue's most recent pending event. With mode: "now", the response confirms admission to ordinary session execution, not completed execution or delivery. The event waits behind work already in that session and does not depend on heartbeat cadence or active hours. The legacy mode: "next-heartbeat" value assigns the event to an eligible scheduled automation for that session. Where no eligible job exists, the request reports an actionable error; for delayed work, use explicit automation scheduling.
When a session queue is full, HTTP 503 is returned with an actionable error rather than evicting an accepted event. Retry only after the session has processed its pending events. Mapped wake actions fall under this too; duplicate wakes can still coalesce while the queue is full.
When you provide agentId, it must identify an agent that exists in the configuration. Include it explicitly if the fleet has no implicit owner and no retained legacy owner. To have the caller pick a sessionKey, you need mode: "now", hooks.allowRequestSessionKey: true, and the configured prefix policy; deferred wakes go to the main session.
The wake text is delivered as a system event. It is not a separate email-reader turn wrapped in safety checks. Send only a brief notification whose content you control. Raw email, documents, and any other untrusted material should be handled through an agent action backed by a restricted reader.
POST /hooks/agent
Submit an agent turn that includes a required message. Routing, model, thinking, timeout, and duplicate-request protection are all optional; the payload reference documents them.
Use sessionMode: "isolated" when you want fresh context. Set "persistent" only if repeated events should share earlier context: in that case direct requests demand an explicit sessionKey, hooks.allowRequestSessionKey: true, and a nonempty hooks.allowedSessionKeyPrefixes.
If you supply sessionKey, it has to be a nonempty string. A malformed or blank key yields 400 rather than quietly falling back to a default or generated session key.
For direct channel delivery, provide a concrete channel together with to; add accountId when you need to pick an enabled channel account. If only part of a destination is given, if channel: "last" is used, or if the account selected is invalid, the call returns 400 before anything is dispatched. Direct hooks do not inherit the last recipient from the main session.
When no destination is given, the default deliver: true permits a completion system event on the target agent's main session. Setting deliver: false suppresses successful announcements and causes destination fields to be ignored; completion is recorded in the log instead. Non-ok outcomes still emit a failure event. Turning off announcements does not restrict tools: if the agent must not send messages, restrict its tools separately.
true
)">
Custom paths are resolved by hooks.mappings. The first mapping that matches takes effect, ahead of presets. A template, or a trusted local JS/TS transform, converts the payload into wake or agent actions; when a transform returns null, the result is HTTP 204 with no run. See Mapping details.
A transform may override the configured wake timing in either direction: for a wake action, return mode: "now" or mode: "next-heartbeat", or the matching wakeMode for an agent action.
Persistent mapped hooks need a stable mapping sessionKey or hooks.defaultSessionKey. Keys derived from a template require the same caller-key opt-in and prefix policy that request keys use.
forEach: "<key>" fans out across a top-level payload array. Every item sees a one-element array, which is why the Gmail preset's messages[0] refers to the current email. Agent fan-out admission replies after roughly 8 seconds of dispatch waiting at most; items still pending keep running in the background, and a partial batch returns non-2xx. Retrying the same batch reuses pending or admitted agent items for as long as the bounded in-memory replay cache holds them. This is not durable exactly-once delivery; mapped wake actions carry no replay identity, and the queue may coalesce repeated wakes. Batch caps and response shapes are covered in the reference.
Verify and troubleshoot hook requests
| Observation | Check or next action |
|---|---|
401 | Check the hook token rather than Gateway auth; verify that the proxy forwards the auth header. |
404 | Check hooks.enabled, hooks.path, and whether the custom path matches a mapping. |
400 | Read the response error: JSON, agent selection, session policy, or delivery coordinates may be invalid. Correct the request before retrying. |
405, 408, or 413 | Use POST; send the body promptly; stay within the documented body limit. |
429 | Repeated authentication failures were throttled. Correct the token and honor Retry-After. |
409 | Resolve the target session conflict before retrying. |
502 or 503 | Check Gateway logs for preparation, capacity, or restart/suspension failures. Single-run admission timeout cancels queued work; fan-out pending work can still start. |
200, but no chat message | Check completion logs first. deliver: false intentionally suppresses successful announcements; direct delivery needs both channel and to. HTTP admission does not prove delivery. |
204 | The mapping intentionally produced no actions, such as a null transform or an empty fan-out array. |
When a request has delivery enabled, confirm it arrived at the right channel, account, and recipient as well. Look at terminal warnings for deliveryError, including the case where status=ok. A failure is not proven by delivered: false on its own, and receipt is not proven by deliveryAttempted: true. Explicit suppression and message-tool delivery may already count as the runner's delivery handling; if delivery flags are absent, the outcome stays unknown.
When retrying agent requests, reuse an Idempotency-Key together with the same payload. Its scope and lifetime are described in the reference. Start a new test with a fresh key; replaying an 200 will not run the agent a second time.
Warning
Endpoints must sit behind loopback, a tailnet, or a trusted reverse proxy. Remote calls need HTTPS, and only the required path should be exposed.
- Give each hook its own token and its own subpath;
/gets rejected.- Limit
hooks.allowedAgentIds, and that covers the effective default-agent path too.- Leave
hooks.allowRequestSessionKey: falsein place unless you need it; if you turn it on, restricthooks.allowedSessionKeyPrefixes.- External event content is data, nothing more. By default, agent hook content is safety-wrapped, yet wrapping grants no protection against tool or workspace access. For untrusted inputs, run a restricted agent and leave unsafe-content overrides off.