Tutorial

OpenClaw Webhooks: Verify the Signature Before You Wake an Agent

May 8, 202615 min readUpdated September 13, 2026By OpenClawBlog Team

It is easy to treat a webhook as one security check: add a secret and forward the JSON. There are two separate checks, owned by different systems.

The service that sends the event may sign the request with HMAC. OpenClaw’s Gateway then authenticates the caller with its hook token. The current OpenClaw inbound webhook documentation describes bearer-token authentication through Authorization: Bearer <token> or x-openclaw-token. It does not describe a generic HMAC signature header for the Gateway hook endpoints.

That distinction determines where verification belongs. This tutorial puts a small Node.js adapter in front of OpenClaw. The adapter receives the provider request, keeps the original bytes, checks the provider signature, validates a narrow event shape, and then calls /hooks/agent with a separate OpenClaw token.

Evidence boundary. The OpenClaw configuration and request behavior below come from the current documentation. The adapter is reference code and the smoke test is a proposed local check, not a claim that this exact program was run against a live Gateway.

The boundary to keep

LayerWhat it checksWhat it does not prove
External serviceHMAC over the request bytes, and possibly a timestamp or event IDThat the event is safe for every agent tool
AdapterBody size, signature, event shape, event type, and replay policyThat OpenClaw finished the agent run
OpenClaw GatewayDedicated bearer token and hook routing policyThe identity of the original provider sender
AgentSession, workspace, sandbox, and tool policyThat untrusted event text is true

The two secrets should not be the same. EVENT_WEBHOOK_SECRET belongs to the relationship between the provider and the adapter. OPENCLAW_HOOK_TOKEN belongs to the relationship between the adapter and the Gateway.

The OpenClaw hook configuration reference makes an important qualification: the hook token grants ingress access, but it is not an authenticated sender identity. A valid request can still contain misleading or hostile content. HMAC proves possession of a shared key and protects the signed bytes. It does not encrypt the body, approve the business action, or make a repeated request fresh.

Prerequisites and decisions

You need a running OpenClaw Gateway, a Node.js runtime with the standard node:crypto, node:http, and node:https modules, and the upstream service’s signing documentation.

Before writing the verifier, record four provider-specific details:

  • the signature header name;
  • the algorithm and digest encoding;
  • the exact bytes or canonical string that the provider signs;
  • the timestamp and event-ID rules, if the provider includes them.

The example below uses X-Event-Signature: sha256=<64 hexadecimal characters>. That format is deliberately local to the example. It is not an OpenClaw protocol and should not be copied into a GitHub, Stripe, or other provider integration without checking that provider’s documentation.

Keep the public part small. The provider can reach the adapter over HTTPS, while the adapter calls a Gateway bound to loopback, a private tailnet, or a trusted reverse proxy. OpenClaw’s own guidance recommends a dedicated hook path, a dedicated token, restricted agent IDs, and leaving caller-selected session keys disabled unless they are needed.

Configure the OpenClaw hook

Merge a configuration like this into the profile used by the Gateway. Replace the token with a long random value and replace main with the configured agent you actually intend to receive the event.

{
  hooks: {
    enabled: true,
    token: '<long-random-openclaw-hook-token>',
    path: '/hooks',
    allowedAgentIds: ['main'],
    allowRequestSessionKey: false,
  },
}

The current configuration reference says that hooks.path cannot be the site root, and that allowedAgentIds can restrict the effective agent. Keeping allowRequestSessionKey false prevents the incoming event from choosing an arbitrary session key. Do not let fields such as agentId, sessionKey, channel, or to pass through from the provider payload unless you have a specific, reviewed routing policy.

On the Gateway host, the documented operational sequence is:

openclaw config validate
openclaw gateway restart
openclaw logs --follow

Use the same OpenClaw profile for these commands that runs the Gateway. The hook documentation also warns that query-string tokens are rejected, so the adapter must send the token in the Authorization header.

Build the adapter without a JSON body parser

Signature verification must happen against the original request bytes. Parsing JSON first and then calling JSON.stringify can change whitespace, escaping, or property order. The sample uses the built-in HTTP server so the order is visible.

Node’s crypto documentation defines createHmac for calculating an HMAC and recommends a same-length comparison before calling timingSafeEqual. The latter compares byte representations with a constant-time algorithm and is suitable for HMAC digests. The surrounding request handling still needs its own limits and error checks.

