Wardengate

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

Event 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 out

Delivery 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: 1745143451

Node.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_01HE6W3KPX

Next steps

  • API reference — manage webhook subscriptions programmatically.
  • CLI reference — full wgctl webhook command tree.
  • SIEM export — for high-volume firehose delivery, prefer a SIEM connector over per-event webhooks.