Webhooks
Instead of polling, let Glitchmug POST to your server when something happens to your mods or your reports.
Setting up
In Settings → Webhooks on the website, or with a key that has webhooks:manage:
| Request | Does |
|---|---|
POST /me/webhooks | Add one: { "url": "https://…", "events": ["version.published"] }. The answer holds the signing secret, shown once |
GET /me/webhooks | List yours, with the last delivery of each |
PATCH /me/webhooks/{id} | Change url, events, or isActive |
POST /me/webhooks/{id}/rotate | New secret; the old one stops at once |
POST /me/webhooks/{id}/ping | Send a ping event now |
GET /me/webhooks/{id}/deliveries | Last 50 attempts (kept 14 days) |
DELETE /me/webhooks/{id} | Remove it |
Up to 10 webhooks per account. The URL must be https:// and reach the public internet: private, loopback and link-local addresses are refused, also when a public name resolves to one. Redirects are not followed.
Events
| Event | When | data |
|---|---|---|
mod.updated | Anything about your mod changed: edits, status, moderation | mod |
version.published | A version of your mod went public | mod, version |
comment.created | Someone commented on your mod | mod, comment |
report.resolved | A report you filed was closed (resolved or rejected) | report |
review.created | Someone reviewed your mod | mod, review (id, recommend, body, author, createdAt) |
ping | You asked for a test | webhook |
mod.updated is debounced: several changes within a couple of seconds arrive as one event with the latest state.
Delivery
POST /your/endpoint
Content-Type: application/json
User-Agent: glitchmug-webhooks/1
Glitchmug-Event: version.published
Glitchmug-Delivery: 8c1f4a2e-6b7d-4f0e-9a51-2d3c4b5a6e7f
Glitchmug-Signature: t=1792843200,v1=5f2b…
{
"id": "8c1f4a2e-6b7d-4f0e-9a51-2d3c4b5a6e7f",
"event": "version.published",
"createdAt": "2026-10-26T12:00:00.000Z",
"data": {
"mod": {
"id": 1042,
"slug": "neon-haze-skin",
"title": "Neon Haze skin",
"status": "published",
"game": "deadlock",
"url": "https://glitchmug.com/mods/1042/neon-haze-skin",
"updatedAt": "2026-10-26T11:59:58.000Z"
},
"version": { "id": "v1v2v3v4v5v6", "version": "1.2.0", "publishedAt": "2026-10-26T11:59:58.000Z" }
}
}
Answer with any 2xx within 10 seconds. Do the real work later: queue the payload and reply.
Verifying the signature
Glitchmug-Signature is t=<unix seconds>,v1=<hex>, where v1 = HMAC-SHA256(secret, "<t>.<raw body>").
- Read the raw body bytes, before any JSON parsing.
- Reject if
tis more than 5 minutes away from now (replays). - Compute the HMAC and compare with
v1in constant time.
- TypeScript
- Python
import { createHmac, timingSafeEqual } from 'node:crypto';
/** Check `Glitchmug-Signature` against the raw request body (not re-serialized JSON). */
export function verify(secret: string, rawBody: string, header: string, now = Date.now()): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(now / 1000 - t) > 300) return false; // 5 min replay window
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}
// Self-check with a sample delivery
const secret = 'whsec_example';
const body = '{"id":"6f1c…","event":"ping","createdAt":"2026-10-26T12:00:00.000Z","data":{}}';
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
if (!verify(secret, body, `t=${t},v1=${v1}`)) throw new Error('valid signature rejected');
if (verify(secret, body + ' ', `t=${t},v1=${v1}`)) throw new Error('tampered body accepted');
console.log('signature check ok');
import hashlib
import hmac
import time
def verify(secret: str, raw_body: bytes, header: str, now: float | None = None) -> bool:
"""Check Glitchmug-Signature against the raw request body (not re-serialized JSON)."""
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
given = parts["v1"]
except (KeyError, ValueError):
return False
if abs((now or time.time()) - t) > 300: # 5 min replay window
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(given, expected)
# Self-check with a sample delivery
secret = "whsec_example"
body = b'{"id":"6f1c...","event":"ping","createdAt":"2026-10-26T12:00:00.000Z","data":{}}'
t = int(time.time())
v1 = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
assert verify(secret, body, f"t={t},v1={v1}"), "valid signature rejected"
assert not verify(secret, body + b" ", f"t={t},v1={v1}"), "tampered body accepted"
print("signature check ok")
Retries and switching off
- A non-
2xxanswer, a timeout or a connection error is retried 6 times: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours — about 21 hours from the first attempt to the last. A short outage on your side loses nothing. - Every attempt of one event carries the same
Glitchmug-Deliveryid and the same body — use it to skip duplicates. - After 10 events in a row that failed every retry, the webhook is switched off (
isActive: false). Turn it back on withPATCH { "isActive": true }; the failure count resets. - Delivery order is not guaranteed. Use
updatedAt/createdAtfrom the payload when it matters.