Skip to content

Webhooks

External services POST JSON to /api/webhooks/<route>. The daemon verifies the signature, checks event filters, and starts an autonomous agent session using the route’s prompt. Each route is a standalone JSON file in ~/.netclaw/config/webhooks/.

This page is the wire protocol and the route-file schema. For the flags that write those files, see netclaw webhooks. A webhook session runs with nobody to approve a tool call, so a handler that needs shell or file access needs its tools approved ahead of time. Run reminders, webhooks and scripts unattended covers that. To create a first route, jump to Set up a route.

The master switch is off by default and every route returns 404 until it’s on. Turn it on in netclaw config → Inbound Webhooks. The editor shows live route counts, and warns if you enable webhooks with no valid routes.

Inbound Webhooks editor with Enabled checked, a 300 second execution timeout, and a route summary of two routes, both enabled

The editor reports route counts. With the switch on and no valid routes, it also warns that every request fails closed.

For scripted or headless installs, set Webhooks.Enabled directly in ~/.netclaw/config/netclaw.json:

{
"Webhooks": {
"Enabled": true,
"ExecutionTimeoutSeconds": 300
}
}
FieldTypeDefaultDescription
EnabledboolfalseMaster switch for /api/webhooks/{route}. Every route returns 404 while it’s off.
ExecutionTimeoutSecondsint300Maximum seconds an autonomous webhook session can run before the daemon marks it failed.

The daemon restarts itself when netclaw.json changes. If you set Webhooks.Enabled through an environment variable instead, restart it by hand. See Set up a route.

Each route lives at ~/.netclaw/config/webhooks/<route-name>.json. The filename (minus .json) is the route name and must match ^[a-z0-9]+(?:-[a-z0-9]+)*$ (lowercase kebab-case). The smallest valid route is a prompt and a secret:

{
"prompt": "Summarize this event and log the result.",
"verification": {
"kind": "Hmac",
"secret": "your-shared-secret"
}
}
FieldTypeDefaultDescription
enabledbooltrueWhether this route accepts requests
promptstring(required)System prompt injected into the webhook session
verificationobject(required)Signature/secret verification settings (details below)
eventsstring[][] (all)Event type allow-list. Empty array accepts all event types.
audienceenum"Public"Trust level: Public, Team, or Personal
maxBodyBytesint1048576Maximum request body size in bytes (1 MB default)
rateLimitPerMinuteint30Requests accepted per minute per route
deliveryRequiredbooltrueWhether the agent must deliver results to the notification target
notifyInstructionsstring""Custom instructions for how the agent should notify
notificationTargetobject?nullWhere to deliver results (details below)

Every route requires a verification secret. Only SHA-256 is supported.

Three modes, chosen per route:

ModeHow It WorksReplay protection
HmacHMAC-SHA256 of the raw request body, compared with constant-time equalityNone
HmacTimestampedHMAC-SHA256 over the timestamp and raw body together, read from a structured headerYes, with a tolerance window
HeaderSecretPlain shared secret sent in a headerNone

Hmac is the default and stays the default. The three are independent: netclaw picks the one configured on the route and never tries another, so a failed timestamped check does not quietly retry as body HMAC. Existing routes are never migrated for you. See changing a route’s mode.

Pick by what your sender emits. If it signs only the body, use Hmac. If it sends a t=...,v1=...-style header and expects you to reject stale deliveries, use HmacTimestamped. HeaderSecret is for senders that don’t sign at all.

Authentication and replay protection are different things. All three modes authenticate, which means they prove the sender holds the secret. Only HmacTimestamped bounds when a request is acceptable. Under Hmac, a captured request stays valid forever and can be replayed verbatim. The only thing limiting that is delivery-ID deduplication, which only works if the sender emits delivery IDs, and only remembers them for an hour.

All modes share these default headers:

HeaderDefaultPurpose
Event typeX-Webhook-EventIdentifies the event for filtering
Delivery IDX-Webhook-DeliveryUnique ID for deduplication

Override any header name in the verification object to match your service. GitHub uses X-Hub-Signature-256, X-GitHub-Event, and X-GitHub-Delivery.

