Webhooks
FreeSDN has two webhook directions, both part of the Fabric subsystem:
- Outbound - the built-in
fabric.webhookoperation POSTs event or step data to an external URL (n8n, Zapier, Make, Node-RED, a custom HTTP receiver). It is SSRF-guarded and optionally HMAC-signed. - Inbound -
POST /api/v1/fabric/ingestaccepts a payload from an external system and emits aningest.externalevent that any Connection can react to.
Neither direction requires you to write code. Both are wired through the Fabric Connection builder in the UI at Fabric → Connections.
Outbound webhooks (fabric.webhook)
Section titled “Outbound webhooks (fabric.webhook)”fabric.webhook is one of three built-in sink operations (alongside fabric.notify and fabric.log). You add it as a step in any Connection. When the Connection fires, Fabric serializes the payload and POSTs it to the URL you configured.
What it sends
Section titled “What it sends”By default the request body is the trigger event’s payload. You can override payload with a static dict or a templated value (see Connections - templating for the {{trigger.*}} / {{steps.0.output.*}} path syntax).
{ "operation_id": "fabric.webhook", "params": { "url": "https://n8n.example.com/webhook/freesdn", "method": "POST", "payload": { "device_id": "{{trigger.device_id}}", "status": "{{trigger.new_status}}", "site_id": "{{trigger.site_id}}" }, "headers": { "X-Org-Token": "secret-value" } }}Parameters
Section titled “Parameters”| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
url | string | Yes | - | Full URL including scheme. Must pass the SSRF guard (see below). |
method | string | No | POST | One of POST, PUT, PATCH, GET. |
payload | any | No | trigger event payload | Serialized as JSON. Supports {{...}} path references. |
headers | object | No | {} | Additional HTTP headers. Keys and values are strings. |
Transport guarantees
Section titled “Transport guarantees”- Timeout: 30 seconds.
- TLS: Certificate verification is enabled for HTTPS destinations (
verify_tls=True). Plain HTTP URLs to public IPs are accepted; destinations that resolve to blocked ranges (loopback, private, cloud-metadata) are refused regardless of scheme. - Response: capped at 16 KiB. The status code and bounded body are returned as the step
output, so a later step can branch on success or failure. - Non-2xx: recorded as
BAD_STATUSin the run log. The step does not retry; setcontinue_on_error: trueon the step if you want the chain to proceed regardless.
SSRF protection
Section titled “SSRF protection”Every outbound call goes through safe_http_request - a resolve-once, IP-pinned guard that refuses destinations in:
- loopback (
127.0.0.0/8,::1) - link-local (
169.254.0.0/16) - which covers cloud metadata endpoints (AWS169.254.169.254, Azure IMDS, GCP metadata) - RFC 1918 private ranges (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) - carrier-grade NAT / shared address space (
100.64.0.0/10) - includes Tailscale mesh IPs; this is why a private mesh DNS hostname needs allow-listing even though it is not RFC 1918 - IPv6 private/ULA (
fc00::/7), link-local (fe80::/10), and multicast (ff00::/8);::ffff:x.x.x.xIPv4-mapped addresses are normalized to their IPv4 form before checking, so none of the above can be evaded via the mapped form
DNS is resolved once at call time. The IP is pinned for the life of the connection, so a DNS-rebind attack cannot redirect the call mid-flight.
Allow-listing private hosts
Section titled “Allow-listing private hosts”Set FABRIC_WEBHOOK_ALLOWED_HOSTS in your .env file (deploy-owner controlled - not a per-org setting). The value is a comma-separated list of hostnames or IPs:
FABRIC_WEBHOOK_ALLOWED_HOSTS=n8n.example.net,192.168.1.150Hosts on the allow-list bypass the private-range block but still go through DNS pinning and TLS verification. Cloud-metadata IPs are never reachable regardless of this setting.
Signing outbound requests
Section titled “Signing outbound requests”Set FABRIC_WEBHOOK_SIGNING_SECRET in your .env file to enable HMAC-SHA256 request signing:
FABRIC_WEBHOOK_SIGNING_SECRET=a-long-random-secret-valueWhen a secret is configured, every fabric.webhook POST includes two headers:
| Header | Value |
|---|---|
X-Fabric-Signature | sha256=<hmac-hex> - HMAC-SHA256 over "{timestamp}.{body_bytes}" (timestamp prepended, Stripe-style), using the signing secret. |
X-Fabric-Timestamp | Unix timestamp (seconds) at send time - for a replay-window check. |
The HMAC is computed over the timestamp and body concatenated as "{timestamp}.{body_bytes}" (Stripe-style). Bind the timestamp into the signed message so a replayed delivery outside the skew window is rejected even if the signature is otherwise valid:
import hashlib, hmac, time
def verify(body: bytes, signature: str, timestamp: str, secret: str, max_age_seconds: int = 300) -> bool: # Replay-window check if abs(time.time() - int(timestamp)) > max_age_seconds: return False signed = timestamp.encode() + b"." + body expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)When FABRIC_WEBHOOK_SIGNING_SECRET is empty (the default), no signature headers are added. If your receiver requires a signature, leave the secret empty and handle authentication via a shared token in the headers param instead.
Endpoint reference (Fabric API)
Section titled “Endpoint reference (Fabric API)”The full Connection CRUD is at /api/v1/fabric. The endpoints you use most for webhooks:
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/fabric/catalog | Browse the full operation and event catalog, including fabric.webhook. |
GET | /api/v1/fabric/connections/suggest | List operations compatible with a given source event, annotated with permission and artifact-match status. |
POST | /api/v1/fabric/connections | Create a Connection that includes a fabric.webhook step. |
POST | /api/v1/fabric/connections/{id}/test | Fire a Connection once with a test payload. Writes stage; webhook calls actually execute. |
GET | /api/v1/fabric/connections/{id}/runs | View delivery history and step outputs for past firings. |
Requires an authenticated session. Creating and editing Connections requires an unscoped org-admin role. Viewing run history is open to all org members, but step params (which may contain webhook URLs or auth headers) are redacted for non-authors - the entire params blob is replaced with {"__redacted__": "hidden - author-only"}.
Building an outbound webhook Connection (procedure)
Section titled “Building an outbound webhook Connection (procedure)”- Open Fabric → Connections in the UI and click New Connection.
- Under Source event, pick the event that should trigger the call. Common choices:
device.status.changed- a managed device went online or offline.controller.change.applied- a staged write was applied to a device.ingest.external- relay an inbound webhook to a downstream system.
- Add a condition if you want to filter (e.g.
new_status eq "offline"). - Click Add step and select Call an external webhook (
fabric.webhook). - Enter the destination
url. - Optionally customize
method,payload(using{{trigger.*}}references), andheaders. - Set Cooldown to prevent flood-firing on chatty events (max 86,400 seconds / 24 hours).
- Save. The Connection is live on the event bus immediately.
- Use Test to send a trial firing with a dummy payload and inspect the step output.
Inbound webhooks (/fabric/ingest)
Section titled “Inbound webhooks (/fabric/ingest)”POST /api/v1/fabric/ingest is the inbound half of the external bridge. An external system posts to it, FreeSDN emits an ingest.external event on the internal bus, and any Connection whose source event matches ingest.external fires.
Request format
Section titled “Request format”POST /api/v1/fabric/ingestX-API-Key: <api-key>Content-Type: application/json
{ "name": "zabbix-alert", "payload": { "host": "switch-01.corp", "severity": "high", "trigger": "Interface down" }}| Field | Type | Required | Notes |
|---|---|---|---|
name | string | No | Identifier ≤ 64 chars. Lowercased; characters outside [a-z0-9_-] stripped. Lets you route different callers via a Connection condition on the name field (e.g. { "field": "name", "operator": "eq", "value": "zabbix-alert" }). |
payload | object | No | Arbitrary JSON. Defaults to {}. Hard cap: 64 KiB - requests over the limit receive 413. The caller’s object is wrapped in the event as {"name": ..., "data": <payload>} - it is not forwarded as-is. In Connection templates, access fields as {{trigger.data.host}}, not {{trigger.host}}. |
Authentication
Section titled “Authentication”Ingest requires the event:write permission. A scoped API key that only has event:read is refused. Issue a dedicated key for each external system that posts to ingest:
- Go to Settings → API Keys.
- Create a key with the
event:writescope. - Use that key as the
X-API-Key: <key>header on ingest calls.
Do not use your personal session token for automated ingest calls.
Rate limiting
Section titled “Rate limiting”Ingest is throttled per org: 120 requests per 60-second window, enforced cluster-wide via Valkey. Requests over the limit receive 429 Too Many Requests. The limit is not configurable per org.
Response
Section titled “Response”A 202 Accepted response confirms the event was accepted and dispatched:
{ "accepted": true, "event_type": "ingest.external", "name": "zabbix-alert"}The 202 does not mean any Connection has fired or completed - it means the event entered the bus. Check Connection run history to confirm delivery.
Routing inbound payloads to different Connections
Section titled “Routing inbound payloads to different Connections”event_type is always ingest.external (not operator-controlled - callers cannot spoof a native event type). Differentiate by name using a Connection condition:
{ "source_event": "ingest.external", "conditions": { "logic": "and", "conditions": [ { "field": "name", "operator": "eq", "value": "zabbix-alert" } ] }, "steps": [...]}You can have multiple Connections all listening on ingest.external and each matching a different name value.
Preventing ingest loops
Section titled “Preventing ingest loops”A common pattern is: ingest.external → fabric.webhook → external system → POST /fabric/ingest → .... The Negotiator’s per-Connection cooldown bounds this. Set a cooldown of at least a few seconds on any Connection that could participate in a loop.
Viewing delivery history
Section titled “Viewing delivery history”Every Connection firing is recorded as a ConnectionRun. To see past webhook deliveries:
- Open Fabric → Connections and click the Connection.
- Click the Runs tab.
- Each row shows: timestamp, success/failure, step-level output (including the webhook response status and body).
Via the API:
GET /api/v1/fabric/connections/{connection_id}/runs?limit=50Authorization: Bearer <token>Returns up to 200 rows per page. The steps field in each run contains per-step output, success, and any error_code.
Error codes you may see on a fabric.webhook step:
| Code | Meaning |
|---|---|
SSRF_BLOCKED | Destination resolved to a blocked IP (loopback, private, metadata). Add the host to FABRIC_WEBHOOK_ALLOWED_HOSTS if it is your own infrastructure. |
BAD_STATUS | Destination returned a non-2xx status. The response body is in output. |
DELIVERY_ERROR | Transport-level failure (connection timeout, connection reset, DNS resolution failure that passed the SSRF guard, etc.). The destination was considered reachable by the SSRF guard but the HTTP exchange failed. Check that the destination service is up and that the 30-second timeout is sufficient. |
PERMISSION_DENIED | The Connection author no longer holds the required permission. Re-check org-admin status. |
Production constraints
Section titled “Production constraints”| Concern | Detail |
|---|---|
| Valkey required for at-most-once | Under multiple API workers, the at-most-once and cooldown guards are cluster-wide only when Valkey is reachable. Without it, each worker fires the chain independently. Run at least the Pro tier (which includes the redis / Valkey service). |
| Negotiator runs in the API process | The Negotiator and artifact broker run inside the API container, not in the Celery worker. Do not expect Celery worker restarts to affect in-flight Connection chains. |
| Durable artifact dir must be a persistent volume | If a Connection step produces an artifact that feeds a downstream fabric.webhook payload, set FABRIC_ARTIFACT_DURABLE_DIR to a persistent volume path. The default path is ephemeral - a container restart loses staged-write blobs awaiting sign-off. |
| Signing secret rotation | Update FABRIC_WEBHOOK_SIGNING_SECRET and restart the API container. There is no grace period - the old and new secrets are not both valid simultaneously. Coordinate the rotation with your receiver. |
Environment variables
Section titled “Environment variables”| Variable | Default | Purpose |
|---|---|---|
FABRIC_WEBHOOK_ALLOWED_HOSTS | "" (empty) | Comma-separated hostnames or IPs that fabric.webhook may reach even if in a private range. Cloud-metadata IPs are always blocked. Example: n8n.example.net,192.168.1.150. |
FABRIC_WEBHOOK_SIGNING_SECRET | "" (empty) | HMAC-SHA256 signing secret. When set, every outbound POST includes X-Fabric-Signature and X-Fabric-Timestamp. Empty = unsigned. |
FABRIC_ARTIFACT_DURABLE_DIR | /data/fabric_artifacts | Persistent directory for staged-write blobs. Mount a volume here in production. |
REDIS_URL | - | Valkey/Redis URL used for at-most-once, cooldown, and ingest-throttle guards. Required for correct multi-worker behavior. |
All four variables go in your .env.<tier> file alongside the rest of the stack configuration.
Next steps
Section titled “Next steps”- The Fabric - catalog vocabulary, the executor, and the read-vs-write model.
- Connections - full authoring guide: schema fields, event patterns, conditions, templating, and the Negotiator.
- Operations and Events - browse the full built-in operation and event catalog.
- n8n Integration - using the FreeSDN n8n community node to build workflows triggered by
ingest.externaland calling back viafabric.webhook.