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> on automations add|create or automations edit to POST the finished run payload to an HTTP endpoint

webhookToken is sent as Authorization: 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:

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: the CronEvent type (src/cron/service/state.ts), buildCronFinishedWebhookPayload, which drops summary and diagnostics on error events, and the POST with Authorization: Bearer (both in src/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
}
FieldDescription
actionAlways finished for this delivery
jobId, jobThe job ID and a snapshot of the job
runId, runAtMs, durationMsThe run's ID, start time (ms) and duration
statusExecution status, e.g. ok or error
completionStatuse.g. succeeded or failed (a run can be status: "ok" with completionStatus: "failed")
summaryThe run's output. Omitted on error events
errorError text on failed runs
nextRunAtMsNext 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 (or jobId + 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).

EndpointPurposeResponse
POST /hooks/agentTrigger an isolated agent turn202 Accepted
POST /hooks/wakeEnqueue a system event200 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
}
FieldRequiredDescription
messageYesPrompt or message for the agent
nameNoHuman-readable hook name (e.g. "GitHub", "Email")
agentIdNoRoute to a specific agent; falls back to default
sessionKeyNoSession key (disabled by default)
wakeModeNonow (default) or next-heartbeat
deliverNoSend agent response to messaging channel (default true)
channelNolast, whatsapp, telegram, discord, slack, signal, msteams
toNoRecipient identifier for the channel
modelNoModel override for this run
thinkingNoThinking level: low, medium, high
timeoutSecondsNoMax duration for the agent run

Wake Hook Payload

{
  "text": "New email received",
  "mode": "now"
}
FieldRequiredDescription
textYesDescription of the event
modeNonow (default) or next-heartbeat

Gateway Response Codes

CodeMeaning
200Wake event accepted
202Agent hook accepted (async run started)
400Invalid payload or query-string token
401Authentication failed
413Payload too large
429Rate-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


Repository

hookdeck/webhook-skills

v0.2.0 · MIT · Updated Aug 6, 2026

View on GitHub →