import { createServer } from 'node:http';
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { createHmac, timingSafeEqual } from 'node:crypto';

const PORT = Number(process.env.PORT ?? '3000');
const MAX_BODY_BYTES = 256 * 1024;
const EVENT_SECRET = required('EVENT_WEBHOOK_SECRET');
const OPENCLAW_TOKEN = required('OPENCLAW_HOOK_TOKEN');
const OPENCLAW_HOOK_URL =
  process.env.OPENCLAW_HOOK_URL ??
  'http://127.0.0.1:18789/hooks/agent';

function required(name) {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

function sendJson(res, status, body) {
  const bytes = Buffer.from(JSON.stringify(body));
  res.writeHead(status, {
    'content-type': 'application/json',
    'content-length': bytes.length,
  });
  res.end(bytes);
}

function readBody(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    let total = 0;
    let settled = false;

    req.on('data', (chunk) => {
      if (settled) return;
      total += chunk.length;
      if (total > MAX_BODY_BYTES) {
        settled = true;
        reject(Object.assign(new Error('body too large'), {
          code: 'PAYLOAD_TOO_LARGE'
        }));
        req.resume();
        return;
      }
      chunks.push(chunk);
    });
    req.on('end', () => {
      if (!settled) {
        settled = true;
        resolve(Buffer.concat(chunks));
      }
    });
    req.on('error', (error) => {
      if (!settled) {
        settled = true;
        reject(error);
      }
    });
  });
}

function parseSignature(header) {
  const match = /^sha256=([0-9a-f]{64})$/i.exec(header ?? '');
  return match ? Buffer.from(match[1], 'hex') : null;
}

function verifySignature(rawBody, header) {
  const received = parseSignature(header);
  if (!received) return false;

  const expected = createHmac('sha256', EVENT_SECRET)
    .update(rawBody)
    .digest();

  return received.length === expected.length &&
    timingSafeEqual(received, expected);
}

function validateEvent(event) {
  if (!event || typeof event !== 'object' || Array.isArray(event)) {
    return 'JSON object required';
  }
  if (typeof event.id !== 'string' ||
      !/^[A-Za-z0-9._:-]{1,120}$/.test(event.id)) {
    return 'id is required';
  }
  if (!['incident.created', 'build.failed'].includes(event.type)) {
    return 'unsupported event type';
  }
  return null;
}

function forwardToOpenClaw(event) {
  const url = new URL(OPENCLAW_HOOK_URL);
  if (!['http:', 'https:'].includes(url.protocol)) {
    throw new Error('unsupported OpenClaw URL protocol');
  }

  const payload = Buffer.from(JSON.stringify({
    name: 'Verified external webhook',
    agentId: 'main',
    sessionMode: 'isolated',
    deliver: false,
    message: [
      'A signed external event passed the adapter checks.',
      `event_id=${event.id}`,
      `event_type=${event.type}`,
      `event_data=${JSON.stringify(event.data ?? null)}`,
    ].join(String.fromCharCode(10)),
  }));

  const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;

  return new Promise((resolve, reject) => {
    const request = requestFn({
      protocol: url.protocol,
      hostname: url.hostname,
      port: url.port || undefined,
      path: `${url.pathname}${url.search}`,
      method: 'POST',
      headers: {
        authorization: `Bearer ${OPENCLAW_TOKEN}`,
        'content-type': 'application/json',
        'content-length': payload.length,
        'idempotency-key': event.id,
      },
      timeout: 10000,
    }, (response) => {
      response.on('data', () => {});
      response.on('end', () => {
        resolve({
          statusCode: response.statusCode ?? 0,
          retryAfter: response.headers['retry-after'],
        });
      });
    });

    request.on('timeout', () => {
      request.destroy(new Error('OpenClaw request timed out'));
    });
    request.on('error', reject);
    request.end(payload);
  });
}

