Verify subnera alert webhooks: check the signature in Node.js or Python
Verified with T@H 3.7.0 on macOS 26.6.2 (October 8, 2026)
Published October 12, 2026 · 6 min read
A webhook alert channel posts to a URL you chose. Anyone who learns that URL could post to it too, so a receiver that acts on the message (paging someone, opening a ticket, restarting something) should check that the request really came from subnera hub. Every webhook send is signed for that purpose. This guide covers what is sent, how to compute the check, and gives working code in Node.js and Python. If you have not set up a webhook channel yet, start with the alerts guide.
Webhook channels are available on Team and Fleet plans.
Where the secret comes from
Each webhook channel has its own signing secret, a string starting with subnera_whs_.
- A new webhook channel shows its secret once, right after you add it. Copy it into your receiver's configuration then; subnera hub never shows it again.
- Rotate secret on the channel row issues a new secret, shown once. The change is immediate: the old secret stops signing at once, so update your receiver right away. There is no overlap period.
- Webhook channels created before signing existed show Unsigned. Their sends carry no signature until someone uses Enable signing on the row, which also shows the new secret once.
Treat the secret like a password: keep it in your receiver's secret store, not in the source code. If an admin who knew it leaves the pool, rotate it.
What subnera hub sends
Each alert is an HTTP POST with a JSON body holding one field:
{"text": "Mac-mini: stuck firing — training but no throughput for 15 min (working, L12)"}
and three headers added for the signature:
| Header | Value |
|---|---|
X-Subnera-Timestamp |
The send time, in Unix seconds (digits only). |
X-Subnera-Delivery |
A random UUID, new for every send. |
X-Subnera-Signature |
v1= followed by 64 lowercase hexadecimal characters. |
The receiver should answer with a 2xx status. Any other status counts as a failed delivery on the Alerts page.
How the signature is computed
The signature is an HMAC with SHA-256 over this exact string:
<timestamp>.<deliveryId>.<raw body>
that is, the timestamp header, a dot, the delivery header, a dot, then the body exactly as it arrived. The key is the whole secret string as UTF-8 bytes, including the subnera_whs_ prefix. Do not strip the prefix and do not base64-decode the rest. The result is written as lowercase hex and prefixed with v1=.
What to check, in order
- Read the headers case-insensitively. Many servers lowercase header names (
x-subnera-signature). - Use the raw request bytes. Compute the HMAC over the body as received. If your framework parses the JSON first and you serialize it again, spaces and key order can change and the check fails. Capture the raw body before any parsing.
- Compare in constant time. Use your language's constant-time comparison, never
==on the two strings. - Reject stale timestamps. If the timestamp is more than 300 seconds away from your own clock (in either direction), refuse the request.
- Drop replays. Remember each accepted
X-Subnera-Deliveryvalue for at least that same window and refuse a delivery id you have already seen. Do this only after the signature has verified, so a forged request cannot fill your memory.
If anything fails, answer 401 and do not act on the message. Do not log the secret.
Node.js
This uses only Node's built-in modules. It reads the raw body as a Buffer before touching it.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const SECRET = process.env.SUBNERA_WEBHOOK_SECRET; // subnera_whs_...
const TOLERANCE_S = 300;
const seen = new Map(); // delivery id -> expiry (ms)
export function verifySubneraWebhook(secret, headers, rawBody, nowS = Math.floor(Date.now() / 1000)) {
const timestamp = headers['x-subnera-timestamp'];
const deliveryId = headers['x-subnera-delivery'];
const signature = headers['x-subnera-signature'];
if (!timestamp || !deliveryId || !signature) return false;
if (!/^\d{1,15}$/.test(timestamp) || !/^[A-Za-z0-9-]{1,64}$/.test(deliveryId)) return false;
if (Math.abs(nowS - Number(timestamp)) > TOLERANCE_S) return false;
if (!/^v1=[0-9a-f]{64}$/.test(signature)) return false;
const expected = createHmac('sha256', secret) // the whole secret string, as UTF-8
.update(`${timestamp}.${deliveryId}.`)
.update(rawBody) // a Buffer: the bytes as received
.digest();
const given = Buffer.from(signature.slice(3), 'hex');
if (!timingSafeEqual(expected, given)) return false;
// Replay protection: only after the signature is known good.
const nowMs = nowS * 1000;
for (const [id, expiry] of seen) if (expiry <= nowMs) seen.delete(id);
if (seen.has(deliveryId)) return false;
seen.set(deliveryId, nowMs + TOLERANCE_S * 1000);
return true;
}
createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (req.method !== 'POST' || !verifySubneraWebhook(SECRET, req.headers, rawBody)) {
res.writeHead(401).end();
return;
}
const { text } = JSON.parse(rawBody.toString('utf8'));
console.log('subnera alert:', text);
res.writeHead(204).end();
});
}).listen(8080);
With Express, mount express.raw({ type: 'application/json' }) on the route instead of express.json(): req.body is then the raw Buffer, and req.headers is already lowercased.
Python
This uses the standard library for the check and Flask for the example route. request.get_data() returns the raw bytes, and Flask's headers are case-insensitive.
import hashlib
import hmac
import os
import re
import time
from flask import Flask, abort, request
SECRET = os.environ["SUBNERA_WEBHOOK_SECRET"] # subnera_whs_...
TOLERANCE_S = 300
seen = {} # delivery id -> expiry (unix seconds)
def verify_subnera_webhook(secret, headers, raw_body, now=None):
now = int(time.time()) if now is None else now
timestamp = headers.get("X-Subnera-Timestamp")
delivery_id = headers.get("X-Subnera-Delivery")
signature = headers.get("X-Subnera-Signature")
if not (timestamp and delivery_id and signature):
return False
if not re.fullmatch(r"\d{1,15}", timestamp) or not re.fullmatch(r"[A-Za-z0-9-]{1,64}", delivery_id):
return False
if abs(now - int(timestamp)) > TOLERANCE_S:
return False
if not re.fullmatch(r"v1=[0-9a-f]{64}", signature):
return False
signed = f"{timestamp}.{delivery_id}.".encode() + raw_body # raw_body: bytes as received
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature[3:]):
return False
# Replay protection: only after the signature is known good.
for known, expiry in list(seen.items()):
if expiry <= now:
del seen[known]
if delivery_id in seen:
return False
seen[delivery_id] = now + TOLERANCE_S
return True
app = Flask(__name__)
@app.post("/subnera")
def subnera_alert():
if not verify_subnera_webhook(SECRET, request.headers, request.get_data()):
abort(401)
print("subnera alert:", request.get_json()["text"])
return "", 204
Both snippets keep the replay memory in the process. If your receiver runs as several processes or restarts often, keep the seen delivery ids in a shared store such as Redis with a 300-second expiry.
Testing your receiver
Press Send test on the channel row: the test message is signed like any other send. A request you build by hand should fail: change one character of the body, or send the same delivery twice, and your receiver must answer 401 both times.
If every request fails, the usual causes are, in order: a secret copied without its subnera_whs_ prefix (or with a trailing space), a body that was parsed and re-serialized before the check, a framework that decodes the body as text with a different character set, and a server clock that is more than five minutes off.
subnera is unofficial and not affiliated with IOTA, Macrocosmos or Bittensor. For the alert rules and the other channel types, see the alerts guide; for who may manage channels, see pools and roles.
Related guides
Want to see this on your own Mac?
subnera shows the real queue position and phase in your menu bar.
Install subnera