Skip to content

netclaw webhooks

netclaw webhooks manages inbound webhook routes. External services POST to a route, netclaw verifies the signature, runs a session with the route’s prompt, and posts results to Slack if configured.

This page is the flag reference. For the wire protocol, the route-file schema, and how a request is verified, see webhook configuration.

Routes live as individual JSON files in ~/.netclaw/config/webhooks/. CLI management (list, set, validate, etc.) doesn’t need a running daemon. Actually receiving webhook requests needs three more things:

  • The daemon running — netclaw daemon start.
  • The global endpoint switched on. Webhooks.Enabled is false by default and every route 404s until it’s on, which is the number one reason a route that looks right never fires. Flip it in netclaw configInbound Webhooks, then restart the daemon once.
  • A way in from the internet — a non-local exposure mode such as Tailscale Serve or Cloudflare Tunnel. GitHub can’t reach your localhost.
Terminal window
netclaw webhooks [subcommand] [options]
Terminal window
netclaw webhooks list [--json] [--all]
FlagDescriptionDefault
--jsonJSON outputOff
--allInclude disabled routesOff

netclaw webhooks list showing no routes configured and the route directory path

With no routes configured, you see the storage path. Once routes exist, the table shows status, audience, verification kind, and delivery requirement per route.

Terminal window
netclaw webhooks show <route> [--json] [--show-secret]

Displays full configuration for a single route. Also validates the route file — exits with code 1 if the route has schema errors.

FlagDescriptionDefault
--jsonJSON outputOff
--show-secretReveal the verification secretOff

Secrets are redacted by default. Pass --show-secret when you need to copy the secret to a third-party service.

Creates or updates a route. Route names must be lowercase alphanumeric with single dashes between segments (github-issues, deploy-v2). Regex: ^[a-z0-9]+(?:-[a-z0-9]+)*$.

Terminal window
netclaw webhooks set <route> [options]

New routes require a prompt (choose one) and a secret (choose one):

FlagDescription
--prompt <text>Prompt text injected into webhook sessions
--prompt-file <path>Read prompt from a file
--secret <value>Verification secret — visible in shell history; prefer --secret-file or --secret-env
--secret-file <path>Read secret from a file
--secret-env <VAR>Read secret from an environment variable

All three secret flags are read once, when the command runs, and the resolved value is written into the route file. --secret-env is not a live reference: changing the variable later doesn’t rotate the route’s secret, and the daemon doesn’t need the variable in its environment. Re-run set to change it.

Three modes. Omit --verification-kind and you get hmac. The configuration reference covers the signing protocol behind each one.

KindWhat it verifies
hmacHMAC-SHA256 over the raw request body
hmac-timestampedHMAC-SHA256 over <timestamp><separator><raw body>, plus a replay window
header-secretA shared secret sent verbatim in a header

The modes are independent. Netclaw never falls back from one to another, and changing an existing route’s mode is a two-sided change — see migrating a route.

FlagDescriptionDefault
--verification-kind <kind>hmac, hmac-timestamped, or header-secrethmac
--signature-header <name>Header containing the HMAC signatureX-Webhook-Signature
--signature-prefix <prefix>Prefix on the signature value (e.g. sha256=)
--secret-header <name>Header containing the secret (header-secret mode)X-Webhook-Secret
--event-header <name>Header with the event type nameX-Webhook-Event
--delivery-header <name>Header with the unique delivery ID (for deduplication)X-Webhook-Delivery

These four apply only to hmac-timestamped routes. The defaults are Stripe’s format, so a Stripe route sets none of them.

FlagDescriptionDefault
--timestamp-field <name>Field in the signature header holding the Unix timestampt
--signature-field <name>Field holding the signaturev1
--signed-payload-separator <value>Joins the timestamp and body before signing.
--signature-tolerance-seconds <n>Replay window in seconds, 1–3600300

Pass any of them to a route that isn’t hmac-timestamped and the command fails before writing anything:

[FAIL] Timestamp signature options require '--verification-kind hmac-timestamped'.

--signature-prefix is inert on timestamped routes — the prefix is forced empty, because the signature lives in a header field rather than carrying its own prefix.