const server = createServer(async (req, res) => {
  req.setTimeout(30000, () => {
    req.destroy(new Error('request timed out'));
  });

  if (req.method !== 'POST' || req.url !== '/provider-events') {
    sendJson(res, 404, { ok: false, error: 'not found' });
    return;
  }

  const contentType = req.headers['content-type'];
  if (typeof contentType !== 'string' ||
      !contentType.toLowerCase().startsWith('application/json')) {
    sendJson(res, 415, { ok: false, error: 'application/json required' });
    return;
  }

  let rawBody;
  try {
    rawBody = await readBody(req);
  } catch (error) {
    const status = error?.code === 'PAYLOAD_TOO_LARGE' ? 413 : 400;
    sendJson(res, status, { ok: false, error: 'invalid body' });
    return;
  }

  const signature = req.headers['x-event-signature'];
  const header = Array.isArray(signature) ? undefined : signature;
  if (!verifySignature(rawBody, header)) {
    sendJson(res, 401, { ok: false, error: 'invalid signature' });
    return;
  }

  let event;
  try {
    event = JSON.parse(rawBody.toString('utf8'));
  } catch {
    sendJson(res, 400, { ok: false, error: 'invalid JSON' });
    return;
  }

  const validationError = validateEvent(event);
  if (validationError) {
    sendJson(res, 422, { ok: false, error: validationError });
    return;
  }

  try {
    const result = await forwardToOpenClaw(event);
    if (result.statusCode < 200 || result.statusCode >= 300) {
      console.error('OpenClaw admission failed', {
        eventId: event.id,
        statusCode: result.statusCode,
      });
      if (result.statusCode === 429 || result.statusCode === 503) {
        if (typeof result.retryAfter === 'string') {
          res.setHeader('retry-after', result.retryAfter);
        }
        sendJson(res, 503, { ok: false, error: 'OpenClaw temporarily unavailable' });
      } else {
        sendJson(res, 502, { ok: false, error: 'OpenClaw rejected event' });
      }
      return;
    }

    console.info('OpenClaw accepted event', { eventId: event.id });
    sendJson(res, 202, { ok: true, eventId: event.id });
  } catch (error) {
    console.error('OpenClaw request failed', {
      eventId: event.id,
      error: error instanceof Error ? error.message : 'unknown error',
    });
    sendJson(res, 502, { ok: false, error: 'OpenClaw unavailable' });
  }
});

server.listen(PORT, '127.0.0.1', () => {
  console.log(`signed webhook adapter listening on 127.0.0.1:${PORT}`);
});

Why the order matters

First, the adapter reads bytes and enforces a size limit. OpenClaw’s normal hook body limit is documented as 256 KiB, so the sample uses the same ceiling before making a second network request.

Second, it verifies the signature. A missing, malformed, or incorrectly encoded tag stops with 401. The length check before timingSafeEqual matters because Node throws when the compared byte arrays have different lengths.

Third, it parses JSON. A valid signature over invalid JSON still fails, this time with 400. Authentication and syntax are different checks.

Fourth, it validates the event. The sample accepts only two event types and requires a bounded event ID. A real integration should use the provider’s event schema and allowlist the fields that the agent actually needs.

Finally, it makes a fixed call to /hooks/agent. The incoming payload cannot select a different agent or session. The request uses an isolated session and deliver: false, so a successful run does not automatically announce a reply to a chat channel. OpenClaw’s docs say external content is safety-wrapped for agent hooks by default, but that wrapper does not remove tools or workspace access. Use a restricted agent for untrusted events.

The event ID is sent as Idempotency-Key. Reuse that same value when retrying the same provider event. Use a new value for a new event. The OpenClaw repository automation documentation and the live docs describe idempotency as a way to replay the same admitted result without dispatching the agent again. It is not a durable exactly-once system, so critical integrations still need persistent deduplication on the adapter side.

Run a local smoke test

The following test plan uses the example signature format. It is useful for checking your adapter’s ordering, but it does not test a provider’s real signing convention.

Set the two environment variables from your local secret store or temporary shell. Do not commit them, place them in source code, or reuse the Gateway authentication secret.

export EVENT_WEBHOOK_SECRET='same-value-configured-at-the-sender'
export OPENCLAW_HOOK_TOKEN='the-dedicated-hooks-token'
node adapter.mjs

In another terminal, sign exactly the bytes that will be sent:

BODY={'id':'evt_123','type':'build.failed','data':{'run':'demo'}}
DIGEST=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$EVENT_WEBHOOK_SECRET" | awk '{print $2}')
curl --include http://127.0.0.1:3000/provider-events \
  -H 'Content-Type: application/json' \
  -H "X-Event-Signature: sha256=$DIGEST" \
  --data-binary "$BODY"

