Skip to main content

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

HeaderPurpose
Content-Typeapplication/json (when the webhook is configured for JSON; application/x-www-form-urlencoded if configured for form).
XF-Content-TypeThe content type — for example mc_dm_download, mc_dm_version.
XF-Webhook-EventThe event name — insert, update, delete, publish, unpublish, download_started, download_completed, download_failed, gate_passed, gate_failed, gate_all_passed.
XF-Webhook-IdThe webhook record ID. Useful when one receiver handles multiple subscriptions.
XF-Webhook-SecretThe 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:

EventExtra fields
gate_passedgate (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_failedgate (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_failedreason (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.