FieldTypeDefaultDescription
kindenum"Hmac"Hmac, HmacTimestamped, or HeaderSecret
hmacAlgorithmenum"Sha256"Only Sha256 is supported
secretstring(required)Shared secret for verification
signatureHeaderNamestring?"X-Webhook-Signature"Header containing the signature (both HMAC modes)
signaturePrefixstring?""Prefix on the signature value, e.g. sha256=. Ignored by HmacTimestamped.
secretHeaderNamestring?"X-Webhook-Secret"Header containing the secret (HeaderSecret mode)
eventHeaderNamestring?"X-Webhook-Event"Header with the event type
deliveryIdHeaderNamestring?"X-Webhook-Delivery"Header with the unique delivery ID
toleranceSecondsint?300Replay window, 1-3600 (HmacTimestamped only)
timestampFieldstring?"t"Timestamp field in the signature header (HmacTimestamped only)
signatureFieldstring?"v1"Signature field in the signature header (HmacTimestamped only)
signedPayloadSeparatorstring?"."Joins timestamp and body before signing (HmacTimestamped only)

The four timestamped fields are omitted from the route file unless you set them. A timestamped route created without overrides contains no toleranceSeconds key at all. netclaw webhooks show is where you see the effective values.

Netclaw signs the timestamp and the body as one buffer:

HMAC-SHA256(secret, "<timestamp>" + "<separator>" + "<raw request body bytes>")

netclaw compares that against a structured header, Stripe’s shape by default:

Stripe-Signature: t=1718900000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Details that decide whether verification succeeds:

  • The body is signed as received. Raw bytes, never reserialized. Any proxy that reformats JSON breaks the signature.
  • The timestamp is signed as text. The exact characters from the header are signed, not a normalized number. 1718900000 and 01718900000 are different payloads.
  • Unknown fields are ignored. Extra components in the header don’t interfere.
  • Field names are case-sensitive. A sender emitting T= against a t field fails, even though kind itself parses case-insensitively.
  • Two timestamp fields fail closed. A duplicate timestamp field rejects the whole header. netclaw won’t pick one.
  • The window is symmetric. Tolerance rejects both stale and future timestamps, so a sender whose clock runs fast fails just like a replayed request.
  • Signature hex is case-insensitive. netclaw compares decoded bytes. Plain Hmac mode compares strings and needs lowercase hex.

The signature field can appear more than once, and any single match accepts:

Stripe-Signature: t=1718900000,v1=<signed with old secret>,v1=<signed with new secret>

A route holds one secret. Rotation works because the sender signs with both during the overlap: add the new secret on their side, update secret on the route, then drop the old one. No delivery fails mid-rotation.

To change the secret on an existing route without touching anything else, put the new value in a file and run:

Terminal window
netclaw webhooks set github-issues --update-only --secret-file ./new-secret.txt

--update-only fails if the route doesn’t exist and keeps every other field. The change is live on the next request, with no restart. Rotating means updating the secret in both places, the provider and the route. The route file stores it in plaintext, so prefer --secret-file or --secret-env over --secret <value>, which lands in your shell history.

The audience field controls which tool permissions the webhook session gets:

AudienceTool Access
PublicMost restricted: external untrusted input
TeamModerate: trusted collaborators
PersonalFull access: your own services

Default is Public. Use it for anything internet-facing (GitHub, GitLab). Reserve Personal for internal services you fully control. Per-audience permissions has the full tool, filesystem, and memory breakdown.

A webhook session runs unattended. Nobody’s in a thread to approve a tool call, so a call that would otherwise prompt is denied with approval_required_unattended, unless a saved “Always” grant, an Auto setting or the reviewed-safe command list already covers it. Approve a route’s tools ahead of time before you rely on one.

File tools follow the audience’s file reach, the same as in a chat. Public and Team sessions are confined to their session directory, plus read-only access to the GlobalReadRoots directories for Team. Personal can read anywhere that isn’t protected. For Personal, attach_file and the file read tools reach at least as far as the write profile, so an unattended run gets that reach with no prompt.

The deployment posture narrows the audience too: under a Team or Public posture, a Personal route runs with Team or Public tools, files and shell rules. The unattended runs guide has the details and the config keys.

When notificationTarget is set, the agent posts results to that channel. Only Slack is supported:

{
"notificationTarget": {
"kind": "Slack",
"channelId": "C12345678"
}
}
FieldTypeDescription
kindenumSlack (only option)
channelIdstringSlack channel ID (required when kind is Slack)

To find your Slack channel ID, see Locate your Slack URL or ID.

