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 listing's dashboard, bot or game server: 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. Any logged delivery can be sent again with Redeliver (see Retries and idempotency).

Above all of that sits the webhook's health, read from the same delivery record: Healthy, Failing (with the count of consecutive failures on the way to the 20-failure auto-disable), Disabled, Paused or No deliveries yet; the last successful delivery; the last failure with its HTTP status; and, for the last 24 hours, how many events were delivered, failed, are still being retried, or ended undelivered. That last number is what Redeliver is for.

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",
    "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, "game" for game servers, which also carry data.game and data.playerName (see Game Servers).
data.userIdstring-The Discord ID of the voter — everything you need to grant a reward.
data.votedAtstring-When the vote was cast (ISO/UTC).
data.nextVoteAtstring-When this user can vote again (12h later for bots, 24h for game servers).
data.weightnumber-The vote's ranking weight (premium listings' votes can weigh more). The public vote count always moves by exactly 1.
Schema change: deliveries no longer include data.username or data.avatarUrl. Both were documented as nullable, so code that reads them already handles their absence, and neither was ever needed to grant a reward — that is what data.userId is for, and it is unchanged. We stopped sending a voter's name and picture to third-party endpoints because nothing about rewarding a vote requires them. If you display the voter, resolve the name from data.userId through Discord, where the voter controls what it says. The same two fields are gone from GET /webhooks/votes and from topVoters in GET /webhooks/stats.

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; a redelivery from the dashboard continues the count.
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.

Once the retries are spent, a vote is not pushed again on its own. Any logged delivery can be sent again from the dashboard with Redeliver: the same payload and eventId, the next attempt number, a fresh X-Discordium-Delivery. Use it to verify a fix against a real vote, or to push the votes that failed while your endpoint was down once it is back (re-enable the webhook first). Because the eventId is unchanged, a receiver that deduplicates correctly treats a redelivery exactly like a retry. Redeliveries are logged and count toward auto-disable like every other attempt, at most 10 per minute. Vote events are kept for 90 days; older deliveries can no longer be resent.

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.

“The dashboard says Failing”

Your most recent deliveries did not get a 2xx, and the consecutive-failure count shown is climbing toward the auto-disable threshold. The last failure's HTTP status tells you which kind it is: a 4xx means your endpoint rejected the request (a wrong route, a signature check that fails, a body parser that ate the raw body) and Discordium does not retry it; a 5xx, a timeout or a connection error is retried on the schedule above. Fix the endpoint, press Send test event to see it answer, and use Redeliver on anything listed as undelivered. The count resets on the first successful delivery.

“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, and on a redelivery someone triggered from the dashboard.

“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.