Develop
Webhooks
Webhooks are how Wardengate pushes events to your systems in near-real time. When something notable happens — a session starts, a policy denies a connection, an approval is requested — the control plane makes an authenticated HTTP POST to the URL you registered with a JSON body describing the event.
Concept
A webhook is a pairing of an endpoint URL and a set of event types you want delivered to it. The endpoint must be reachable from the control plane and must respond with a 2xx status within thirty seconds. Anything else — a timeout, a 5xx, a connection reset — is treated as a failed delivery and retried.
Create a webhook from the admin console or with wgctl. The signing secret is only shown once on creation; write it to your secret store immediately.
wgctl webhook create \
--url https://ops.example.com/hooks/wardengate \
--events "session.*,policy.denied,approval.requested" \
--secret-out ./wg-webhook.secretEvent catalog
Event types follow a resource.action naming scheme. Subscribe to a specific event, to a wildcard family (session.*), or to everything (*).
session.started a brokered session was established
session.ended a session closed (normal or forced)
session.terminated a session was cut by policy or an admin
session.joined a second operator joined a collaborative session
policy.denied a connection attempt was blocked
policy.updated a policy revision was applied
target.created a new target was onboarded
target.health_changed a target's reachability flipped
account.rotated a credential was rotated
approval.requested an approval-gated access request was opened
approval.granted an approver allowed the request
approval.denied an approver blocked the request
approval.expired no approver responded within the window
mfa.challenged a user was prompted for an additional factor
mfa.failed a factor check did not succeed
backup.completed a control-plane backup finished
backup.failed a backup job errored outDelivery semantics
Deliveries are at-least-once. The control plane will repeat an event until it observes a 2xx from your endpoint or until it exhausts retries — your handler must be idempotent. Retries use exponential backoff starting at ten seconds and capped at one hour, with jitter; after twenty-four hours without success the delivery is moved to a dead-letter queue and a webhook.delivery_failed internal alert fires.
Order is not guaranteed: two events from the same session may arrive out of order if one is retried. Use the created_at timestamp on the payload to reconstruct sequence where it matters.
Payload structure
Every event has a stable envelope: an id, a type, a created_at ISO-8601 timestamp, an api_version pinned at webhook creation, and a data object shaped by the event type.
{
"id": "evt_01HE6W3KPT4JX9A0",
"type": "session.started",
"created_at": "2026-04-20T10:04:11.412Z",
"api_version": "2026-04-01",
"data": {
"session": {
"id": "ses_01HE6W7R8Q",
"user": "avery@example.com",
"target": "web-01.prod",
"protocol": "ssh",
"gateway": "gw-use1-3",
"policy_id": "pol_01HE6W0000",
"recording": { "mode": "full" }
}
}
}Signature verification
Every delivery carries an X-Wardengate-Signature header with a Unix timestamp and one or more HMAC-SHA256 signatures. The canonical signed string is the timestamp, a literal dot, and the exact raw request body — concatenated, not re-serialised. Compute HMAC-SHA256 of that string using your webhook secret and compare it to the v1= value in constant time.
X-Wardengate-Signature: t=1745143451,v1=0f7c4c0a0cfb7f87cb1a1cf18f3b6d3c5e1a2fbd9c7b4e0ad7c2f5e3a0d1b284
X-Wardengate-Event: evt_01HE6W3KPT4JX9A0
X-Wardengate-Delivery: whd_01HE6W3KPX
X-Wardengate-Timestamp: 1745143451Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.WG_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;
// Keep the raw body so we can HMAC it byte-for-byte.
app.use("/hooks/wardengate", express.raw({ type: "application/json" }));
app.post("/hooks/wardengate", (req, res) => {
const header = req.get("X-Wardengate-Signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((pair) => pair.split("=")),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return res.status(400).send("bad signature");
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) {
return res.status(400).send("stale timestamp");
}
const signed = `${t}.${req.body.toString("utf8")}`;
const expected = crypto
.createHmac("sha256", SECRET)
.update(signed)
.digest("hex");
const ok =
expected.length === v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
if (!ok) return res.status(401).send("invalid signature");
const event = JSON.parse(req.body.toString("utf8"));
// Acknowledge quickly; do real work in a queue.
enqueue(event);
res.status(200).send("ok");
});Python (Flask)
import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["WG_WEBHOOK_SECRET"].encode()
TOLERANCE = 5 * 60
def parse_sig(header: str) -> dict[str, str]:
return dict(part.split("=", 1) for part in header.split(","))
@app.post("/hooks/wardengate")
def hook():
raw = request.get_data()
header = request.headers.get("X-Wardengate-Signature", "")
sig = parse_sig(header)
try:
t = int(sig["t"])
v1 = sig["v1"]
except (KeyError, ValueError):
abort(400, "bad signature header")
if abs(time.time() - t) > TOLERANCE:
abort(400, "stale timestamp")
signed = f"{t}.".encode() + raw
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(401, "invalid signature")
event = request.get_json()
enqueue(event)
return ("", 204)Replay protection
Reject any delivery whose signed timestamp is more than five minutes away from your server's clock. Five minutes is enough to absorb normal NTP drift and a short retry window without widening the attack surface. Pair this with idempotency on the event id and you defeat replay attempts even if the secret leaks briefly.
Idempotency
The id field on every event is a stable, unique identifier across retries. Record it when you commit the delivery to your system and skip any future delivery with the same id. A three-line table — event_id PRIMARY KEY, received_at, processed_at — handles this correctly for most teams.
Receiving webhooks on localhost
While you build a handler, use wgctl webhook forward to stream real deliveries to a port on your laptop without opening an inbound hole. The command establishes an outbound connection to the control plane and tunnels deliveries through it, so there is no ngrok-style third party in the loop.
# Tunnel remote deliveries to a local dev server on :4000
wgctl webhook forward --webhook whk_01HE6W... --to http://localhost:4000/hooks/wardengate
# Output
# forwarding evt_... session.started -> 200 (12ms)
# forwarding evt_... policy.denied -> 200 (8ms)Testing a new webhook
Once your endpoint is deployed, synthesise a signed delivery to validate your signature checker and downstream plumbing. The same command will also redeliver failed events from the dead-letter queue, which is what you want after shipping a handler fix.
wgctl webhook test whk_01HE6W... --event policy.denied
wgctl webhook deliveries --webhook whk_01HE6W... --state failed
wgctl webhook redeliver whd_01HE6W3KPXNext steps
- API reference — manage webhook subscriptions programmatically.
- CLI reference — full
wgctl webhookcommand tree. - SIEM export — for high-volume firehose delivery, prefer a SIEM connector over per-event webhooks.