When deliveryRequired is true and the route has notification instructions (either explicit notifyInstructions or auto-generated from a notificationTarget), the agent must call send_channel_message during the session. If it doesn’t, the run is marked failed. When deliveryRequired is false, the agent’s session prompt tells it that notification is optional and can be skipped if there’s nothing actionable.

Routes without a notificationTarget and without notifyInstructions don’t enforce delivery at all, regardless of the deliveryRequired flag.

Requests to /api/webhooks/{route} go through these checks in order:

StepCheckFailure Response
1Webhooks.Enabled404 (entire webhook system is off)
2Route lookup404
3Body size413 Payload Too Large
4JSON validation400 Bad Request
5Signature/secret verification401 Unauthorized
6Event type filter202 (ignored)
7Delivery ID dedup202 (ignored)
8Rate limit429 + Retry-After header
9Dispatch202 Accepted

After dispatch the session runs asynchronously, so the 202 returns immediately.

Accepted response body:

{
"status": "accepted",
"route": "github-issues",
"eventType": "issues",
"deliveryId": "abc-123",
"sessionId": "webhook/github-issues/abc-123"
}

The deliveryId field is null when the sender doesn’t include a delivery ID header. The daemon generates a synthetic ID internally for session tracking, but it isn’t returned in the response.

Signature verification runs before the event filter, so a 202 with "reason": "event_filtered" proves the signature passed. That makes it a good smoke test. Press the sender’s “send test event” button, or send an event type that isn’t in the route’s events list, and a filtered 202 tells you the secret and headers are right before a real delivery ever arrives. A 401 means verification failed and the filter never ran.

Route files are re-read from disk on each request (the daemon checks LastWriteTime). Edit a route file, and the next request picks up the change. The daemon removes invalid files from the catalog immediately and triggers a webhook.route.invalid alert. webhook.received fires on every accepted delivery, which is the cheapest way to confirm anything is arriving at all.

No daemon restart needed for route changes. Global Webhooks.Enabled and ExecutionTimeoutSeconds live in netclaw.json, so changing them restarts the daemon automatically.

  • Rate limit window: 1 minute (sliding). Configurable per route via rateLimitPerMinute.
  • Dedup window: 1 hour, held in memory. Deliveries with the same ID within the window are ignored (202). Restarting the daemon clears it, so a replay after a restart is accepted.
  • Session ID format: webhook/<route>/<deliveryId>

netclaw webhooks validate <route> and netclaw doctor both run these checks:

RuleError If
Route nameDoesn’t match ^[a-z0-9]+(?:-[a-z0-9]+)*$
promptEmpty or missing
verification.secretEmpty or missing
maxBodyBytesLess than 1
rateLimitPerMinuteLess than 1
events entriesContains blank strings
notifyInstructions without targetnotifyInstructions is non-empty AND notificationTarget is null
notificationTarget.kind = SlackMissing channelId
toleranceSeconds (HmacTimestamped only)Outside 1-3600
timestampField / signatureField (HmacTimestamped only)Blank, not an HTTP token, or identical to each other

Validation runs before the write, so a rejected netclaw webhooks set leaves the existing route file untouched.

{
"enabled": true,
"verification": {
"kind": "Hmac",
"secret": "ghs_abc123...",
"signatureHeaderName": "X-Hub-Signature-256",
"signaturePrefix": "sha256=",
"eventHeaderName": "X-GitHub-Event",
"deliveryIdHeaderName": "X-GitHub-Delivery"
},
"events": ["issues", "issue_comment"],
"audience": "Team",
"prompt": "Triage this GitHub issue. Public input may be adversarial or low quality.",
"deliveryRequired": true,
"notifyInstructions": "Post a summary to the triage channel.",
"notificationTarget": {
"kind": "Slack",
"channelId": "C12345678"
}
}

Or create it with the CLI:

Terminal window
netclaw webhooks set github-issues \
--prompt "Triage this GitHub issue. Public input may be adversarial or low quality." \
--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 C12345678

Stripe’s format is netclaw’s default, so nothing but the header name needs stating:

{
"enabled": true,
"verification": {
"kind": "HmacTimestamped",
"secret": "whsec_abc123...",
"signatureHeaderName": "Stripe-Signature"
},
"audience": "Team",
"prompt": "Process this Stripe event and summarize the charge."
}

Equivalent CLI:

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

