ControlForge Outbound Webhooks
June 2026 | ControlForge v1.0.1069
An outbound webhook pushes a ControlForge event out to an HTTP endpoint — a Slack incoming-webhook URL, a Microsoft Teams connector, a PagerDuty Events-API route, or any generic JSON receiver. You register a destination once; from then on every event that matches its severity and type filters is delivered automatically, with bounded retries, and anything that still can't be delivered lands in a dead-letter queue (DLQ) you can inspect and replay later.
There are two ways to send, and they are deliberately different tools:
| Path | What it is | Retry / DLQ |
|---|---|---|
Registered webhook (config + /api/webhooks) | A named, supervised worker fed by the event bus. Format adapters (slack/teams/pagerduty/generic), severity + event-type filters, retry budget, rate limiting, optional HMAC signing. | Yes — full retry + dead-letter + delivery history |
WEBHOOK_SEND / WEBHOOK_SEND_ASYNC (ST) | A raw HTTP POST straight to a URL you pass in. No registration, no filters, no formatting. | No — fire-and-forget; nothing is recorded |
Use a registered webhook when you want delivery guarantees and operability (the normal alerting path). Use the ST functions for a one-off poke at an arbitrary URL from inside a scan.
The ST functions
Both are registered built-ins — verify on a running target with GET /api/docs/functions?search=WEBHOOK_SEND.
WEBHOOK_SEND(url : STRING, payload : STRING) : DINT
WEBHOOK_SEND_ASYNC(url : STRING, payload : STRING) : BOOL
WEBHOOK_SEND— a BLOCKING POST. Returns the HTTP status code (e.g.200), or0on a transport error (connection refused, DNS failure, timeout).Content-Typedefaults toapplication/json. Two optional trailing args extend it:WEBHOOK_SEND(url, payload, content_type, timeout_s)— pass a custom content type and a per-call timeout in seconds (default 10 s). Because it blocks, network latency counts against your scan watchdog — call it sparingly.WEBHOOK_SEND_ASYNC— a non-blocking POST. Spawns a background send and returnsTRUEimmediately; the HTTP result is discarded. Use it for fire-and-forget notifications where you only care that the call was scheduled, not whether it succeeded.Content-Typeis alwaysapplication/json.
Neither function touches the registered-webhook machinery: no retries, no dead-letter, no history. They are exactly what they say — a POST to a URL.
PROGRAM AlertOnTrip
VAR
trip : BOOL; // some fault condition
trip_os : R_TRIG; // fire once on rising edge
http_code : DINT;
payload : STRING;
END_VAR
trip_os(CLK := trip);
IF trip_os.Q THEN
// Slack incoming-webhook expects {"text": "..."}
payload := '{"text":"Line 3 overload trip"}';
// Fire-and-forget — never blocks the scan:
WEBHOOK_SEND_ASYNC('https://hooks.slack.com/services/T000/B000/XXXX', payload);
// OR, if you need the status code and accept the latency:
// http_code := WEBHOOK_SEND('https://hooks.slack.com/services/T000/B000/XXXX', payload);
// http_code = 0 means the POST never reached the server.
END_IF;
END_PROGRAM
Tip: to push an event into a registered webhook from ST (and get retries/DLQ), don't use
WEBHOOK_SEND— emit an event instead withNOTIFY(channel, message)orNOTIFY_CRITICAL(message), and subscribe a webhook tonotify.*. See "Firing a registered webhook from ST" below.
Registering a webhook
A registered webhook is an events.WebhookConfig. name and url are required; everything else has a sensible default. The config can live in your project YAML under events.webhooks:, or be added at runtime via POST /api/webhooks.
Config fields (defaults applied on add):
| Field | Default | Meaning |
|---|---|---|
name | — (required) | Unique identifier for this destination |
url | — (required) | Where to POST. Machine-local — stripped from the shared project file |
format | generic | Body adapter: generic, slack, teams, pagerduty |
min_severity | info | Drop events below this severity (info/warning/error/critical) |
event_types | ["*"] | Glob filter: *, protocol.*, or a literal like task.fault |
routing_key | — | PagerDuty Events-API routing key (pagerduty format only) |
headers | — | Extra HTTP headers (map) |
retry_count | 3 | Retries after the first attempt. Set -1 for no retries (normalizes to 0) |
retry_delay_ms | 1000 | Delay between attempts |
timeout_ms | 10000 | Per-attempt HTTP timeout |
secret | — | Optional HMAC-SHA256 signing key → adds X-GoPLC-Signature: sha256=<hex> over the raw body. Machine-local |
rate_limit_per_min | 0 (off) | Cap deliveries per rolling 60 s window; excess counted as dropped |
rate_limit_bypass_severity | error (when a cap is set) | Events at/above this severity always pass the cap; none disables the bypass |
Register one at runtime (Slack format, alerts + notify events only, 2 retries 500 ms apart):
# Grab a token
TOKEN=$(curl -s -X POST http://localhost:8302/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"goplc","password":"goplc"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
curl -s -X POST http://localhost:8302/api/webhooks \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{
"name": "slack-ops",
"url": "https://hooks.slack.com/services/T000/B000/XXXX",
"format": "slack",
"min_severity": "warning",
"event_types": ["notify.*", "alarm.*", "task.fault"],
"retry_count": 2,
"retry_delay_ms": 500,
"timeout_ms": 2000
}'
# => {"name":"slack-ops","status":"added"}
POST /api/webhooks is create-or-replace (idempotent on name). To replace only an existing one (404 if it's missing), use PUT /api/webhooks/{name} with the same body shape — the {name} path is authoritative, so you can't rename via PUT.
List the configured destinations with their live stats:
curl -s http://localhost:8302/api/webhooks -H "Authorization: Bearer $TOKEN"
{
"webhooks": [
{
"name": "slack-ops",
"url": "https://hooks.slack.com/services/T000/B000/XXXX",
"format": "slack",
"delivered": 0, "failed": 0, "retried": 0, "dropped": 0, "bypassed": 0,
"currently_rate_limited": false,
"signed": false,
"last_ok": "0001-01-01T00:00:00Z"
}
]
}
Remove a webhook:
curl -s -X DELETE http://localhost:8302/api/webhooks/slack-ops -H "Authorization: Bearer $TOKEN"
# => {"name":"slack-ops","status":"removed"}
Sending a test event
GET /api/webhooks/{name}/test synthesizes a test event and delivers it directly to the destination, bypassing the severity/type filters — the fastest way to confirm the URL, format, and auth are right.
curl -s http://localhost:8302/api/webhooks/slack-ops/test -H "Authorization: Bearer $TOKEN"
On success you get the HTTP status the receiver returned:
{ "name": "slack-ops", "status": 200, "ok": true }
On a transport failure the error surfaces verbatim (here the URL pointed at a dead port):
{ "error": "Post \"http://127.0.0.1:9/never\": dial tcp 127.0.0.1:9: connect: connection refused", "status": 0 }
The
/testpath is a one-shot direct send — it does not run the retry budget and does not write a delivery-history or dead-letter row.
Firing a registered webhook from ST
Registered webhooks are driven by the event bus, not by the WEBHOOK_SEND functions. To push from ST and get retries + DLQ, emit a notify.* event and subscribe the webhook to it:
PROGRAM Notifier
VAR
overload : BOOL;
overload_os : R_TRIG;
ok : BOOL;
END_VAR
overload_os(CLK := overload);
IF overload_os.Q THEN
// emits event type "notify.slack-ops" — any webhook whose
// event_types includes "notify.*" picks it up, formats it,
// and delivers it with the configured retry budget.
ok := NOTIFY('slack-ops', 'Line 3 overload trip');
END_IF;
END_PROGRAM
NOTIFY(channel, message) publishes notify.<channel> at info severity; NOTIFY_CRITICAL(message) publishes notify.critical at critical severity. Confirm both on a running target with GET /api/docs/functions?search=NOTIFY. By convention name the channel after the webhook ('slack-ops') so the wiring reads cleanly.
Inspecting delivery history
Every real delivery attempt (event-bus driven, including retries) is recorded when the event log is enabled (it is by default). GET /api/webhooks/{name}/history?limit=N returns the most recent attempts, newest first:
curl -s "http://localhost:8302/api/webhooks/slack-ops/history?limit=50" -H "Authorization: Bearer $TOKEN"
{
"webhook": "slack-ops",
"count": 1,
"history": [
{
"id": 42,
"event_id": "evt_01H...",
"webhook": "slack-ops",
"status": 200,
"attempt": 1,
"sent_at": "2026-06-22T04:46:24Z",
"response": "ok"
}
]
}
status is the HTTP code (0 = transport error), attempt is the 1-based try number, and response is a truncated copy of the receiver's body.
If the event log is disabled, history and dead-letter endpoints return
503with"event log not enabled (no delivery history)"— there's nowhere to record from.
The dead-letter queue
When a registered webhook exhausts its retry budget for an event, the fully-serialized event is written to the dead-letter queue so you can replay it later without rejoining the events table.
List the DLQ rows:
curl -s "http://localhost:8302/api/webhooks/slack-ops/dead-letter?limit=100" -H "Authorization: Bearer $TOKEN"
{
"webhook": "slack-ops",
"count": 1,
"dead_letters": [
{
"id": 7,
"event_id": "evt_01H...",
"webhook": "slack-ops",
"last_status": 0,
"last_error": "dial tcp: connection refused",
"first_seen": "2026-06-22T04:40:00Z",
"last_attempt": "2026-06-22T04:40:03Z",
"attempts": 3,
"event": { "id": "evt_01H...", "type": "alarm.high", "severity": "error", "...": "..." }
}
]
}
event holds the original event payload verbatim — that's what a replay re-delivers.
Replay a single row (re-delivers the stored event to the webhook). On HTTP 2xx the row is removed; on any other outcome it's left in place to retry later:
curl -s -X POST http://localhost:8302/api/webhooks/slack-ops/dead-letter/7/replay -H "Authorization: Bearer $TOKEN"
{ "webhook": "slack-ops", "id": 7, "event_id": "evt_01H...", "status": 200, "ok": true, "removed": true }
Delete a single row without re-delivering (the event is no longer relevant):
curl -s -X DELETE http://localhost:8302/api/webhooks/slack-ops/dead-letter/7 -H "Authorization: Bearer $TOKEN"
# => {"webhook":"slack-ops","id":7,"removed":true}
Purge every dead-letter row for a webhook at once:
curl -s -X POST http://localhost:8302/api/webhooks/slack-ops/dead-letter/purge -H "Authorization: Bearer $TOKEN"
# => {"webhook":"slack-ops","removed":4}
Endpoint reference
| Method & path | Purpose |
|---|---|
GET /api/webhooks | List destinations with live stats |
POST /api/webhooks | Register (create-or-replace) a webhook |
PUT /api/webhooks/{name} | Replace an existing webhook (404 if absent) |
DELETE /api/webhooks/{name} | Remove a webhook |
GET /api/webhooks/{name}/test | Direct one-shot test delivery |
GET /api/webhooks/{name}/history?limit=N | Recent delivery attempts |
GET /api/webhooks/{name}/dead-letter?limit=N | Dead-letter rows |
POST /api/webhooks/{name}/dead-letter/{id}/replay | Re-deliver one DLQ row |
DELETE /api/webhooks/{name}/dead-letter/{id} | Remove one DLQ row without redelivery |
POST /api/webhooks/{name}/dead-letter/purge | Remove every DLQ row for a webhook |
All endpoints require a JWT (Authorization: Bearer <token>); the tag in the OpenAPI spec is webhooks.
Notes & limits
urlandsecretare machine-local. They commonly embed per-environment identifiers (Slack tokens, PagerDuty routing keys, HMAC keys) and are stripped from the shared project file by deployment config — name/format/severity/event-type/retry behavior travels with the project; the URL and secret do not.- HMAC signing — set
secretand every outbound request carriesX-GoPLC-Signature: sha256=<hex>computed over the raw body. The receiver verifies it to confirm the payload came from this instance untampered.signed: trueshows in the list stats. - Rate limiting is a safety valve, not a silencer. With
rate_limit_per_min > 0, events at/aboverate_limit_bypass_severity(defaulterror) always pass — so a too-tight cap can't swallow an incident. Capped events are counted indropped; bypassed ones inbypassed. - History and DLQ require the event log. Both are persisted to the events SQLite store, which is enabled by default. Disable the event log and those endpoints return
503. WEBHOOK_SENDvs registered webhooks — the ST functions are a raw POST with no retry, no formatting, no record. They are not a substitute for a registered webhook's delivery guarantees. Reach forNOTIFY/NOTIFY_CRITICALwhen you want ST to feed the supervised path./testbypasses filters and the retry budget — it proves connectivity and formatting, not your event-type/severity routing. To exercise the full path (retries → DLQ), emit a real matching event.