Vote Webhooks

Vote webhooks let Discordium notify your application the moment a user votes, instead of you polling the vote-check endpoint. One event exists today: vote.created.

Setup

Configure the webhook in your bot's dashboard: Bot → Integrations → Vote webhook. You set:

  • Endpoint URL: your server's HTTPS endpoint. (A Discord channel webhook URL also works: Discordium then posts a ready-made embed to that channel instead of the JSON below, so you get vote announcements with zero code.)
  • Event: vote.created, currently the only one.
  • Signing secret: generated in the dashboard, shown once. Treat it like a password: it is what lets your endpoint prove a delivery really came from Discordium.

The same tab has a Send test event button (the delivery arrives with "test": true) and a log of recent deliveries with status, HTTP code, attempt and duration.

Payload

Every delivery is an HTTP POST with a JSON body of this exact shape (version 1; fields are only ever added, never renamed or removed):

{
  "event": "vote.created",
  "version": 1,
  "eventId": "1024",
  "timestamp": "2026-08-24T18:42:03.000Z",
  "test": false,
  "data": {
    "listingId": "123456789012345678",
    "listingType": "bot",
    "userId": "876543210987654321",
    "username": "somevoter",
    "avatarUrl": "https://cdn.discordapp.com/avatars/…",
    "votedAt": "2026-08-24T18:42:00.000Z",
    "nextVoteAt": "2026-08-25T06:42:00.000Z",
    "weight": 1
  }
}
FieldTypeRequiredDescription
eventstring-Always vote.created today.
versionnumber-Payload version; currently 1.
eventIdstring-The vote event's ID. Stable across retries; your deduplication key. "test" for test deliveries.
timestampstring-When this delivery was built (ISO/UTC).
testboolean-True for dashboard test deliveries. Don't grant rewards for these.
data.listingIdstring-Your listing's ID.
data.listingTypestring-"bot" for bot listings.
data.userIdstring-The Discord ID of the voter.
data.usernamestring | null-The voter's Discordium username, when known.
data.avatarUrlstring | null-The voter's avatar, when known.
data.votedAtstring-When the vote was cast (ISO/UTC).
data.nextVoteAtstring-When this user can vote again (12h later).
data.weightnumber-The vote's ranking weight (premium listings' votes can weigh more). The public vote count always moves by exactly 1.

Alongside the body, every delivery carries these headers:

HeaderTypeRequiredDescription
X-Discordium-Eventvote.created-Which event this delivery carries.
X-Discordium-Event-Id1024-Stable across retries; deduplicate on this.
X-Discordium-Deliverya UUID-Unique per delivery attempt.
X-Discordium-Attempt1-1-based attempt number.
X-Webhook-Signaturesha256=<hex>-HMAC-SHA256 of the exact request body. Only sent when a signing secret is configured.

Verifying signatures

When a signing secret is configured, Discordium signs each delivery: X-Webhook-Signature is sha256=<hex>, the HMAC-SHA256 of the exact request body bytes under your secret. Verify before trusting anything in the payload.

Two rules make or break verification: compute the HMAC over the raw request body (never over JSON you parsed and re-serialized, which almost never reproduces the same bytes), and compare with a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest), never === or ==.
// Verify a delivery's signature (Node.js).
const crypto = require("node:crypto");

function verifySignature(rawBody, signatureHeader, secret) {
    const expected =
        "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
    // Constant-time comparison. Never use ===
    const a = Buffer.from(signatureHeader ?? "");
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Retries and idempotency

Delivery is at-least-once: the same event can reach you more than once, so processing must be safe to repeat. Two identifiers make that easy:

  • eventId (also X-Discordium-Event-Id) names the vote event and is stable across retries. Store processed IDs and skip repeats; that is what prevents granting the same reward twice.
  • X-Discordium-Delivery names the individual delivery attempt and is unique every time. Useful for correlating with the dashboard's delivery log, never for deduplication.

How your endpoint's response is interpreted:

Your responseTypeRequiredDescription
2xx--Delivered. Done.
4xx--Permanent failure. Your endpoint received and rejected the request, so this delivery is not retried.
5xx--Retryable failure. Discordium retries.
timeout / network error--Retryable failure. Discordium retries. Deliveries time out after 10 seconds; respond fast and process afterwards.

Failed deliveries are retried up to 5 times with increasing delays (typically over the following minutes). An endpoint that keeps failing (20 consecutive failed attempts) is automatically disabled; the dashboard shows this prominently, and re-saving the webhook re-enables it with a clean slate.

A complete minimal receiver

Raw body → verify signature → reject invalid → parse → deduplicate on eventId → process:

// A complete minimal webhook receiver (Express).
const express = require("express");
const crypto = require("node:crypto");

const app = express();
const SECRET = process.env.DISCORDIUM_WEBHOOK_SECRET;
const seenEvents = new Set(); // use your database in production

app.post(
    "/webhooks/discordium",
    // Raw body: the signature covers the exact bytes Discordium sent.
    express.raw({ type: "application/json" }),
    (req, res) => {
        const signature = req.get("X-Webhook-Signature") ?? "";
        const expected =
            "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
        const valid =
            signature.length === expected.length &&
            crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
        if (!valid) return res.status(401).end();

        const payload = JSON.parse(req.body);

        // Deliveries are at-least-once: the same event can arrive twice.
        // eventId is stable across retries, so it is your deduplication key.
        if (payload.test || seenEvents.has(payload.eventId)) {
            return res.status(200).end();
        }
        seenEvents.add(payload.eventId);

        const { userId, votedAt } = payload.data;
        grantVoteReward(userId, votedAt); // your reward logic

        res.status(200).end(); // 2xx tells Discordium the delivery succeeded
    },
);

app.listen(3000);

Common mistakes

“My signature doesn't match”

  • You parsed the JSON and re-serialized it before verifying. Sign the raw bytes.
  • A body-parsing middleware consumed the raw body before your handler saw it.
  • Wrong secret (it was replaced in the dashboard, or you're mixing environments).
  • Comparing with === on trimmed/re-encoded strings instead of a constant-time compare over the exact header value.

“I received the same vote twice”

Expected: delivery is at-least-once. Deduplicate on eventId; it is identical on every retry of the same vote.

“voted is false even though the user voted”

The vote-check answers current eligibility: a vote older than the 12-hour window is real history but no longer active, so the answer is false, and the user can vote again immediately.

“My statistics aren't updating”

  • The token is missing the stats:write scope (that answers 401).
  • The bot ID in the URL isn't the bot the token was created for (403).
  • You're reporting more than once a minute (429; the extra reports change nothing).
  • serverCount missing or out of range (400).
  • It worked. Check the “last reported” timestamp in Integrations before assuming it didn't.