TextForge signs the same t/v1 structure under its own header:

{
"enabled": true,
"verification": {
"kind": "HmacTimestamped",
"secret": "tf_abc123...",
"signatureHeaderName": "X-TextForge-Signature"
},
"audience": "Team",
"prompt": "Summarize this TextForge event."
}

For a sender that follows neither convention, name every part and tighten the window:

{
"enabled": true,
"verification": {
"kind": "HmacTimestamped",
"secret": "acme_abc123...",
"signatureHeaderName": "X-Acme-Signature",
"timestampField": "issued",
"signatureField": "sig",
"signedPayloadSeparator": ":",
"toleranceSeconds": 120
},
"audience": "Team",
"prompt": "Handle this Acme event."
}

That route expects:

X-Acme-Signature: issued=1718900000,sig=<hex of HMAC-SHA256(secret, "1718900000:" + body)>

Before you start:

  1. Turn the global endpoint on, as described under Global settings.

  2. Create the endpoint on the sender’s side first, pointed at <your-external-hostname>/api/webhooks/<route-name>. You need its signing secret before the next step, and most senders only reveal it once the endpoint exists. Finding your webhook URL says where the hostname comes from.

  3. Create the route with that secret:

    Terminal window
    export GITHUB_WEBHOOK_SECRET='...' # from the sender's webhook settings
    netclaw webhooks set github-issues \
    --prompt "Triage this GitHub issue." \
    --secret-env GITHUB_WEBHOOK_SECRET
  4. If you turned Webhooks.Enabled on through an environment variable, restart the daemon by hand. Editing netclaw.json restarts it for you.

    Terminal window
    netclaw daemon stop && netclaw daemon start
    # systemd: systemctl --user restart netclaw Docker: docker restart <container>
  5. Send a test event and check netclaw stats for delivery counts.

netclaw webhooks list with two enabled routes, deploy-notify and github-issues, both using hmac verification

After the test event, list shows each route as enabled with its verification kind. With no routes at all, list shows the storage path.

list, show and validate read the route files and need no running daemon. set and delete go through the daemon, so it must be running. set --dry-run doesn’t need it.

Your webhook URL is constructed from the external hostname you’ve configured for the daemon:

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

