Back to Tailscale integration
SIMSY Bridge · Reference

Bridge events and webhooks

The bridge sends events to the HTTPS address you give it, so your own systems know when it changes state and who accessed which device through it. Access events can support your record-keeping, for example under NIS2. They cover access through the bridge only, and do not make you compliant by themselves.

What the bridge emits

EventWhen
bridge.provisioningThe bridge is being set up, or set up again after being re-enabled.
bridge.onlineEverything is working: SIMSY tunnel up, Tailscale node running, route approved.
bridge.degradedSomething needs attention. reason says what (below); detail says more.
bridge.disabledThe bridge was switched off. Nothing is forwarded; SIMSY stays configured.
bridge.deleting, bridge.deletedThe bridge is being removed, and has been.
policy.downlink_changedUplink-only was switched on or off. data.downlink_allowed is the new setting.
access.openedA device on your tailnet started talking to a SIM device.
access.closedThat session ended (5 minutes without traffic), with its duration and bytes.

Degraded reasons: handshake_stale (no recent WireGuard handshake with SIMSY), route_unapproved, tags_not_assignable (your tailnet policy does not let the bridge use its tags), ts_offline, simsy_api_error, tailscale_api_error, simsy_key_unknown, config_error, data_plane_error.

Shape of a delivered event

example: access.closed
{
  "id": "evt_6b1d0f3c9a2e4f5d8c7b6a51",
  "type": "access.closed",
  "tenant": "fleet-a",
  "time": "2026-10-13T12:12:05Z",
  "data": {
    "who": "[email protected]",
    "device": "sam-laptop",
    "tailnet_address": "100.101.102.103",
    "sim_name": "depot-router-3",
    "sim_address": "10.20.0.70",
    "started": "2026-10-13T12:00:01Z",
    "ended": "2026-10-13T12:12:05Z",
    "duration_seconds": "724",
    "bytes_to_sim": "18342",
    "bytes_from_sim": "1203391"
  }
}

who and device are what your tailnet knows about the address that opened the session. Sessions are counted per tailnet address and SIM; replies to connections the SIM device opened itself are not access.

How events arrive

  • •POST with Content-Type: application/json, over HTTPS only, to a public address. Redirects are not followed. Answer with any 2xx status within 15 seconds.
  • •The webhook secret must be at least 32 characters: generate it with openssl rand -hex 16.
  • •Headers: X-SIMSY-Event (the type), X-SIMSY-Delivery (the event ID, the same on every retry: use it to ignore duplicates), X-SIMSY-Signature.
  • •Events for one bridge arrive in order. If your endpoint fails, the bridge retries after 10 s, 30 s, 1, 2, 5 and 10 minutes, then every 15 minutes, for 24 hours. Events are kept on disk, so a bridge restart does not lose them. Later events wait for earlier ones.

HMAC-SHA256 of timestamp + body

X-SIMSY-Signature looks like t=1792000000,v1=5f2b…. v1 is the hex HMAC-SHA256, keyed with your webhook secret, of the timestamp, a full stop, and the raw request body. Check it before trusting the event, and reject timestamps more than five minutes from now so a captured request cannot be replayed.

Python

verify.py
import hashlib, hmac, time

def verify(secret: bytes, header: str, body: bytes, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    t, sig = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

Node.js

verify.js
const crypto = require("crypto");

// rawBody must be the request body as a Buffer, for example from
// express.raw({type: "application/json"}).
function verify(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries((header || "").split(",").map(p => p.split("=", 2)));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
  // timingSafeEqual throws on different lengths, so check that first.
  if (typeof parts.v1 !== "string" || parts.v1.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Verify against the raw body exactly as received, before any JSON parsing or reformatting.

New to the bridge?

The step-by-step guide walks through standing one up on your own Linux host in under half an hour.