Agent skill
OpenClaw Webhooks Skill
Receive and verify OpenClaw automation (cron) webhooks. Use when handling the finished-run POSTs OpenClaw sends with Authorization: Bearer <webhookToken>, processing automation results or failure alerts, or calling an OpenClaw Gateway's inbound /hooks/agent and /hooks/wake endpoints.
Install this skill
npx skills add hookdeck/webhook-skills --skill openclaw-webhooks
When to Use This Skill
- Receiving the webhook OpenClaw POSTs when an automation (cron job) finishes (
--webhook <url>) - Verifying the
Authorization: Bearer <webhookToken>header on those POSTs - Handling finished-run events and failure alerts from OpenClaw automations
- Calling an OpenClaw Gateway's inbound hooks (
/hooks/agent,/hooks/wake) from your service (see Calling OpenClaw's Inbound Hooks)
Receiving Webhooks from OpenClaw
OpenClaw automations (cron jobs) can deliver their output to a URL. From Manage automations:
Use
--webhook <url>onautomations add|createorautomations editto POST the finished run payload to an HTTP endpoint
webhookTokenis sent asAuthorization: Bearer <token>on automation webhook POSTs.
The body is JSON and is not signed, so the Authorization header is the whole check. Compare the entire header value with Bearer <webhookToken> in constant time. In Hookdeck's OpenClaw source, paste the bare webhookToken into the Webhook Token field; Hookdeck accepts the header with or without the Bearer prefix. OpenClaw only sends the header when cron.webhookToken is set, so always set it.
Token Verification (JavaScript)
const crypto = require('crypto');
// Compare the whole Authorization value with "Bearer <webhookToken>"
function verifyOpenClawWebhook(authorizationHeader, webhookToken) {
if (!authorizationHeader || !webhookToken) return false;
const received = Buffer.from(authorizationHeader, 'utf8');
const expected = Buffer.from(`Bearer ${webhookToken}`, 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
Express Webhook Handler
const express = require('express');
const app = express();
// express.raw keeps the exact bytes; parse JSON only after the token check
app.post('/webhooks/openclaw', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyOpenClawWebhook(req.get('authorization'), process.env.OPENCLAW_WEBHOOK_TOKEN)) {
return res.status(401).json({ error: 'Invalid token' });
}
const event = JSON.parse(req.body.toString('utf8'));
if (event.action === 'finished') {
console.log(`Automation ${event.job?.name || event.jobId}: ${event.status}`, event.summary || event.error);
}
res.status(200).json({ received: true }); // any 2xx counts as delivered
});
Token Verification (Python)
import hmac
def verify_openclaw_webhook(authorization_header: str | None, webhook_token: str) -> bool:
if not authorization_header or not webhook_token:
return False
return hmac.compare_digest(authorization_header.encode(), f"Bearer {webhook_token}".encode())
For complete working examples with tests, see:
- examples/express/ - Full Express implementation
- examples/nextjs/ - Next.js App Router implementation
- examples/fastapi/ - Python FastAPI implementation
Finished Event Payload
Delivery modes: "webhook | POST finished event payload to a URL". The payload is the run's finished event:
Source, not docs. OpenClaw's docs only say "POST finished event payload to a URL" and don't list its fields. The fields below come from OpenClaw's source at commit
5dc91d6: theCronEventtype (src/cron/service/state.ts),buildCronFinishedWebhookPayload, which dropssummaryanddiagnosticson error events, and the POST withAuthorization: Bearer(both insrc/gateway/server-cron-notifications.ts). Field names can change between releases, so read them defensively.
{
"jobId": "job_123",
"action": "finished",
"runId": "run_abc",
"runAtMs": 1767268800000,
"durationMs": 4200,
"status": "ok",
"completionStatus": "succeeded",
"summary": "Three deploys today, all green.",
"job": { "id": "job_123", "name": "Deploy digest" },
"nextRunAtMs": 1767355200000
}
| Field | Description |
|---|---|
action | Always finished for this delivery |
jobId, job | The job ID and a snapshot of the job |
runId, runAtMs, durationMs | The run's ID, start time (ms) and duration |
status | Execution status, e.g. ok or error |
completionStatus | e.g. succeeded or failed (a run can be status: "ok" with completionStatus: "failed") |
summary | The run's output. Omitted on error events |
error | Error text on failed runs |
nextRunAtMs | Next scheduled run, when there is one |
A successful run with an empty summary skips the POST; failed runs always send. If the job's delivery.failureDestination uses webhook mode, failure alerts go there with the same token and a smaller body: { "jobId", "jobName", "message", "runAtMs" } (also from source, not the docs).
Delivery Behavior
- Any HTTP 2xx records the delivery. Other responses record Not delivered.
- If the response is lost or times out, delivery stays Unknown, and OpenClaw doesn't retry it. Put Hookdeck in front if you need retries; dedupe on
runId(orjobId+runAtMs). - Outbound webhooks use a strict SSRF policy. Public URLs (such as a Hookdeck source URL) work as-is. Private or local receivers need
cron.webhookSsrfPolicy.allowedHostnames.
Calling OpenClaw's Inbound Hooks
The other direction: your service (or a Hookdeck destination) sends events to an OpenClaw Gateway. From Inbound webhooks, every request must include the hook token via Authorization: Bearer <token> (recommended) or x-openclaw-token: <token>, and query-string ?token=... is rejected. The Gateway does the verification; your job is to send the right token (hooks.token, not cron.webhookToken or the Gateway auth token).
| Endpoint | Purpose | Response |
|---|---|---|
POST /hooks/agent | Trigger an isolated agent turn | 202 Accepted |
POST /hooks/wake | Enqueue a system event | 200 OK |
curl -X POST http://127.0.0.1:18789/hooks/agent \
-H 'Authorization: Bearer <hooks.token>' \
-H 'Content-Type: application/json' \
-d '{"message": "Summarize inbox", "name": "Email"}'
Agent Hook Payload
{
"message": "Summarize inbox",
"name": "Email",
"agentId": "hooks",
"sessionKey": "hook:email:msg-123",
"wakeMode": "now",
"deliver": true,
"channel": "last",
"to": "+15551234567",
"model": "openai/gpt-5.2-mini",
"thinking": "low",
"timeoutSeconds": 120
}
| Field | Required | Description |
|---|---|---|
message | Yes | Prompt or message for the agent |
name | No | Human-readable hook name (e.g. "GitHub", "Email") |
agentId | No | Route to a specific agent; falls back to default |
sessionKey | No | Session key (disabled by default) |
wakeMode | No | now (default) or next-heartbeat |
deliver | No | Send agent response to messaging channel (default true) |
channel | No | last, whatsapp, telegram, discord, slack, signal, msteams |
to | No | Recipient identifier for the channel |
model | No | Model override for this run |
thinking | No | Thinking level: low, medium, high |
timeoutSeconds | No | Max duration for the agent run |
Wake Hook Payload
{
"text": "New email received",
"mode": "now"
}
| Field | Required | Description |
|---|---|---|
text | Yes | Description of the event |
mode | No | now (default) or next-heartbeat |
Gateway Response Codes
| Code | Meaning |
|---|---|
200 | Wake event accepted |
202 | Agent hook accepted (async run started) |
400 | Invalid payload or query-string token |
401 | Authentication failed |
413 | Payload too large |
429 | Rate-limited (check Retry-After header) |
Environment Variables
OPENCLAW_WEBHOOK_TOKEN=your_webhook_token # Must match cron.webhookToken in the Gateway config
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 openclaw --path /webhooks/openclaw
Then point an automation at the tunnel URL: openclaw automations edit <jobId> --webhook <url>.
Reference Materials
- references/overview.md - Automation webhooks, inbound hooks, payloads
- references/setup.md - Gateway and automation configuration
- references/verification.md - Token verification details and gotchas