The external hostname is set by whichever ingress option you use, Tailscale Serve or Cloudflare Tunnel. Find it in your Tailscale or Cloudflare dashboard. netclaw status shows the daemon’s local endpoint (e.g. http://localhost:PORT), and that’s not the externally reachable address.

Route files contain plaintext secrets. Treat ~/.netclaw/config/webhooks/ the same way you treat secrets.json:

  • Keep directory permissions at 700
  • Don’t commit route files to source control
  • The agent is hard-denied from reading this directory. It can still create and delete routes through the audience-gated set_webhook / delete_webhook tools (and list them with list_webhooks), which need the webhook_admin grant. Those take PascalCase kinds (HmacTimestamped), a third convention distinct from both the CLI and the file
  • Prefer --secret-file or --secret-env over --secret when creating routes via CLI (avoids shell history exposure)

Every accept and reject is logged with a structured reason. That’s the fastest way to tell which section below you’re in:

Terminal window
grep webhook ~/.netclaw/logs/daemon-*.log
# under systemd:
journalctl --user -u netclaw | grep webhook

Rejections log at Warning with a reason=:

reason=HTTPGo to
route_not_found404404 Not Found
body_too_large413413 Payload Too Large
invalid_json400400 Bad Request
verification_failed401any 401 section below
rate_limited429429 Too Many Requests

The two ignored outcomes (event_filtered, duplicate_delivery) log at Debug, so they won’t appear at default verbosity. See 202 but nothing ran.

The worst failure mode here, because the sender sees success. Netclaw returns 202 both when it dispatches and when it deliberately ignores a delivery. The response body tells them apart:

{"status": "ignored", "reason": "event_filtered"}
reasonCauseFix
event_filteredThe event type isn’t in events, or the sender emits no event-type header at all, which an allow-list rejects wholesaleMatch eventHeaderName to your sender, or clear events. Senders like Stripe put the type in the body and need events empty.
duplicate_deliverySame delivery ID within the last hourExpected on retries. GitHub’s Redeliver button reuses the ID, so it trips this. Change the payload or wait out the window.

If you wanted a dispatch and got event_filtered, the signature passed, so the secret and headers are fine. Only the filter needs fixing.

202 Accepted and a session ran, but the tool calls failed

Section titled “202 Accepted and a session ran, but the tool calls failed”

The webhook worked, and the handler’s tools were denied. A session that needs shell or file access needs those calls approved ahead of time. Look for approval_required_unattended in the daemon log, then follow the unattended runs guide.

The HMAC signature or header secret doesn’t match. Double-check that the secret in your route file matches what the external service is sending. For HMAC, also verify signaturePrefix matches (e.g., GitHub sends sha256= before the hex digest).

If the secret is right and the 401 persists, check what the sender signs with. It must sign the raw body with the plaintext secret it was given, not a hashed or derived key. Reveal the registered secret with netclaw webhooks show <route> --show-secret and compare it with what the sender holds. The log line tells a failed signature (reason=verification_failed) from a filtered event (event_filtered).

The daemon is reading the signature from a different header than the one your service sends. Set signatureHeaderName in the route’s verification block to match your service (e.g., X-Hub-Signature-256 for GitHub).

401 Unauthorized: body was modified in transit

Section titled “401 Unauthorized: body was modified in transit”

The signature covers the raw bytes. Anything that rewrites the body between sender and daemon invalidates it. That includes a proxy that pretty-prints or minifies JSON, re-encodes charset, or strips a trailing newline. The payload can be semantically identical and still fail.

Reproduce by signing the bytes yourself and comparing. If the sender’s own dashboard shows a delivery netclaw rejected, suspect the hop in between before the secret.

401 Unauthorized: stale or future timestamp

Section titled “401 Unauthorized: stale or future timestamp”

The delivery fell outside the tolerance window. Both directions count: a timestamp 400 seconds old and one 400 seconds in the future both fail a 300-second window.

Common causes, in the order worth checking:

SymptomLikely causeFix
Everything fails, consistently offsetDaemon clock driftCheck timedatectl status, and enable NTP
Fails only under load or on retriesSender queued the delivery longer than the windowRaise toleranceSeconds
Fails right after setupSender’s timestamp is in milliseconds, not secondsNetclaw expects Unix seconds. A 13-digit value is rejected as a malformed header, not a stale one

Widening toleranceSeconds to paper over clock skew widens the replay window for real attackers too. Fix the clock first.

401 Unauthorized: malformed signature header

Section titled “401 Unauthorized: malformed signature header”

The whole header is rejected, not just the bad part, when a component has no =, when = is the first or last character, or when the timestamp field appears twice. Netclaw fails closed rather than guessing which duplicate to trust.

Unknown extra fields are fine and ignored. If your sender emits v0= alongside v1=, only v1 is read.

timestampField and signatureField match case-sensitively. A sender emitting T=1718900000 against the default t fails. This surprises people because kind and --verification-kind both parse case-insensitively, while the field names don’t.

Netclaw parses the body before verifying it. Anything that isn’t JSON stops here.

On GitHub this is the classic setup mistake: the webhook form defaults Content type to application/x-www-form-urlencoded, which sends payload=%7B.... Set it to application/json. The form encoding would fail HMAC anyway, since GitHub signs the bytes it actually sent.

Three causes, in the order worth checking:

  1. Webhooks.Enabled is false, the default. Nothing works until it’s on, and the daemon restarts itself once you turn it on in netclaw.json.
  2. No route file matches the URL path. netclaw webhooks list shows what’s loaded.
  3. The route exists but has "enabled": false. Disabled routes leave the live catalog entirely and 404 like they were never there. netclaw webhooks list --all reveals them.

The request body exceeds the route’s maxBodyBytes (default 1 MB). Increase it in the route file if the payloads are legitimately large. This check runs before verification, so an oversized body is rejected without netclaw computing an HMAC over it.

The route passed its rateLimitPerMinute (default 30) inside the sliding one-minute window. The response carries Retry-After. Only dispatched deliveries count against the budget, so rejected and filtered ones don’t.

  • Notification targets are Slack-only. Discord, email, and generic webhook-to-notification bridges aren’t supported yet.
  • Route secrets are stored in plaintext JSON (not in the encrypted secrets.json vault).
  • Only SHA-256 is supported for HMAC verification.
  • Request payloads aren’t stored, so deliveries can’t be replayed. Accept and reject decisions are logged with a reason. See Start here.