openclaw/docs/automation/webhook.md
Bradley Priest 920551ae70 feat(hooks): support custom verifyAuth in transform modules
Add ability for hook mapping transform modules to export a verifyAuth
function for custom authentication (e.g., GitHub HMAC signatures).

When a transform module exports verifyAuth, it runs BEFORE standard
token auth. This enables external webhook signature verification
(GitHub, Stripe, Linear, etc.).

Changes:
- hooks.ts: Add verifyAndParseWebhook() with custom/token auth paths
- hooks-mapping.ts: Add HookVerifyAuthContext type, loadVerifyAuth()
- server-http.ts: Use verifyAndParseWebhook() for cleaner auth flow
- webhook.md: Document verifyAuth with GitHub example
- Tests for verifyAndParseWebhook and loadVerifyAuth
2026-01-30 12:45:05 +13:00

11 KiB

summary read_when
Webhook ingress for wake and isolated agent runs
Adding or changing webhook endpoints
Wiring external systems into Moltbot

Webhooks

Gateway can expose a small HTTP webhook endpoint for external triggers.

Enable

{
  hooks: {
    enabled: true,
    token: "shared-secret",
    path: "/hooks"
  }
}

Notes:

  • hooks.token is required when hooks.enabled=true.
  • hooks.path defaults to /hooks.

Auth

Every request must include the hook token. Prefer headers:

  • Authorization: Bearer <token> (recommended)
  • x-moltbot-token: <token>
  • ?token=<token> (deprecated; logs a warning and will be removed in a future major release)

Endpoints

POST /hooks/wake

Payload:

{ "text": "System line", "mode": "now" }
  • text required (string): The description of the event (e.g., "New email received").
  • mode optional (now | next-heartbeat): Whether to trigger an immediate heartbeat (default now) or wait for the next periodic check.

Effect:

  • Enqueues a system event for the main session
  • If mode=now, triggers an immediate heartbeat

POST /hooks/agent

Payload:

{
  "message": "Run this",
  "name": "Email",
  "sessionKey": "hook:email:msg-123",
  "wakeMode": "now",
  "deliver": true,
  "channel": "last",
  "to": "+15551234567",
  "model": "openai/gpt-5.2-mini",
  "thinking": "low",
  "timeoutSeconds": 120
}
  • message required (string): The prompt or message for the agent to process.
  • name optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries.
  • sessionKey optional (string): The key used to identify the agent's session. Defaults to a random hook:<uuid>. Using a consistent key allows for a multi-turn conversation within the hook context.
  • wakeMode optional (now | next-heartbeat): Whether to trigger an immediate heartbeat (default now) or wait for the next periodic check.
  • deliver optional (boolean): If true, the agent's response will be sent to the messaging channel. Defaults to true. Responses that are only heartbeat acknowledgments are automatically skipped.
  • channel optional (string): The messaging channel for delivery. One of: last, whatsapp, telegram, discord, slack, mattermost (plugin), signal, imessage, msteams. Defaults to last.
  • to optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for MS Teams). Defaults to the last recipient in the main session.
  • model optional (string): Model override (e.g., anthropic/claude-3-5-sonnet or an alias). Must be in the allowed model list if restricted.
  • thinking optional (string): Thinking level override (e.g., low, medium, high).
  • timeoutSeconds optional (number): Maximum duration for the agent run in seconds.

Effect:

  • Runs an isolated agent turn (own session key)
  • Always posts a summary into the main session
  • If wakeMode=now, triggers an immediate heartbeat

POST /hooks/<name> (mapped)

Custom hook names are resolved via hooks.mappings (see configuration). A mapping can turn arbitrary payloads into wake or agent actions, with optional templates or code transforms.

Mapping options (summary):

  • hooks.presets: ["gmail"] enables the built-in Gmail mapping.
  • hooks.mappings lets you define match, action, and templates in config.
  • hooks.transformsDir + transform.module loads a JS/TS module for custom logic.
  • Use match.source to keep a generic ingest endpoint (payload-driven routing).
  • TS transforms require a TS loader (e.g. bun or tsx) or precompiled .js at runtime.
  • Set deliver: true + channel/to on mappings to route replies to a chat surface (channel defaults to last and falls back to WhatsApp).
  • allowUnsafeExternalContent: true disables the external content safety wrapper for that hook (dangerous; only for trusted internal sources).
  • moltbot webhooks gmail setup writes hooks.gmail config for moltbot webhooks gmail run. See Gmail Pub/Sub for the full Gmail watch flow.

Transform Functions

When templates aren't flexible enough, use a transform function to process webhook payloads with code.

Basic Example

hooks:
  enabled: true
  token: "secret"
  transformsDir: ./hooks  # relative to config file
  mappings:
    - id: github
      match:
        path: github
      action: agent
      transform:
        module: github.js      # resolves to ./hooks/github.js
        export: handleWebhook  # optional, defaults to "default" or "transform"

The transform function receives a context object:

type HookMappingContext = {
  payload: Record<string, unknown>;  // Parsed JSON body
  headers: Record<string, string>;   // Lowercase header names
  url: URL;                          // Parsed request URL
  path: string;                      // Subpath after /hooks/ (e.g., "github")
};
// hooks/github.js
export function handleWebhook(ctx) {
  const event = ctx.headers["x-github-event"];
  
  // Return null to skip this webhook entirely (no agent run)
  if (event === "ping") return null;
  
  // Return fields to override the mapping defaults
  return {
    message: `GitHub ${event}: ${ctx.payload.action} on ${ctx.payload.repository?.full_name}`,
    name: "GitHub",
    sessionKey: `github:${ctx.payload.repository?.id}`,
  };
}