--audience controls which tool audience the webhook session runs under. public gets the most restricted tool access, personal gets the most permissive.

FlagDescriptionDefault
--events <list>Comma-separated event type allow-list (empty = all)All
--audience <level>public, team, or personalpublic
--max-body <bytes>Maximum request body size1048576 (1 MB)
--rate-limit <N>Requests per minute30
--enabled / --disabledEnable or disable the routeEnabled

Set a notification target and the agent posts results to that Slack channel. Configure Slack first.

FlagDescriptionDefault
--notify-instructions <text>Instructions for agent notification behavior
--notify-instructions-file <path>Read notification instructions from a file
--delivery-required / --no-delivery-requiredRequire notification deliverytrue
--notification-channel <id>Slack channel ID for notifications
FlagDescription
--dry-runValidate and print the route without saving
--create-onlyFail if the route already exists
--update-onlyFail if the route doesn’t exist
Terminal window
netclaw webhooks set github-issues \
--prompt "Triage this GitHub issue. Summarize it and suggest a priority label." \
--secret-env GITHUB_WEBHOOK_SECRET \
--verification-kind hmac \
--signature-header X-Hub-Signature-256 \
--signature-prefix "sha256=" \
--event-header X-GitHub-Event \
--delivery-header X-GitHub-Delivery \
--events "issues,issue_comment" \
--audience team \
--notification-channel C0123SLACK

Stripe signs t=<timestamp>.<body> and sends it as Stripe-Signature. Netclaw’s field defaults already match, so the only thing worth naming is the header:

Terminal window
netclaw webhooks set stripe-events \
--prompt "Process this Stripe event and summarize the charge." \
--secret-env STRIPE_WEBHOOK_SECRET \
--verification-kind hmac-timestamped \
--signature-header Stripe-Signature \
--audience team

webhooks show fills in the effective defaults, marking the ones you didn’t set:

Verification:
Kind: hmac-timestamped
Secret: ********** (use --show-secret to reveal)
Algorithm: sha256
Signature Header: Stripe-Signature
Timestamp Field: t (default)
Signature Field: v1 (default)
Payload Separator: . (default)
Tolerance: 300 (default) seconds

--json reports the same resolved values:

"verification": {
"kind": "hmac-timestamped",
"secret": "********",
"hmacAlgorithm": "sha256",
"signatureHeader": "Stripe-Signature",
"signaturePrefix": null,
"secretHeader": null,
"eventHeader": null,
"deliveryIdHeader": null,
"timestampField": "t",
"signatureField": "v1",
"signedPayloadSeparator": ".",
"toleranceSeconds": 300
}

When the sender doesn’t follow Stripe’s conventions, name each part:

Terminal window
netclaw webhooks set custom-signed \
--prompt "Handle this event." \
--secret-env ACME_WEBHOOK_SECRET \
--verification-kind hmac-timestamped \
--signature-header X-Acme-Signature \
--timestamp-field issued \
--signature-field sig \
--signed-payload-separator ":" \
--signature-tolerance-seconds 120
Terminal window
netclaw webhooks set github-issues --rate-limit 10 --dry-run
Terminal window
netclaw webhooks set deploy-notify \
--prompt "Summarize this deployment and post a status update." \
--secret-env DEPLOY_SECRET \
--create-only

Existing routes keep their verification mode forever. Upgrading netclaw doesn’t touch them, and set only changes the mode when you pass --verification-kind. Edit anything else and verification stays exactly as it was:

Terminal window
netclaw webhooks set github-issues --rate-limit 60

Switching modes is a two-sided change: netclaw won’t accept the sender’s old signatures once you flip it, and there’s no fallback to the previous verifier. Reconfigure the sender first, then flip the route:

Terminal window
netclaw webhooks set github-issues \
--verification-kind hmac-timestamped \
--signature-header X-Hub-Signature-256

Between those two steps every delivery fails with a 401. Routes hot-reload per request, so the gap lasts however long you take between the two commands — no restart involved.

Terminal window
netclaw webhooks delete <route> [--force | -f]

Prompts for confirmation unless --force is passed.

Terminal window
netclaw webhooks validate <route>

