Skip to content

Webhooks

FreeSDN has two webhook directions, both part of the Fabric subsystem:

  • Outbound - the built-in fabric.webhook operation 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/ingest accepts a payload from an external system and emits an ingest.external event 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.


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.

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"
}
}
}
ParameterTypeRequiredDefaultNotes
urlstringYes-Full URL including scheme. Must pass the SSRF guard (see below).
methodstringNoPOSTOne of POST, PUT, PATCH, GET.
payloadanyNotrigger event payloadSerialized as JSON. Supports {{...}} path references.
headersobjectNo{}Additional HTTP headers. Keys and values are strings.
  • 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_STATUS in the run log. The step does not retry; set continue_on_error: true on the step if you want the chain to proceed regardless.

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 (AWS 169.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.x IPv4-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.

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

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

Set FABRIC_WEBHOOK_SIGNING_SECRET in your .env file to enable HMAC-SHA256 request signing:

FABRIC_WEBHOOK_SIGNING_SECRET=a-long-random-secret-value

When a secret is configured, every fabric.webhook POST includes two headers:

HeaderValue
X-Fabric-Signaturesha256=<hmac-hex> - HMAC-SHA256 over "{timestamp}.{body_bytes}" (timestamp prepended, Stripe-style), using the signing secret.
X-Fabric-TimestampUnix 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.

The full Connection CRUD is at /api/v1/fabric. The endpoints you use most for webhooks:

MethodPathPurpose
GET/api/v1/fabric/catalogBrowse the full operation and event catalog, including fabric.webhook.
GET/api/v1/fabric/connections/suggestList operations compatible with a given source event, annotated with permission and artifact-match status.
POST/api/v1/fabric/connectionsCreate a Connection that includes a fabric.webhook step.
POST/api/v1/fabric/connections/{id}/testFire a Connection once with a test payload. Writes stage; webhook calls actually execute.
GET/api/v1/fabric/connections/{id}/runsView 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)”
  1. Open Fabric → Connections in the UI and click New Connection.
  2. 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.
  3. Add a condition if you want to filter (e.g. new_status eq "offline").
  4. Click Add step and select Call an external webhook (fabric.webhook).
  5. Enter the destination url.
  6. Optionally customize method, payload (using {{trigger.*}} references), and headers.
  7. Set Cooldown to prevent flood-firing on chatty events (max 86,400 seconds / 24 hours).
  8. Save. The Connection is live on the event bus immediately.
  9. Use Test to send a trial firing with a dummy payload and inspect the step output.

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.

POST /api/v1/fabric/ingest
X-API-Key: <api-key>
Content-Type: application/json
{
"name": "zabbix-alert",
"payload": {
"host": "switch-01.corp",
"severity": "high",
"trigger": "Interface down"
}
}
FieldTypeRequiredNotes
namestringNoIdentifier ≤ 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" }).
payloadobjectNoArbitrary 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}}.

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:

  1. Go to Settings → API Keys.
  2. Create a key with the event:write scope.
  3. Use that key as the X-API-Key: <key> header on ingest calls.

Do not use your personal session token for automated ingest calls.

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.

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.

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.


Every Connection firing is recorded as a ConnectionRun. To see past webhook deliveries:

  1. Open Fabric → Connections and click the Connection.
  2. Click the Runs tab.
  3. 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=50
Authorization: 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:

CodeMeaning
SSRF_BLOCKEDDestination resolved to a blocked IP (loopback, private, metadata). Add the host to FABRIC_WEBHOOK_ALLOWED_HOSTS if it is your own infrastructure.
BAD_STATUSDestination returned a non-2xx status. The response body is in output.
DELIVERY_ERRORTransport-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_DENIEDThe Connection author no longer holds the required permission. Re-check org-admin status.

ConcernDetail
Valkey required for at-most-onceUnder 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 processThe 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 volumeIf 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 rotationUpdate 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.

VariableDefaultPurpose
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_artifactsPersistent 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.


  • 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.external and calling back via fabric.webhook.