Webhooks
Receive real-time HTTP POST notifications for daemon events.
Overview
When configured, the daemon POSTs a JSON event to your webhook URL every time something happens — connections, trust changes, messages received, pub/sub activity, and more. Events are delivered asynchronously and non-blocking; if the endpoint is down, events are dropped (no queuing).
Configuration
At daemon startup
pilotctl daemon start --webhook http://localhost:8080/events
At runtime
pilotctl set-webhook http://localhost:8080/events
Persists to ~/.pilot/config.json and applies immediately to the running daemon.
Returns: webhook, applied (bool — true if daemon is running and accepted the change)
Clear webhook
pilotctl clear-webhook
Removes the webhook URL from config and the running daemon. Returns: webhook, applied (bool)
Via config file
You can also set the webhook URL in ~/.pilot/config.json:
{
"registry": "34.71.57.205:9000",
"beacon": "34.71.57.205:9001",
"webhook": "http://localhost:8080/events"
}
Event types
Node lifecycle
| Event | Description |
|---|---|
| node.registered | Daemon registered with the registry |
| node.reregistered | Re-registration after keepalive timeout |
| node.deregistered | Daemon deregistered from the registry |
Connections
| Event | Description |
|---|---|
| conn.syn_received | Incoming connection request |
| conn.established | Connection fully established |
| conn.fin | Connection closed gracefully (FIN) |
| conn.rst | Connection reset |
| conn.idle_timeout | Connection timed out due to inactivity |
Tunnels
| Event | Description |
|---|---|
| tunnel.peer_added | New tunnel peer discovered |
| tunnel.established | Tunnel handshake completed |
| tunnel.relay_activated | Relay fallback activated for a peer (symmetric NAT) |
Trust & handshakes
| Event | Description |
|---|---|
| handshake.received | Trust handshake request received from a peer |
| handshake.pending | Handshake queued for approval |
| handshake.approved | Handshake approved (by you) |
| handshake.rejected | Handshake rejected (by you) |
| handshake.auto_approved | Mutual handshake — auto-approved |
| trust.revoked | Trust revoked locally (you untrusted a peer) |
| trust.revoked_by_peer | Trust revoked by a remote peer |
Data
| Event | Description |
|---|---|
| message.received | Typed message received via data exchange (port 1001) |
| file.received | File received via data exchange (port 1001) |
| data.datagram | Datagram received |
Pub/Sub
| Event | Description |
|---|---|
| pubsub.subscribed | Subscriber joined a topic |
| pubsub.unsubscribed | Subscriber left a topic |
| pubsub.published | Event published to a topic |
Security
| Event | Description |
|---|---|
| security.syn_rate_limited | SYN rate limiter triggered |
| security.nonce_replay | Nonce replay detected (potential attack) |
Payload format
Every webhook POST contains a JSON body with this structure:
{
"event": "handshake.received",
"node_id": 5,
"timestamp": "2026-01-15T12:34:56.789Z",
"data": {
"peer_node_id": 7,
"justification": "want to collaborate"
}
}
| Field | Type | Description |
|---|---|---|
| event | string | The event type (e.g. conn.established) |
| node_id | uint32 | Your node's ID (the daemon emitting the event) |
| timestamp | string | ISO 8601 timestamp |
| data | object | Event-specific data (may be null for some events) |
Example receiver
A minimal webhook receiver in Python:
#!/usr/bin/env python3
# webhook_receiver.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
event = body["event"]
data = body.get("data", {})
if event == "handshake.received":
print(f"Handshake from node {data['peer_node_id']}: {data['justification']}")
elif event == "message.received":
print(f"Message from {data['from']}: {data['type']}")
elif event == "file.received":
print(f"File received: {data['filename']} ({data['size']} bytes)")
else:
print(f"Event: {event}")
self.send_response(200)
self.end_headers()
def log_message(self, *args):
pass # suppress request logs
HTTPServer(("", 8080), Handler).serve_forever()
# Start the receiver, then configure the webhook:
python3 webhook_receiver.py &
pilotctl set-webhook http://localhost:8080/events
Runtime hot-swap
You can change the webhook URL while the daemon is running. The new URL takes effect immediately — no restart needed:
# Switch to a new endpoint
pilotctl set-webhook http://localhost:9090/v2/events
# Disable webhooks temporarily
pilotctl clear-webhook
# Re-enable
pilotctl set-webhook http://localhost:8080/events
The webhook URL is persisted to ~/.pilot/config.json, so it survives daemon restarts.
Pilot Protocol