Webhooks
The events we post to your URL, their payloads, how to verify the signature and how delivery is retried.
A webhook is one URL that we POST an event to when something happens to your account. Each event is JSON, signed, and tried three times.
Set it
curl -s -X PATCH $API/v1/tenant -H "Authorization: Bearer $SK" \
-d '{"webhook_url": "https://example.com/hooks/video"}'The answer holds webhook_secret (whsec_...) once. Keep it with your
other secrets: it is how you check that a request came from us. Setting the
URL again makes a new secret and the old one stops working.
{"webhook_url": null} removes the webhook and its secret.
The URL must be http or https, at most 512 characters, and a public
address. These are refused with 400 invalid_request
(webhook_url: a public address (not loopback, private, link-local or internal names)): loopback, private and link-local addresses, and names such
as localhost, *.local, *.internal and names with no dot. The address is
checked again when an event is delivered, after the name is resolved.
Use https in production.
Events
An account gets these:
type | When | Fields besides type |
|---|---|---|
spend.threshold | spend reaches 50 % or 80 % of your monthly cap | tenant, month, percent (50 or 80), spend_usd, spend_cap_usd, at, note (empty) |
spend.cap_reached | spend reaches 100 %: new viewers are now refused | the same, percent is 100, note says what happens |
publisher.gone | none of your publishers has been connected for publisher_grace_s (15 s by default) | scope, gone_for_s, at |
publisher.back | a publisher again after publisher.gone | scope, publishers, at |
The spend events fire once each a month; a new cap re-arms them
(Usage and spending caps). The
publisher events exist only on a deployment with a fleet, and
scope is your account's (/tn_...), not a stream's.
Illustrative example. The values below are made up to show the shape; they are not a measurement.
{ "type": "spend.cap_reached", "tenant": "tn_...", "month": "2026-10", "percent": 100,
"spend_usd": 50.4, "spend_cap_usd": 50, "at": "2026-10-21T09:14:03Z",
"note": "new viewers are refused until the cap is raised or the month ends; viewers already in keep watching" }Treat unknown types and extra fields as normal: answer them 2xx and
ignore them.
What a delivery looks like
POST /hooks/video HTTP/1.1
Content-Type: application/json
User-Agent: nearglass-control-api
X-Nearglass-Signature: t=1793560420,v1=9c1f...
{"type":"spend.cap_reached", ...}Answer any 2xx within 5 s and the event is delivered. Anything else
(another status, a refused connection, a timeout) is tried again: three
attempts in all, at once, after 2 s and after 10 s. After the third the
event is dropped. It stays in our log, and the next event of that kind
(next month's, say) is delivered as usual, so for anything you cannot miss,
read the state again from the API (GET /v1/usage, GET /v1/tenant) when
your receiver comes back. A retry sends the same body with a new t and a
new signature. A 2xx lost on the way can make us send an event twice, so
make your handler safe to run twice (a spend event is unique by type,
month and percent). Order is not guaranteed.
Verify the signature
X-Nearglass-Signature: t=<unix seconds>,v1=<hex>, where v1 is the
HMAC-SHA256, keyed with your webhook_secret, of <t>.<the raw body>,
in lower-case hex. Check three things:
- recompute it over the raw bytes you received, before you parse the JSON (re-serialising changes the bytes);
- compare in constant time;
- check that
tis recent (5 minutes is a good bound), so a captured request cannot be replayed later.
Answer 401 for a bad signature and do not act on the body.
Node.js, a whole receiver (no packages):
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.WEBHOOK_SECRET; // whsec_...
const SIG = 'X-Nearglass-Signature'.toLowerCase(); // Node lower-cases header names
// header: "t=<unix seconds>,v1=<hex>"; rawBody: the bytes as received, as a string
export function verify(secret, header, rawBody, maxAgeS = 300) {
const parts = Object.fromEntries(String(header ?? '').split(',').map((p) => p.split('=')));
if (!parts.t || !parts.v1) return false;
const want = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < maxAgeS;
return fresh && want.length === parts.v1.length && timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));
}
createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
// Verify the raw body, before any JSON.parse
const raw = Buffer.concat(chunks).toString('utf8');
if (!verify(SECRET, req.headers[SIG], raw)) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(raw);
console.log(event.type, JSON.stringify(event));
res.writeHead(204).end();
});
}).listen(Number(process.env.PORT ?? 8080), '127.0.0.1');Python 3, the same:
import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["WEBHOOK_SECRET"].encode() # whsec_...
def verify(secret: bytes, header: str, raw_body: bytes, max_age_s: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
if "t" not in parts or "v1" not in parts:
return False
want = hmac.new(secret, parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < max_age_s
return fresh and hmac.compare_digest(want, parts["v1"])
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("content-length", 0)))
if not verify(SECRET, self.headers.get("X-Nearglass-Signature"), raw):
self.send_response(401)
self.end_headers()
return
event = json.loads(raw)
print(event["type"], json.dumps(event), flush=True)
self.send_response(204)
self.end_headers()
HTTPServer(("127.0.0.1", int(os.environ.get("PORT", "8080"))), Hook).serve_forever()Try it without a public URL
You cannot point an account's webhook at your laptop, but you can check your
receiver against a signature made by hand. This is the exact scheme, with
openssl:
BODY='{"type":"spend.threshold","tenant":"tn_x","percent":80}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | sed 's/.* //')
curl -i -X POST http://127.0.0.1:8080/ -H "X-Nearglass-Signature: t=$T,v1=$SIG" -d "$BODY"204 is a pass. Change one character of BODY, or use an old T, and the
receiver answers 401.
On your own self-hosted API there is a second way: the
operator's alert channel can use format json, which signs events exactly
like this (with the secret file's first line as the key) and may be a
private address. Point it at a receiver on loopback and the next spend alert
arrives there (Fleet).
What was run, and what was not
Both receivers above ran as printed. Against each, the hand-signed request
answered 204, the same request with a changed body 401, with a
timestamp 1000 s old 401, and with no header 401. A real event, signed
by the API, was delivered to the Node receiver through the operator's
json channel and verified (spend.cap_reached and spend.threshold,
and relay.down). With the receiver stopped, the API tried at once, after
2 s and after 10 s (log lines alert webhook: ... attempt=1, 2, 3) and
then gave up. An account's webhook_url on a loopback address was refused
as above.