If the Gateway admits the request, the adapter responds with 202. The direct OpenClaw call inside the adapter should have returned a successful admission response. OpenClaw documents that this response means the run entered the agent runner; it does not mean the model finished, a tool succeeded, or a message was delivered.

Keep openclaw logs --follow running. For a direct agent hook, the documented response includes ok and a runId. Use that ID and the agent’s run session to investigate completion.

Then change one byte in BODY without changing DIGEST. The adapter should return 401, and it should make no request to OpenClaw. Try a valid signature over malformed JSON next; that should reach the parser and return 400. An unsupported but syntactically valid event should return 422.

Failure paths worth preserving

SymptomLikely boundaryNext check
401 from the adapterProvider signatureCheck the raw bytes, header prefix, encoding, secret, and signed string. Do not reserialize the JSON.
413 from the adapter or GatewayBody limitTrim the event or use the provider’s reference ID and fetch details through a restricted worker.
401 from OpenClawGateway hook authenticationCheck the dedicated hook token and confirm that the proxy forwarded the Authorization header.
400 from OpenClawPayload or routing policyRead the error, then check the fixed agent ID, session policy, and delivery fields.
429 from OpenClawRepeated failed authenticationCorrect the token and honor Retry-After; do not keep retrying a bad credential.
200 but no chat replyAdmission is not completionCheck the run logs and remember that deliver: false intentionally suppresses successful announcements.

The official inbound hook page lists separate outcomes for malformed requests, authentication failures, body timeouts, oversized bodies, throttled authentication, and admission failures. Keeping those categories in your adapter logs is more useful than recording only “webhook failed.” Never log the provider secret, OpenClaw token, full signature, or unredacted event body.

Replay protection and rotation

HMAC authenticates a message using a shared secret. The RFC 2104 specification describes that shared-key model and stresses random key selection, protection, and periodic refresh. It does not turn an old valid message into a new one.

If the provider supplies a signed timestamp, verify it using the provider’s exact format and reject requests outside a narrow clock window. If it supplies an event ID, keep a durable record of IDs that have been accepted or completed. If neither exists, ask whether a nonce or signed timestamp can be added before treating the endpoint as reliable.

Rotate the provider secret and the OpenClaw hook token independently. During a planned rotation, a short overlap with two explicitly configured verification keys can prevent lost events, but only if the provider and your adapter can identify which key was used. Remove the old key after the provider confirms the change.

There is also a content boundary. A signed event may be authentic and still contain instructions aimed at the model. Keep the agent’s workspace narrow, deny tools that the event does not need, and avoid sending raw email, documents, or arbitrary text to a wake endpoint. OpenClaw’s documentation distinguishes /hooks/wake, which creates a system event, from /hooks/agent, which submits an agent turn and applies the external-content safety boundary. For this reason, the adapter uses /hooks/agent.

When you do not need an HMAC adapter

If the upstream caller is a private service that you control and it has no separate signing protocol, the Gateway’s dedicated bearer token may be enough for a small trusted deployment. Keep the hook endpoint on a private network, use an explicit agent allowlist, and treat the token as a capability that must be protected.

If you are receiving an outbound webhook generated by an OpenClaw automation, that is a different direction of travel. The receiving application must verify whatever authentication or signature contract its sender defines. Do not assume that OpenClaw’s inbound hooks.token is an outbound HMAC signature.

For an HMAC-signed provider event, the safe sequence is straightforward: preserve the bytes, authenticate them, reject stale or unknown events, forward a fixed message with a separate OpenClaw token, and monitor the admitted run. The important detail is ownership of authentication. HMAC protects the adapter’s provider boundary; the OpenClaw token protects Gateway ingress. Keeping those responsibilities separate makes the failure easier to find and the authority easier to limit.

Sources

Reference Trail

Sources and further reading

  1. OpenClaw inbound webhook documentationdocs.openclaw.ai
  2. OpenClaw hook configuration referencedocs.openclaw.ai
  3. crypto documentationnodejs.org
  4. OpenClaw repository automation documentationgithub.com
  5. RFC 2104 specificationwww.rfc-editor.org
Back to ArchiveMore: TutorialsNext: OpenClaw Microsoft Teams setup: checkpoints for a silent channel