What Webhooks Are
Webhooks are HTTP callbacks that SGT Systems sends to a URL you control whenever a notable event happens on the platform. Rather than polling the REST API for changes, you register a URL once and receive a signed JSON POST every time something interesting occurs — a device going offline, an alarm being raised, a report finishing.
Webhooks are the right choice when:
- Latency matters (you want to react in seconds, not minutes).
- Events are sparse relative to your polling cadence — polling wastes both sides' resources.
- You want your integration to scale linearly with event volume, not with how aggressively you poll.
Webhooks are not the right choice when you need a complete, ordered history of every state change with strong delivery guarantees — for that, consume the same events from the event stream API (GET /v1/events?since=...) which is the canonical record and is always replayable. Many integrations use both: webhooks for low-latency reaction, periodic event-stream reconciliation as a safety net.
Event Types
The platform currently emits the following event types. New types may be added at any time; receivers must tolerate unknown type values gracefully (log and acknowledge).
| Event type | Triggered when | Typical use |
|---|---|---|
device.online | A device's connection state transitions from offline to online. | Update connection dashboards. |
device.offline | A device fails to publish or respond to keepalives for the configured grace window. | Notify ops, create ticket. |
alarm.raised | A configured alarm rule fires (threshold crossed, schedule violated, etc.). | Page on-call, write to incident system. |
alarm.cleared | An active alarm condition returns to normal or is manually cleared. | Close incident. |
telemetry.threshold_breached | A single telemetry reading crosses a configured threshold. | Energy demand alerts, leak detection. |
report.ready | An asynchronous report job completes. | Email the signed download link to recipients. |
Each event type has a stable JSON schema for its data object, documented in the developer portal. Additive changes to that schema (new optional fields) are not breaking; subscribers must ignore unknown fields. Removing or renaming a field would constitute a breaking change and would only happen under a new event type name (e.g. alarm.raised.v2).
Webhook Payload Format
All webhook deliveries share an envelope. The data object's shape varies by event type. Example:
POST /your/webhook HTTP/1.1
Host: hooks.acme.com
Content-Type: application/json
X-SGT-Event: alarm.raised
X-SGT-Delivery: d_01HW3K9F2X4G2T...
X-SGT-Timestamp: 1716549600
X-SGT-Signature: t=1716549600,v1=5257a869e7e...e72c4
User-Agent: SGTSystems-Webhooks/1.0
{
"id": "evt_01HW3K9F2X4G2T...",
"type": "alarm.raised",
"created_at": "2026-05-24T10:00:00Z",
"tenant": "acme-industries",
"data": {
"alarm_id": "alm_42",
"device_id": "dev_42",
"site": "dhaka-warehouse-1",
"severity": "high",
"metric": "power_kw",
"value": 147.3,
"threshold": 120.0,
"message": "Active power above threshold (147.3 kW > 120.0 kW)"
}
}
Receivers should respond with HTTP 2xx within 5 seconds. Any non-2xx response, or a timeout, is treated as a failed delivery and triggers the retry policy below. The body of the response is ignored — even a bare 200 OK with no body is a valid acknowledgement.
Headers worth knowing:
| Header | Purpose |
|---|---|
X-SGT-Event | Event type. Same as the body's type field; provided in the header so receivers can route without parsing the body. |
X-SGT-Delivery | Unique delivery identifier. Stable across retries of the same event. |
X-SGT-Timestamp | Unix timestamp of when this delivery was queued. Used for replay protection. |
X-SGT-Signature | HMAC SHA-256 signature; see below. |
X-SGT-Tenant | Tenant the event belongs to. Useful for multi-tenant receivers. |
HMAC SHA-256 Signature Verification
Every delivery is signed with a per-endpoint secret using HMAC SHA-256. The signing input is the concatenation of the timestamp, a dot, and the raw request body:
signing_string = f"{timestamp}.{raw_body}"
signature = HMAC_SHA256(secret, signing_string)
The signature is sent in the X-SGT-Signature header in the form t=<timestamp>,v1=<hex>. The v1 prefix is the signature scheme version, allowing the platform to introduce v2 in future without breaking existing receivers — verifiers should iterate over all vN= components and accept any that validate. To validate a request you must:
- Extract
tandv1fromX-SGT-Signature. - Reject the request if the timestamp is more than 5 minutes old (replay protection).
- Recompute the HMAC using your endpoint secret and compare in constant time.
Python (Flask) example
import hmac, hashlib, os, time
from flask import Flask, request, abort, jsonify
app = Flask(__name__)
SECRET = os.environ["SGT_WEBHOOK_SECRET"].encode()
TOLERANCE_SEC = 300
def verify(req):
header = req.headers.get("X-SGT-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
ts = int(parts["t"])
sig = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > TOLERANCE_SEC:
return False
signed = f"{ts}.".encode() + req.get_data()
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
@app.post("/webhooks/sgt")
def receive():
if not verify(request):
abort(401)
event = request.get_json()
# ... enqueue for processing ...
return jsonify(ok=True), 200
Node.js (Express) example
const express = require("express");
const crypto = require("crypto");
const SECRET = process.env.SGT_WEBHOOK_SECRET;
const TOLERANCE_MS = 5 * 60 * 1000;
const app = express();
app.use(express.raw({ type: "application/json" })); // keep raw bytes
app.post("/webhooks/sgt", (req, res) => {
const header = req.get("X-SGT-Signature") || "";
const parts = Object.fromEntries(
header.split(",").map(p => p.split("="))
);
const ts = parseInt(parts.t, 10);
const sig = parts.v1;
if (!ts || Math.abs(Date.now() - ts * 1000) > TOLERANCE_MS) {
return res.status(401).send("stale");
}
const signed = Buffer.concat([Buffer.from(`${ts}.`), req.body]);
const expected = crypto
.createHmac("sha256", SECRET)
.update(signed)
.digest("hex");
const ok =
sig &&
expected.length === sig.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString());
// ... enqueue ...
res.status(200).json({ ok: true });
});
app.listen(3000);
Retry Policy & Idempotency
Failed deliveries (non-2xx, network error, timeout > 5s) are retried with exponential backoff:
| Attempt | Delay after previous attempt |
|---|---|
| 1 (initial) | — |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 4 hours |
| 7 | 12 hours |
After the 7th unsuccessful attempt the delivery is dead-lettered and an email is sent to the endpoint owner. You can replay dead-lettered deliveries from the admin UI for up to 30 days. The platform also auto-pauses an endpoint that has produced more than 50 consecutive failures in the last hour — this prevents a misbehaving receiver from queueing millions of retries that will never succeed.
Because retries happen, your receiver must be idempotent. The platform guarantees that the id field on each event is stable across retries. Store recently-seen IDs (e.g. in Redis with a 24h TTL) and skip duplicates. A minimal idempotency wrapper:
def already_seen(event_id, redis):
# SET NX returns 1 if the key was created, 0 if it already existed
return redis.set(f"sgt:evt:{event_id}", "1", nx=True, ex=86400) is None
if already_seen(event["id"], redis):
return jsonify(ok=True), 200 # ack and skip
process(event)
Best Practices for Receivers
- Acknowledge fast, process async. Validate the signature, enqueue to a worker, return 2xx. Do not do downstream work synchronously — it eats your 5-second budget.
- Always log the
X-SGT-Deliveryheader. Support requests resolve in minutes when you can quote a delivery ID. - Don't IP-allowlist webhook sources. Source IPs change. Use signature verification as the trust boundary.
- Tolerate new fields and new event types. The envelope is stable;
datamay grow over time. - Rotate the signing secret on a schedule. The admin UI supports two active secrets during rotation windows.
- Don't depend on event ordering. Events for the same resource are usually but not always delivered in order. If order matters, sort by
created_atat the receiver. - Return 410 Gone for permanently-decommissioned URLs. The platform interprets 410 as "stop sending immediately" rather than retrying.
- Monitor your queue depth. A slow worker that piles up unprocessed events is a silent data-loss risk if your queue has a TTL.
Testing with Webhook.site
For ad-hoc testing without spinning up an endpoint:
- Open https://webhook.site and copy the unique URL it generates.
- Register the URL in the SGT admin under Integrations → Webhooks → New endpoint.
- Trigger a test event from the same screen ("Send test payload"). The delivery appears in webhook.site instantly with full headers and body.
- Verify the signature offline by copying the raw body and timestamp into a small script.
Observability
The admin UI for each webhook endpoint shows per-event delivery success rate, p50/p95 receiver latency, dead-letter count, and the last 7 days of failed deliveries. Use these as your SLO dashboard. Alert on:
- Success rate dropping below 99% over a 5-minute window.
- p95 latency above 2 seconds (you're getting close to the 5-second budget).
- Any dead-letter event for a high-severity event type (
alarm.raised). - Sudden drops in delivery volume — often a sign that an upstream filter or rule has changed unexpectedly.
The same metrics are also available via GET /v1/webhooks/{id}/metrics for programmatic ingestion into your own monitoring stack (Datadog, Grafana, New Relic). The endpoint returns a Prometheus-compatible JSON shape that translates cleanly to gauges and counters.
Filtering Events at the Subscription
By default, a webhook subscribes to a list of event types. For higher-volume event types (telemetry.threshold_breached in particular) you may want server-side filtering so only events matching a predicate are delivered. The subscription supports a JSON filter expression:
{
"url": "https://hooks.acme.com/sgt",
"event_types": ["telemetry.threshold_breached"],
"filter": {
"and": [
{ "field": "data.site", "op": "eq", "value": "dhaka-warehouse-1" },
{ "field": "data.severity", "op": "in", "value": ["high", "critical"] }
]
}
}
Supported operators: eq, ne, in, nin, gt, lt, contains, exists. Filters apply before delivery counts toward your rate-limit quota, so a precise filter is cheaper end-to-end than receiving everything and filtering at the receiver.
Migration from Polling
If you have an existing integration that polls the events endpoint, migrate to webhooks in three steps:
- Stand up the webhook endpoint and verify signatures with a sample test payload. Do not yet turn off the poller.
- Run both in parallel for at least 24 hours; reconcile any events the poller saw that the webhook did not, or vice versa. The platform's
idfield makes this trivial. - Once parity is established, reduce the poller's frequency to once per hour as a safety net, then turn it off entirely after another week of clean operation.
Need help?
If a delivery is failing, signature verification isn't matching, or you need a new event type added, contact the SGT Systems integrations team at support@sgtsystems.com or via our contact page.