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
}
}| Field | Type | Required | Description |
|---|---|---|---|
| event | string | - | Always vote.created today. |
| version | number | - | Payload version; currently 1. |
| eventId | string | - | The vote event's ID. Stable across retries; your deduplication key. "test" for test deliveries. |
| timestamp | string | - | When this delivery was built (ISO/UTC). |
| test | boolean | - | True for dashboard test deliveries. Don't grant rewards for these. |
| data.listingId | string | - | Your listing's ID. |
| data.listingType | string | - | "bot" for bot listings. |
| data.userId | string | - | The Discord ID of the voter. |
| data.username | string | null | - | The voter's Discordium username, when known. |
| data.avatarUrl | string | null | - | The voter's avatar, when known. |
| data.votedAt | string | - | When the vote was cast (ISO/UTC). |
| data.nextVoteAt | string | - | When this user can vote again (12h later). |
| data.weight | number | - | 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:
| Header | Type | Required | Description |
|---|---|---|---|
| X-Discordium-Event | vote.created | - | Which event this delivery carries. |
| X-Discordium-Event-Id | 1024 | - | Stable across retries; deduplicate on this. |
| X-Discordium-Delivery | a UUID | - | Unique per delivery attempt. |
| X-Discordium-Attempt | 1 | - | 1-based attempt number. |
| X-Webhook-Signature | sha256=<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.
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(alsoX-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-Deliverynames 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 response | Type | Required | Description |
|---|---|---|---|
| 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:writescope (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).
serverCountmissing or out of range (400).- It worked. Check the “last reported” timestamp in Integrations before assuming it didn't.