Operate

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:

typeWhenFields besides type
spend.thresholdspend reaches 50 % or 80 % of your monthly captenant, month, percent (50 or 80), spend_usd, spend_cap_usd, at, note (empty)
spend.cap_reachedspend reaches 100 %: new viewers are now refusedthe same, percent is 100, note says what happens
publisher.gonenone of your publishers has been connected for publisher_grace_s (15 s by default)scope, gone_for_s, at
publisher.backa publisher again after publisher.gonescope, 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:

  1. recompute it over the raw bytes you received, before you parse the JSON (re-serialising changes the bytes);
  2. compare in constant time;
  3. check that t is 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.

On this page