Checks the route file for syntax errors and schema violations. These are the same checks netclaw doctor runs across all routes automatically. The backtick-quoted names below are JSON fields in the route file:

  • Route name must match ^[a-z0-9]+(?:-[a-z0-9]+)*$
  • prompt is required and non-empty
  • verification section is required with a non-empty secret
  • maxBodyBytes and rateLimitPerMinute must be >= 1
  • events list can’t contain blank entries
  • If notifyInstructions is set, notificationTarget is required — regardless of deliveryRequired
  • If notificationTarget.kind is Slack, notificationTarget.channelId is required

On HmacTimestamped routes only:

  • toleranceSeconds must be between 1 and 3600
  • timestampField and signatureField must be non-empty HTTP tokens and must differ from each other

set runs the same validation before persisting, so a rejected command leaves the existing route untouched:

[FAIL] Webhook route 'stripe-events' has validation errors:
- Verification.ToleranceSeconds must be between 1 and 3600.

Each route is stored as ~/.netclaw/config/webhooks/<route-name>.json. A GitHub Issues route looks like this:

{
"enabled": true,
"prompt": "Triage this GitHub issue. Summarize it and suggest a priority label.",
"verification": {
"kind": "Hmac",
"hmacAlgorithm": "Sha256",
"secret": "ghs_abc123...",
"signatureHeaderName": "X-Hub-Signature-256",
"signaturePrefix": "sha256=",
"eventHeaderName": "X-GitHub-Event",
"deliveryIdHeaderName": "X-GitHub-Delivery"
},
"events": ["issues", "issue_comment"],
"audience": "Team",
"maxBodyBytes": 1048576,
"rateLimitPerMinute": 30,
"deliveryRequired": true,
"notifyInstructions": "Post a summary to the triage channel.",
"notificationTarget": {
"kind": "Slack",
"channelId": "C0123SLACK"
}
}

Three casing conventions meet in that file, and mixing them up is the easiest mistake here:

WhereConventionExample
CLI flag valueslowercase, hyphenated--verification-kind hmac-timestamped
Route file keyscamelCase"signatureHeaderName"
Route file enum valuesPascalCase"kind": "HmacTimestamped"

That’s what netclaw webhooks set writes, and what webhook-route.v1.schema.json declares. Hand-written PascalCase keys still load — deserialization is case-insensitive — but they won’t match what the CLI writes back.

The CLI’s hyphenated spelling doesn’t carry into the file: "kind": "hmac-timestamped" fails to parse. Use "HmacTimestamped".

When a POST hits /api/webhooks/{routeName}:

  1. Checks Webhooks.Enabled, then loads the route file (hot-reloaded per request — no restart needed after changes)
  2. Rejects bodies over maxBodyBytes (413) and non-JSON bodies (400) — both before any signature work
  3. Verifies the request using the route’s configured mode — body HMAC, timestamped HMAC, or header secret. Timestamped routes also reject signatures outside the tolerance window.
  4. Filters by event type if the route has an allow-list, then drops deliveries it has already seen
  5. Applies the rate limit
  6. Creates a new Webhook session with the route’s prompt injected as context
  7. If a notification target is configured, the agent posts results to that Slack channel. Without a notification target, the session still runs — output is stored in the session log

Anything that fails verification — wrong signature, stale or future timestamp, unparseable signature header — returns 401 and never reaches step 6, so no session runs. A 404 means Webhooks.Enabled is off, the route doesn’t exist, or the route is disabled. The full status table lists every rejection and its code. To see delivery and rejection counts, run netclaw stats.

Your route’s endpoint is your daemon’s external hostname plus the route path:

<your-external-hostname>/api/webhooks/<route-name>

Netclaw doesn’t store or report that hostname — it comes from whichever ingress you run, Tailscale Serve or Cloudflare Tunnel, and you’ll find it in their dashboard. netclaw status shows the daemon’s local endpoint (http://localhost:PORT), which external services can’t reach — don’t paste that into GitHub.

CodeMeaning
0Success
1Route not found, validation error, or other failure

After setting up a route, restart the daemon and run netclaw status to confirm the webhook endpoint is healthy. Then paste the full webhook URL into your external service’s webhook settings.