Payloads
Each webhook delivery is an HTTP POST with a JSON body and a small set of headers identifying the content type, event, and webhook.
Headers
| Header | Purpose |
|---|---|
Content-Type | application/json (when the webhook is configured for JSON; application/x-www-form-urlencoded if configured for form). |
XF-Content-Type | The content type — for example mc_dm_download, mc_dm_version. |
XF-Webhook-Event | The event name — insert, update, delete, publish, unpublish, download_started, download_completed, download_failed, gate_passed, gate_failed, gate_all_passed. |
XF-Webhook-Id | The webhook record ID. Useful when one receiver handles multiple subscriptions. |
XF-Webhook-Secret | The shared secret you configured on the webhook. Sent in plaintext. |
There is no signature header, no delivery UUID, no timestamp, and no attempt counter. If you need any of those for de-duplication or replay protection, your receiver is responsible for generating them.
Body envelope
{
"content_type": "mc_dm_download",
"event": "update",
"content_id": 42,
"data": { "...": "entity payload" }
}
Always four top-level keys. data holds the entity's serialised columns plus the relations the content type's handler eager-loads.
Per-content-type data shape
Each handler declares which relations are eager-loaded. The data object contains the entity's columns plus those relations.
mc_dm_download
data is the Download with Category, User, and CurrentVersion eager-loaded. Example:
{
"content_type": "mc_dm_download",
"event": "insert",
"content_id": 42,
"data": {
"download_id": 42,
"title": "Example release",
"category_id": 3,
"user_id": 7,
"download_state": "visible",
"...": "...",
"Category": { "category_id": 3, "title": "Mods", "...": "..." },
"User": { "user_id": 7, "username": "alice", "...": "..." },
"CurrentVersion": { "version_id": 99, "version_string": "1.2.0", "...": "..." }
}
}
mc_dm_version
data is the Version with Download, Download.Category, and User eager-loaded.
mc_dm_file
data is the File with Version, Version.Download, and Version.Download.Category eager-loaded.
mc_dm_comment
data is the Comment with Download, Download.Category, and User eager-loaded.
mc_dm_review
data is the Review with Download, Download.Category, and User eager-loaded.
Custom event payloads
For custom events (publish, unpublish, download_started, download_completed, download_failed, gate_passed, gate_failed, gate_all_passed), the entity attached to data is whichever record the event is most naturally associated with — Version for publish/unpublish, Download for everything else.
For download_started, download_completed, download_failed, gate_passed, gate_failed, and gate_all_passed, the entity attached to data is always the Download, not the file or version. There is one record per delivery, and the gate context only carries a Download.
Extra fields per event (1.0.0+)
Some events include extra top-level fields alongside the standard envelope. They are emitted by the addon's Webhook\Notifier and merged into the delivery payload:
| Event | Extra fields |
|---|---|
gate_passed | gate (string — permission / criteria / password / hotlink / captcha / rate_limit / bandwidth), context (object — gate-specific result context, e.g. {"bytes_per_sec": 524288} for the bandwidth gate). |
gate_failed | gate (same set as above), reason (string — phrase key or human reason), context (object). |
gate_all_passed | (no extra fields — fires once after every challenge gate passes) |
download_failed | reason (string), file_id (int or null), version_id (int). |
gate_passed example
{
"content_type": "mc_dm_download",
"event": "gate_passed",
"content_id": 42,
"gate": "bandwidth",
"context": { "bytes_per_sec": 524288 },
"data": { "download_id": 42, "...": "..." }
}
gate_failed example
{
"content_type": "mc_dm_download",
"event": "gate_failed",
"content_id": 42,
"gate": "criteria",
"reason": "criteria_not_met",
"context": { "trust_level": 1, "required": 3 },
"data": { "download_id": 42, "...": "..." }
}
Choosing between gate_passed and gate_all_passed
gate_passed fires once per gate per attempt — up to seven times for one click (six chain gates plus bandwidth). gate_all_passed fires exactly once per attempt that clears the chain. Subscribe to gate_passed if you want per-gate telemetry; subscribe to gate_all_passed if you want a single "this user is about to start downloading" notification.
gate_failed short-circuits — only the first failing gate fires it. If you need the cumulative state, your receiver has to combine multiple gate_failed deliveries across separate attempts (which is rare; users usually fix the first failure and try again).
Verifying authenticity
The receiver compares the XF-Webhook-Secret header against its stored copy of the same secret. Use a constant-time comparison primitive (crypto.timingSafeEqual in Node, hmac.compare_digest in Python, subtle.ConstantTimeCompare in Go, etc.) so the comparison cannot be timed by an attacker.
A typical Node/Express receiver:
import { timingSafeEqual } from 'node:crypto';
app.post('/webhooks/xf', (req, res) => {
const expected = Buffer.from(process.env.MY_WEBHOOK_SECRET);
const got = Buffer.from(req.get('XF-Webhook-Secret') ?? '');
if (expected.length !== got.length || !timingSafeEqual(expected, got))
{
return res.sendStatus(401);
}
// queue req.body for async processing, ack quickly
res.sendStatus(200);
});
The pattern is the same in every language: read XF-Webhook-Secret, compare to a stored secret with a constant-time function, reject on mismatch, ack 200 fast.
Always serve over HTTPS. The secret is sent in plaintext; without TLS, anyone on the network path can read it.
Response handling
Any 2xx response is treated as success. Any non-2xx (or a connection error) marks the delivery as failed and triggers the retry rules in Retries. Response bodies are not stored.
De-duplication
If your receiver needs exactly-once semantics, key on the tuple (XF-Webhook-Id, XF-Content-Type, XF-Webhook-Event, content_id) plus a timestamp you record on receipt. There is no built-in delivery UUID; if you need one, generate it from the body on receipt.
A retry of the same delivery sends an identical body with identical headers. If you ack a duplicate, no further retries fire.