Skip to main content

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:

PathWhat it isRetry / 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), or 0 on a transport error (connection refused, DNS failure, timeout). Content-Type defaults to application/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 returns TRUE immediately; 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-Type is always application/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 with NOTIFY(channel, message) or NOTIFY_CRITICAL(message), and subscribe a webhook to notify.*. 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):

FieldDefaultMeaning
name— (required)Unique identifier for this destination
url— (required)Where to POST. Machine-local — stripped from the shared project file
formatgenericBody adapter: generic, slack, teams, pagerduty
min_severityinfoDrop events below this severity (info/warning/error/critical)
event_types["*"]Glob filter: *, protocol.*, or a literal like task.fault
routing_keyPagerDuty Events-API routing key (pagerduty format only)
headersExtra HTTP headers (map)
retry_count3Retries after the first attempt. Set -1 for no retries (normalizes to 0)
retry_delay_ms1000Delay between attempts
timeout_ms10000Per-attempt HTTP timeout
secretOptional HMAC-SHA256 signing key → adds X-GoPLC-Signature: sha256=<hex> over the raw body. Machine-local
rate_limit_per_min0 (off)Cap deliveries per rolling 60 s window; excess counted as dropped
rate_limit_bypass_severityerror (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 /test path 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 503 with "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 & pathPurpose
GET /api/webhooksList destinations with live stats
POST /api/webhooksRegister (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}/testDirect one-shot test delivery
GET /api/webhooks/{name}/history?limit=NRecent delivery attempts
GET /api/webhooks/{name}/dead-letter?limit=NDead-letter rows
POST /api/webhooks/{name}/dead-letter/{id}/replayRe-deliver one DLQ row
DELETE /api/webhooks/{name}/dead-letter/{id}Remove one DLQ row without redelivery
POST /api/webhooks/{name}/dead-letter/purgeRemove every DLQ row for a webhook

All endpoints require a JWT (Authorization: Bearer <token>); the tag in the OpenAPI spec is webhooks.


Notes & limits

  • url and secret are 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 secret and every outbound request carries X-GoPLC-Signature: sha256=<hex> computed over the raw body. The receiver verifies it to confirm the payload came from this instance untampered. signed: true shows in the list stats.
  • Rate limiting is a safety valve, not a silencer. With rate_limit_per_min > 0, events at/above rate_limit_bypass_severity (default error) always pass — so a too-tight cap can't swallow an incident. Capped events are counted in dropped; bypassed ones in bypassed.
  • 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_SEND vs 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 for NOTIFY/NOTIFY_CRITICAL when you want ST to feed the supervised path.
  • /test bypasses 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.