Webhook to Slack Flow
A complete example flow that receives webhooks and sends notifications to Slack, built entirely from shipped components.
Overview
This flow demonstrates:
- HTTP webhook reception (
http_server, http-module) - Conditional routing (
router, common-module) - External API integration (
http_request, http-module) - Data mapping with edge expressions — no glue components
- Error handling with the
retrycomponent
Flow Architecture
+---------------+
+--->| out_critical |---+
| +---------------+ |
+--------+ +-----------+ +----+----------+ v
| signal |-->| http_server|-->| router | +--------------+ +-------------+
| (Start)| | request -->| | (by severity) |--->| http_request |-->| http_server |
+--------+ +-----------+ +----+----------+ | (Slack API) | | response |
| +---------+ +------+-------+ +-------------+
+--->| default | |
| (drop / | | error
| debug) | v
+---------+ +---------+
| retry |
+---------+
All components are real: signal, router, retry, debug from common-module; http_server, http_request from http-module.
Node Configurations
1. Webhook Server
Component: http-module/http_server
The server does not run until its start port receives a message — wire a signal node into start and click Send once. The public URL then appears on the server's _control port (listenAddr); never guess the address.
_settings port configuration:
{
"enableStatusPort": false,
"enableStopPort": true
}
Edge signal.out -> webhook-server.start configuration:
{
"autoHostName": true,
"readTimeout": 60,
"writeTimeout": 10
}
Each incoming HTTP request emits on the request port as {context, requestURI, requestParams, host, method, realIP, headers, body, scheme}. The body is a raw string.
2. Severity Router
Component: common-module/router
_settings port configuration:
{
"routes": ["CRITICAL", "WARNING"],
"enableDefaultPort": true
}
This produces output ports out_critical, out_warning, and default.
Edge webhook-server.request -> severity-router.input configuration:
Senders call POST /webhook/alerts?severity=critical&source=db-monitor with the alert text as the body. The conditions are boolean-returning expressions evaluated per message:
{
"context": {
"severity": "{{$.requestParams.severity[0]}}",
"source": "{{$.requestParams.source[0]}}",
"message": "{{$.body}}",
"receivedAt": "{{RFC3339(now())}}"
},
"conditions": [
{ "route": "CRITICAL", "condition": "{{$.requestParams.severity[0] == 'critical' || $.requestParams.severity[0] == 'error'}}" },
{ "route": "WARNING", "condition": "{{$.requestParams.severity[0] == 'warning'}}" }
]
}
The router forwards the payload under a context key: each out_<route> port emits { "context": {...} }, so downstream edges read $.context.severity — not $.severity.
3. Slack API Client
Component: http-module/http_request
_settings port configuration:
{
"enableErrorPort": true
}
Edge severity-router.out_critical -> slack-client.request configuration:
The Slack token comes from a secret placeholder — never a literal:
{
"context": "{{$.context}}",
"method": "POST",
"timeout": 10,
"url": "https://slack.com/api/chat.postMessage",
"contentType": "application/json",
"headers": [
{ "key": "Authorization", "value": "Bearer [[secret:slack/botToken]]" }
],
"body": "{\"channel\": \"#alerts-critical\", \"text\": \"🚨 {{upper($.context.severity)}} from {{$.context.source}}: {{$.context.message}}\"}"
}
Edge severity-router.out_warning -> slack-client.request is identical except channel and prefix:
{
"context": "{{$.context}}",
"method": "POST",
"timeout": 10,
"url": "https://slack.com/api/chat.postMessage",
"contentType": "application/json",
"headers": [
{ "key": "Authorization", "value": "Bearer [[secret:slack/botToken]]" }
],
"body": "{\"channel\": \"#alerts-warning\", \"text\": \"⚠️ {{$.context.source}}: {{$.context.message}}\"}"
}
severity-router.default — leave it unwired to silently drop unknown severities, or wire it to a debug node (common-module) to inspect them on the dashboard.
4. Response to the Webhook Caller
Edge slack-client.response -> webhook-server.response configuration:
http_server blocks each HTTP request until data arrives back on its response port, so the flow answers the webhook caller by completing the loop:
{
"statusCode": 200,
"contentType": "application/json",
"body": "{\"status\": \"accepted\", \"severity\": \"{{$.context.severity}}\"}"
}
5. Retry on Slack Failures
Component: common-module/retry
http_request marks network failures, 429 and 5xx as retryable (retryable: true on its error port — the canonical {context, error, retryable} shape). Wire the loop explicitly:
slack-client.error -> retry-slack.request(mapcontext,retryable,errorstraight through)retry-slack.retry -> slack-client.request(re-map the request fields from$.context)retry-slack.failed -> debug(dead-letter for alerting)
_settings port configuration:
{
"maxAttempts": 5,
"initialDelayMs": 500,
"maxDelayMs": 30000,
"backoff": "exponential",
"jitter": true
}
Non-retryable errors (4xx — bad channel, revoked token) go straight to failed without sleeping.
Complete Flow Definition
The editor and the MCP tools (build_flow) write these resources for you; shown here decoded for readability (port configuration is JSON stored as bytes). Edges are declared on the node that owns the source port, with to as "node:port"; edge data mapping lives in the target node's spec.ports entry with from set:
apiVersion: operator.tinysystems.io/v1alpha1
kind: TinyNode
metadata:
name: webhook-server
namespace: tinysystems
spec:
module: http-module
component: http_server
ports:
- port: _settings
configuration: >
{"enableStatusPort": false, "enableStopPort": true}
edges:
- id: edge-request-router
port: request
to: "severity-router:input"
flowID: flow-webhook-alerts
---
apiVersion: operator.tinysystems.io/v1alpha1
kind: TinyNode
metadata:
name: severity-router
namespace: tinysystems
spec:
module: common-module
component: router
ports:
- port: _settings
configuration: >
{"routes": ["CRITICAL", "WARNING"], "enableDefaultPort": true}
- port: input
from: "webhook-server:request"
configuration: >
{"context": {"severity": "{{$.requestParams.severity[0]}}",
"source": "{{$.requestParams.source[0]}}",
"message": "{{$.body}}"},
"conditions": [
{"route": "CRITICAL", "condition": "{{$.requestParams.severity[0] == 'critical'}}"},
{"route": "WARNING", "condition": "{{$.requestParams.severity[0] == 'warning'}}"}]}
edges:
- id: edge-critical-slack
port: out_critical
to: "slack-client:request"
flowID: flow-webhook-alerts
- id: edge-warning-slack
port: out_warning
to: "slack-client:request"
flowID: flow-webhook-alerts
# ... slack-client and retry-slack nodes follow the same shape
Note there is no data field on an edge — an edge is {id, port, to, flowID} plus an optional retryPolicy; all mapping lives in port configurations.
Testing the Flow
Sample Webhook Payloads
Read the real URL from the server's _control port first.
Critical Alert:
curl -X POST "https://<listenAddr>/webhook/alerts?severity=critical&source=db-monitor" \
-H "Content-Type: text/plain" \
-d 'Unable to connect to primary database. Failover initiated.'
Warning Alert:
curl -X POST "https://<listenAddr>/webhook/alerts?severity=warning&source=metrics-collector" \
-d 'Memory usage at 85%. Consider scaling.'
Info Alert (falls through to default — dropped or debugged):
curl -X POST "https://<listenAddr>/webhook/alerts?severity=info&source=ci-pipeline" \
-d 'Version 2.3.1 deployed successfully'
Expected Slack Output
Critical Alert (#alerts-critical):
🚨 CRITICAL from db-monitor: Unable to connect to primary database. Failover initiated.
Warning Alert (#alerts-warning):
⚠️ metrics-collector: Memory usage at 85%. Consider scaling.
Key Patterns Used
1. Mapping on Edges, Not Glue Nodes
Normalization happens in edge expressions (JSONPath over the source port's output, ternaries, &&/||, functions like upper(), now(), RFC3339()). The transform component exists for same-node loops (e.g. request→response on one http_server), not as inter-node glue.
2. Conditions Computed Per Message
The router holds no comparison logic — booleans are computed on the edge, and the first true condition wins:
{ "route": "CRITICAL", "condition": "{{$.requestParams.severity[0] == 'critical'}}" }
3. Blocking Request/Response Loop
http_server.request → ... → http_server.response completes synchronously; the webhook caller gets the real outcome, not a blind 200.
4. Explicit, Bounded Retry
Retry is a visible loop in the graph (error → retry → request), honoring the retryable flag — nothing retries implicitly.
Extending the Flow
Add Deduplication
Prevent duplicate alerts with database-module/redis_dedup between the router and the Slack client — it routes first-seen IDs to out_new and repeats to out_seen:
{
"context": "{{$.context}}",
"url": "redis://redis.internal:6379",
"keyPrefix": "alerts",
"id": "{{$.context.source}}-{{$.context.severity}}",
"ttlSeconds": 300
}
Add Acknowledgment Tracking
Store alerts with database-module/postgres_exec:
{
"dsn": "postgres://app:[[secret:db/password]]@db.internal:5432/alerts",
"sql": "INSERT INTO alerts (severity, source, message, created_at) VALUES ($1, $2, $3, $4)",
"params": ["{{$.context.severity}}", "{{$.context.source}}", "{{$.context.message}}", "{{RFC3339(now())}}"]
}
Guard Costs
Put common-module/budget_guard in front of paid downstream calls to cap spend per window.
Monitoring
Track flow metrics with common-module/flow_telemetry, or via traces:
- Webhook requests/minute: Monitor incoming traffic
- Slack API latency: Track external API performance
- Retry exhaustion (
failedport) rate: Alert on delivery failures - Route distribution: See which severity levels are most common