-
Notifications
You must be signed in to change notification settings - Fork 0
HOOKS
Generated from
docs/HOOKS.md. Edit that file and re-runnode scripts/publish-wiki.mjs --push. An edit made here is a fork of the documentation that nothing reconciles, and the next run of this script will overwrite it without asking.
One signed POST per transition, in order, for a script rather than a person.
When the stream starts, when it stops, when a destination goes live or drops, polyemesis POSTs a small JSON body to a URL you own. Set them up under Automation → Webhooks.
Both post to a URL. They promise opposite things, and choosing the wrong one is the most likely way to be disappointed.
| Alert rule | Webhook | |
|---|---|---|
| Audience | a person reading Slack | a script |
| Repeats | coalesced — "×12" | never — one delivery per transition |
| Delay | debounced 10s, rate-floored 30s | none beyond the dwell below |
| Ordering | none | guaranteed per endpoint |
| Signed | no | HMAC-SHA256 |
| Body | formatted for the channel | stable, versioned, machine-readable |
Coalescing an alert is a kindness. Coalescing a hook loses the eleven events the script needed.
| Trigger | Fires when | Dwell |
|---|---|---|
ingest.published |
data arrives on the ingest after silence | none |
ingest.disconnected |
nothing has arrived for 5s | 5s |
destination.up |
a destination starts delivering | none |
destination.down |
a destination stops — failed, disabled or deleted | 10s |
broadcast.fault |
a platform refused to start or end a broadcast | none |
destination.rolledover |
a file destination's recording continued into a different file | none |
destination.rolledover is also not a destination.down. Nothing stopped:
the destination is delivering and the recording is continuing. What changed is
which file it is continuing into.
A file destination never overwrites footage. If its child exits and is respawned
while the configured filename already holds real bytes, the replacement is given
a timestamped sibling — show.mkv becomes show-20260819-021500.mkv — so the
earlier take survives. That is the intended behaviour and it is not going to
change.
What was missing is anyone being told. The respawn is not an error: the child
can exit cleanly, and a clean exit is logged at Info with nothing in the process
log ring, so the only trace was a restart counter moving. An operator who looked
at the filename they configured found a file with a header and no video, and no
reason anywhere to suspect a sibling existed. reason carries the path actually
written, so a script can follow the recording rather than guess at it.
broadcast.fault is not a destination.down and must not be treated as
one. The stream is fine: bytes are flowing and the destination is delivering.
What failed is the platform's own idea of the broadcast — the channel is at its
concurrent-broadcast limit, the broadcast has already been completed and cannot
return to live, the connected account's token expired. polyemesis never stops a
stream because a transition failed, so a script that mirrors "what are we live
to" must not tear anything down when it hears this. The reason field carries
the operator-facing sentence and the broadcast id.
ingest.published has no dwell on purpose. An operator scripting "we are
live" wants it now, and the cost is stated below under limitations.
The dwell on destination.down is what stops a reconnecting destination
producing a storm: it never goes down inside the window, so it never comes back
up either.
ingest.disconnected can only fire after a publish, so a server sitting idle
since boot never announces a disconnection that never happened.
{
"specVersion": "1",
"id": "d_9f2c1a7b4e",
"sequence": 42,
"trigger": "destination.down",
"at": "2026-07-31T18:04:11Z",
"source": { "id": 1, "name": "Main" },
"destination": { "id": 3, "name": "Twitch", "platform": "twitch" },
"reason": "disabled",
"error": ""
}destination is absent on the two ingest triggers. reason is free text meant
for a human reading a log; branch on trigger, not on reason.
Headers:
| Header | Contents |
|---|---|
X-Polyemesis-Signature |
v1= + hex HMAC-SHA256 |
X-Polyemesis-Timestamp |
Unix seconds, covered by the signature |
X-Polyemesis-Trigger |
the trigger, for routing without parsing |
X-Polyemesis-Delivery |
unique per delivery; the idempotency key |
X-Polyemesis-Sequence |
per-endpoint counter, for spotting gaps |
The signature is over "<timestamp>.<raw body>". Use the raw bytes, not a
re-serialised object — any difference in key order or spacing changes the digest.
const crypto = require("crypto");
// express.raw({ type: "application/json" }) — req.body must be a Buffer.
function verify(req, secret) {
const ts = req.get("X-Polyemesis-Timestamp");
const sig = req.get("X-Polyemesis-Signature");
// Reject anything older than five minutes, or a captured delivery can be
// replayed forever.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const mac = crypto.createHmac("sha256", secret);
mac.update(ts + "." );
mac.update(req.body);
const want = "v1=" + mac.digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want));
}import hmac, hashlib, time
def verify(headers, raw_body: bytes, secret: str) -> bool:
ts = headers["X-Polyemesis-Timestamp"]
sig = headers["X-Polyemesis-Signature"]
if abs(time.time() - int(ts)) > 300:
return False
mac = hmac.new(secret.encode(), digestmod=hashlib.sha256)
mac.update(ts.encode() + b".")
mac.update(raw_body)
return hmac.compare_digest(sig, "v1=" + mac.hexdigest())The signing key is shown once, when the hook is created. polyemesis stores it sealed and cannot show it again — if you lose it, edit the hook and set a new one.
- Ordering is per endpoint. Deliveries to one hook arrive in the order the transitions happened. Nothing is ordered across hooks.
- Three attempts by default, 1–5. A 4xx is never retried — an endpoint saying the request is wrong will say it again, and retrying only delays everything queued behind it. A 5xx, a 429 and a 408 are retried.
-
sequencecounts from 1 per endpoint. A gap means deliveries were dropped. -
missedappears on the next successful envelope after a drop, saying how many were lost. Go and reconcile; nothing reconciles for you. -
sequenceresetting to 1 means polyemesis restarted. See the limitations.
No destination URL, no stream key, no publish token, no ingest passphrase.
This is enforced centrally rather than per call site: the free-text fields go
through the same redaction the alert path uses, on the way in. It matters more
than it looks — error carries the last lines of FFmpeg's stderr, and an FFmpeg
that cannot publish prints the whole rtmps:// URL with the key on the end.
Pinned by internal/hooks/payload_test.go, which plants a key in three fields and fails if any of them reaches the wire, plus a structural guard that walks the marshalled JSON so a field added later cannot smuggle a credential out by being named after one.
- The test button sends a real signed delivery and shows you the exact body and signature that were sent — so you can check your verification code against real bytes rather than against this page.
- Recent deliveries lists the last 50 per hook: trigger, sequence, status, duration and any error. A webhook that fires into a black hole is indistinguishable from one that does not fire at all, and this is the difference.
Written out rather than discovered later.
A restarted server replays the current state as fresh events. The ingest and
every destination start in the OFF position, so a process restarted mid-broadcast
fires ingest.published and one destination.up per live destination within two
seconds. This is deliberate — the alternative is a script that never runs because
the transition happened while the server was down — but it means every receiver
must be idempotent. sequence resetting to 1 is the signal.
Adding the first hook mid-broadcast does the same. The sweep does not run when nothing is subscribed, so the watcher's first observation is whatever is true at that moment.
Whether that happens depends on your alert rules. If alert rules already exist the sweep has been running, the hook watcher is warm, and adding a hook fires nothing. If they do not, it is cold and adding a hook fires the current state. That inconsistency is real and is not fixed here.
A one-sample handshake blip produces a publish/disconnect pair.
ingest.published has zero dwell by design. An SRT connection that delivers a
few bytes and dies will announce a stream that never happened, followed five
seconds later by a disconnection.
Dropped deliveries are counted, not recovered. There is no persistent outbox, no at-least-once guarantee, and no ordering across a restart.
Retries block their own endpoint. Ordering is bought with head-of-line
blocking, bounded at maxAttempts × (timeoutSeconds + backoff) — 33s at
defaults, 165s at the maximums. A slow endpoint delays only its own deliveries,
but it delays all of them.
No SSRF protection. A hook URL may point at 127.0.0.1, a cloud metadata
service or an internal admin panel, and polyemesis will POST to it. This is the
position alert rules are already in, so it is not a new exposure — but it is not
a defended one. Anyone who can create a hook can already reconfigure the
pipeline.
No per-source subscription filter. A hook receives from every source. An
install with three programmes gets three ingest.published events,
distinguishable by source.id.
No separate enable/disable triggers. A deliberate disable arrives as
destination.down with reason: "disabled" — free text, and therefore weaker
than a trigger name.
The delivery log is in memory and per process. Fifty entries per hook, gone on restart. A debugging aid, not an audit trail.
The engine wiring is only shallowly tested. The gate that decides whether a sweep runs has a unit test and a verified mutation, but "the snapshot actually reaches the hook watcher" is proved by nothing except the build. An end-to-end test would need a live relay hub and a real FFmpeg, which this repository does not do for the alert path either.
hooks importing alerts is a real coupling. The watcher takes an
alerts.Snapshot and the payload uses the alert package's redaction. If
alerts.Snapshot gains a field carrying a URL, the hook payload inherits the
risk: the structural guard catches a field named after a secret, not one
merely containing one.
- MONITORING.md — alerts, for when a person needs telling
- MQTT.md — retained state telemetry, for a dashboard
-
API.md — the
/hooksroutes
Getting it running
- Quickstart: from nothing to a live restream
- Install polyemesis — an SRT server on your own box
- OBS SRT setup: multitrack audio to one ingest
- TLS certificates for a self-hosted SRT server
The routing
- Audio routing: a different mix per destination
- Renditions: one shared video encode
- Encoding: what is copied and what is encoded
- Hardware encoding: NVENC, QSV, VA-API, AMF
Operating it
- Configuration: config.yaml and the web UI
- Streaming platforms: what can be automated
- Broadcasting from a file, on a schedule
- What a settings change restarts, and what it does not
- Upgrading polyemesis and its database
- Troubleshooting: SRT, RTMP and audio problems
Automating it
- Monitoring: Prometheus metrics and alerts
- Lifecycle webhooks: one signed POST per event
- MQTT telemetry and Home Assistant
- HTTP API reference — polyemesis /api/v1
Understanding it