Return Values

Return an object with fields to override, or null to skip:

// For action: "agent" (default)
return {
  message: string;           // Required if not set in mapping
  name?: string;             // Display name for the hook
  sessionKey?: string;       // Session identifier
  wakeMode?: "now" | "next-heartbeat";
  deliver?: boolean;         // Send response to chat
  channel?: string;          // Target channel
  to?: string;               // Recipient
  model?: string;            // Model override
  thinking?: string;         // Thinking level
  timeoutSeconds?: number;
};

// For action: "wake"
return {
  text: string;              // Required
  mode?: "now" | "next-heartbeat";
};

// Skip this webhook (no action taken, returns 204)
return null;

Async Transforms

Transforms can be async:

export default async function(ctx) {
  const extra = await fetchAdditionalContext(ctx.payload.id);
  return {
    message: `Event: ${ctx.payload.type}\nContext: ${extra}`,
  };
}

TypeScript

JavaScript (.js / .mjs) transforms work out of the box with no extra setup.

TypeScript transforms require a loader at runtime:

# Run gateway with tsx
npx tsx node_modules/.bin/moltbot gateway start

# Or use bun
bun run moltbot gateway start

# Or precompile to .js
tsc hooks/*.ts --outDir hooks-dist
# then reference hooks-dist/github.js in config

Responses

  • 200 for /hooks/wake
  • 202 for /hooks/agent (async run started)
  • 401 on auth failure
  • 400 on invalid payload
  • 413 on oversized payloads

Examples

curl -X POST http://127.0.0.1:18789/hooks/wake \
  -H 'Authorization: Bearer SECRET' \
  -H 'Content-Type: application/json' \
  -d '{"text":"New email received","mode":"now"}'
curl -X POST http://127.0.0.1:18789/hooks/agent \
  -H 'x-moltbot-token: SECRET' \
  -H 'Content-Type: application/json' \
  -d '{"message":"Summarize inbox","name":"Email","wakeMode":"next-heartbeat"}'

Use a different model

Add model to the agent payload (or mapping) to override the model for that run:

curl -X POST http://127.0.0.1:18789/hooks/agent \
  -H 'x-moltbot-token: SECRET' \
  -H 'Content-Type: application/json' \
  -d '{"message":"Summarize inbox","name":"Email","model":"openai/gpt-5.2-mini"}'

If you enforce agents.defaults.models, make sure the override model is included there.

curl -X POST http://127.0.0.1:18789/hooks/gmail \
  -H 'Authorization: Bearer SECRET' \
  -H 'Content-Type: application/json' \
  -d '{"source":"gmail","messages":[{"from":"Ada","subject":"Hello","snippet":"Hi"}]}'

Custom Authentication (verifyAuth)

External webhooks (GitHub, Stripe, Linear, etc.) often use their own authentication schemes (e.g., HMAC signatures). Instead of using the standard bearer token, you can export a verifyAuth function from your transform module to handle custom auth.

When a mapping's transform module exports verifyAuth, it runs before the standard token check. If it returns true, the request is authorized; if false, a 401 is returned.

Example: GitHub Webhook Signature Verification

The verifyAuth function receives a context with the raw body for signature verification:

type HookVerifyAuthContext = {
  headers: Record<string, string>;   // Lowercase header names
  url: URL;                          // Parsed request URL
  path: string;                      // Subpath after /hooks/
  rawBody: Buffer;                   // Raw request body for signature verification
};
// hooks/github-transform.js
import { createHmac, timingSafeEqual } from "crypto";

// Runs BEFORE token auth - return true to allow, false to reject
export function verifyAuth(ctx) {
  const signature = ctx.headers["x-hub-signature-256"];
  if (!signature) return false;
  
  const secret = process.env.GITHUB_WEBHOOK_SECRET;
  const expected = "sha256=" + createHmac("sha256", secret)
    .update(ctx.rawBody)
    .digest("hex");
  
  try {
    return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  } catch {
    return false;
  }
}

// Transform the payload (runs after auth passes)
export default function transform(ctx) {
  const event = ctx.headers["x-github-event"];
  
  // Format based on event type
  if (event === "push") {
    return { message: `Push to ${ctx.payload.repository?.full_name}: ${ctx.payload.head_commit?.message}` };
  }
  if (event === "pull_request") {
    return { message: `PR ${ctx.payload.action}: ${ctx.payload.pull_request?.title}` };
  }
  
  return { message: `GitHub ${event}: ${JSON.stringify(ctx.payload).slice(0, 200)}` };
}

Config:

hooks:
  enabled: true
  token: "regular-token"  # Still needed for non-custom-auth hooks
  mappings:
    - id: github
      match:
        path: github
      action: agent
      name: GitHub
      transform:
        module: github-transform.js

Async verifyAuth

verifyAuth can be async if needed:

export async function verifyAuth(ctx) {
  // async validation (e.g., checking against external service)
  return await validateSignature(ctx.headers, ctx.rawBody);
}

Security

  • Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
  • Use a dedicated hook token; do not reuse gateway auth tokens.
  • Avoid including sensitive raw payloads in webhook logs.
  • Hook payloads are treated as untrusted and wrapped with safety boundaries by default. If you must disable this for a specific hook, set allowUnsafeExternalContent: true in that hook's mapping (dangerous).