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.

  1. Turn the global endpoint on: netclaw configInbound Webhooks. It’s false by default and every route 404s until it’s on. The editor shows live route counts, and warns if you enable webhooks with no valid routes.

    Inbound Webhooks editor

  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.

  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. Restart the daemon once to pick up Webhooks.Enabled:

    Terminal window
    netclaw daemon stop && netclaw daemon start
  5. Send a test event and check netclaw stats for delivery counts.

After that first restart, route changes are hot-reloaded per request. Global Webhooks.Enabled and ExecutionTimeoutSeconds changes still need one.

netclaw webhooks list showing the route directory path

All CLI route management works offline — no running daemon required.

For scripted or headless installs, set Webhooks.Enabled directly in ~/.netclaw/config/netclaw.json. Interactive installs should use netclaw config → Inbound Webhooks instead.

{
"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.

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).

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)

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 — 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 — 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.

Verification below has the field reference and the signing protocol.

{
"prompt": "Summarize this event and log the result.",
"verification": {
"kind": "Hmac",
"secret": "your-shared-secret"
}
}
{
"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)>

Every route requires a verification secret. Only SHA-256 is supported. 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.

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 autonomously — nobody’s in a thread to approve a tool call — so a tool that would otherwise prompt gets denied by default. One exception worth knowing: a matching grant already sitting in the persistent approval store still lets that tool through.

The audience’s non-interactive tools run normally. File tools are scoped per audience: Public and Team sessions are confined to the session directory, while Personal widens to the autonomous filesystem zone.

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 — 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.

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 changes do require a restart.

  • 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.

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, 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 eventsor 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.

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).

401 Unauthorized — wrong signature header

Section titled “401 Unauthorized — wrong signature header”

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 — 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; 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 — 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 it needs a daemon restart.
  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 — rejected and filtered ones don’t.

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) — that’s not the externally reachable address.

  • 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.