Skip to main content

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:

RequestDoes
POST /me/webhooksAdd one: { "url": "https://…", "events": ["version.published"] }. The answer holds the signing secret, shown once
GET /me/webhooksList yours, with the last delivery of each
PATCH /me/webhooks/{id}Change url, events, or isActive
POST /me/webhooks/{id}/rotateNew secret; the old one stops at once
POST /me/webhooks/{id}/pingSend a ping event now
GET /me/webhooks/{id}/deliveriesLast 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​

EventWhendata
mod.updatedAnything about your mod changed: edits, status, moderationmod
version.publishedA version of your mod went publicmod, version
comment.createdSomeone commented on your modmod, comment
report.resolvedA report you filed was closed (resolved or rejected)report
review.createdSomeone reviewed your modmod, review (id, recommend, body, author, createdAt)
pingYou asked for a testwebhook

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>").

  1. Read the raw body bytes, before any JSON parsing.
  2. Reject if t is more than 5 minutes away from now (replays).
  3. Compute the HMAC and compare with v1 in constant time.
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');

Retries and switching off​

  • A non-2xx answer, 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-Delivery id 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 with PATCH { "isActive": true }; the failure count resets.
  • Delivery order is not guaranteed. Use updatedAt / createdAt from the payload when it matters.