Receive instant notifications when users vote for your bot. Use vote webhooks to grant rewards, track voting patterns, and build engagement features.
Overview
When a user successfully votes for your bot on Rankly, an HTTP POST request is sent to your configured webhook endpoint. This enables you to:
π Grant instant rewards to voters
π Track voting participation
π― Build loyalty programs
π Handle credit awards automatically
Vote webhooks are distinct from premium webhooks. Each uses a separate webhook secret (RANKLY_VOTE_WEBHOOK_SECRET).
Webhook Delivery
Vote webhooks are delivered as JSON POST requests:
POST https://your-endpoint.com/webhook/votes
Content-Type: application/json
X-Webhook-Signature: < hmac_sha256_he x >
Payload Structure
{
"event" : "bot_vote" ,
"timestamp" : "2025-11-25T10:00:00.000Z" ,
"voter" : {
"userId" : "987654321098765432" ,
"username" : "exampleuser"
},
"bot" : {
"id" : "1234567890123456789" ,
"name" : "Your Bot Name"
},
"creditsAwarded" : true ,
"adBlocker" : false ,
"creditDenialReason" : null
}
Field Reference
Event identifier. Always bot_vote for vote webhooks.
ISO 8601 formatted timestamp when the vote occurred.
The Discord user ID of the voter.
The Discord username of the voter.
Your botβs Discord application ID.
Your botβs display name on Rankly.
Whether Rankly platform credits were awarded for this vote.
true if an ad blocker was detected during the vote process.
Explanation if credits werenβt awarded. null when credits were successfully awarded.
Understanding Credit Awards
The creditsAwarded and adBlocker fields help you understand the vote context:
Scenario creditsAwarded adBlocker creditDenialReason Successful vote, no blocker truefalsenullVote blocked by ad blocker falsetrueAd blocker detected Duplicate recent vote falsefalseAlready voted recently
Even if creditsAwarded is false, you may want to grant your own bot rewards for user engagement and support.
Signature Verification
All webhooks include a cryptographic signature. Verify it before processing to prevent unauthorized requests:
Verification Steps
Extract X-Webhook-Signature header
Stringify the JSON payload as received
Compute HMAC SHA256 with your webhook secret
Compare using timing-safe comparison
const crypto = require ( 'crypto' );
function verifyWebhookSignature ( payload , signature , secret ) {
if ( ! signature || ! secret ) {
return false ;
}
const expectedSignature = crypto
. createHmac ( 'sha256' , secret )
. update ( JSON . stringify ( payload ))
. digest ( 'hex' );
try {
return crypto . timingSafeEqual (
Buffer . from ( signature ),
Buffer . from ( expectedSignature )
);
} catch {
return false ;
}
}
import hmac
import hashlib
import json
def verify_webhook_signature ( payload , signature , secret ):
expected_signature = hmac.new(
secret.encode( 'utf-8' ),
json.dumps(payload, separators = ( ',' , ':' )).encode( 'utf-8' ),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
package main
import (
" crypto/hmac "
" crypto/sha256 "
" crypto/subtle "
" encoding/hex "
" encoding/json "
)
func verifyWebhookSignature ( payload interface {}, signature string , secret string ) bool {
payloadBytes , err := json . Marshal ( payload )
if err != nil {
return false
}
mac := hmac . New ( sha256 . New , [] byte ( secret ))
mac . Write ( payloadBytes )
expectedSignature := hex . EncodeToString ( mac . Sum ( nil ))
return subtle . ConstantTimeCompare ([] byte ( signature ), [] byte ( expectedSignature )) == 1
}
Never process webhooks without verifying the signature. Always use timing-safe comparison functions.
Complete Handler Implementation
Hereβs a production-ready webhook handler with verification, idempotency, and reward logic:
const crypto = require ( 'crypto' );
const express = require ( 'express' );
const Redis = require ( 'redis' );
const app = express ();
app . use ( express . json ());
const WEBHOOK_SECRET = process . env . RANKLY_VOTE_WEBHOOK_SECRET ;
const redisClient = Redis . createClient ();
function verifyWebhookSignature ( payload , signature , secret ) {
if ( ! signature || ! secret ) {
return false ;
}
const expectedSignature = crypto
. createHmac ( 'sha256' , secret )
. update ( JSON . stringify ( payload ))
. digest ( 'hex' );
try {
return crypto . timingSafeEqual (
Buffer . from ( signature ),
Buffer . from ( expectedSignature )
);
} catch {
return false ;
}
}
app . post ( '/webhook/votes' , async ( req , res ) => {
const signature = req . headers [ 'x-webhook-signature' ];
const payload = req . body ;
// Verify signature before processing
if ( ! verifyWebhookSignature ( payload , signature , WEBHOOK_SECRET )) {
console . error ( 'Webhook signature verification failed' );
return res . status ( 401 ). json ({ error: 'Invalid signature' });
}
const { voter , bot , timestamp , creditsAwarded , adBlocker } = payload ;
const voteKey = ` ${ voter . userId } : ${ timestamp } ` ;
try {
// Check for duplicates using Redis
const isDuplicate = await redisClient . exists ( voteKey );
if ( isDuplicate ) {
return res . status ( 200 ). json ({ received: true , duplicate: true });
}
// Mark as processed
await redisClient . setex ( voteKey , 86400 , '1' ); // 24 hour expiry
console . log ( `Vote received: ${ voter . username } ( ${ voter . userId } ) voted for ${ bot . name } ` );
// Process the vote asynchronously
await processVote ({
userId: voter . userId ,
username: voter . username ,
botId: bot . id ,
botName: bot . name ,
creditsAwarded ,
adBlocker ,
timestamp
});
return res . status ( 200 ). json ({ received: true });
} catch ( error ) {
console . error ( 'Error processing vote:' , error );
return res . status ( 500 ). json ({ error: 'Processing failed' });
}
});
async function processVote ( data ) {
try {
// Record the vote in your database
await database . votes . insertOne ({
userId: data . userId ,
username: data . username ,
botId: data . botId ,
botName: data . botName ,
creditsAwarded: data . creditsAwarded ,
adBlocker: data . adBlocker ,
votedAt: new Date ( data . timestamp )
});
// Grant rewards
if ( data . creditsAwarded && ! data . adBlocker ) {
await grantVoteReward ( data . userId , data . botId );
} else if ( ! data . adBlocker ) {
// Still reward users for voting, even if Rankly didn't award credits
await grantVoteReward ( data . userId , data . botId );
}
console . log ( `Processed vote from ${ data . username } ` );
} catch ( error ) {
console . error ( 'Error in processVote:' , error );
throw error ;
}
}
async function grantVoteReward ( userId , botId ) {
// Implement your reward logic here
// Examples: +100 currency, +5 XP, etc.
console . log ( `Granted vote reward to ${ userId } ` );
}
app . listen ( 3000 , () => {
console . log ( 'Vote webhook server running on port 3000' );
});
Response Requirements
Your endpoint must meet these specifications for reliable delivery:
Requirement Specification Response Time Return within 10 seconds Success Status HTTP 200 to acknowledge receipt Idempotency Handle duplicate deliveries gracefully Retry Behavior Non-2xx responses trigger retries
Recommended Response
Idempotency & Deduplication
Implement deduplication using voter.userId and timestamp to prevent duplicate reward grants:
Redis (Recommended)
Database
In-Memory (Dev Only)
const Redis = require ( 'redis' );
const redisClient = Redis . createClient ();
app . post ( '/webhook/votes' , async ( req , res ) => {
const { voter , timestamp } = req . body ;
const voteKey = `vote: ${ voter . userId } : ${ timestamp } ` ;
// Check if already processed
const isDuplicate = await redisClient . exists ( voteKey );
if ( isDuplicate ) {
return res . status ( 200 ). json ({ received: true , duplicate: true });
}
// Mark as processed with 24-hour expiry
await redisClient . setex ( voteKey , 86400 , '1' );
// Process vote...
});
app . post ( '/webhook/votes' , async ( req , res ) => {
const { voter , timestamp } = req . body ;
// Check if already processed
const existingVote = await database . votes . findOne ({
userId: voter . userId ,
timestamp: new Date ( timestamp )
});
if ( existingVote ) {
return res . status ( 200 ). json ({ received: true , duplicate: true });
}
// Record immediately to prevent race conditions
await database . votes . insertOne ({
userId: voter . userId ,
timestamp: new Date ( timestamp ),
processed: true
});
// Process vote...
});
const processedVotes = new Map ();
app . post ( '/webhook/votes' , async ( req , res ) => {
const { voter , timestamp } = req . body ;
const voteKey = ` ${ voter . userId } : ${ timestamp } ` ;
if ( processedVotes . has ( voteKey )) {
return res . status ( 200 ). json ({ received: true , duplicate: true });
}
processedVotes . set ( voteKey , true );
// Clear after 24 hours
setTimeout (() => processedVotes . delete ( voteKey ), 86400000 );
// Process vote...
});
Use Redis or database persistence for production. In-memory storage is only suitable for local development.
Reward Implementation
Design your reward system based on vote behavior:
async function grantVoteReward ( userId , botId ) {
const rewardAmount = 100 ; // Your bot's currency unit
try {
// Award currency
await database . users . updateOne (
{ discordId: userId },
{ $inc: { balance: rewardAmount } }
);
// Track reward
await database . rewardLog . insertOne ({
userId ,
botId ,
type: 'vote_reward' ,
amount: rewardAmount ,
awardedAt: new Date ()
});
// Optional: Notify user via DM
await notifyUserOfReward ( userId , rewardAmount );
} catch ( error ) {
console . error ( 'Error granting reward:' , error );
throw error ;
}
}
Database Schema
Votes Table
CREATE TABLE votes (
id SERIAL PRIMARY KEY ,
user_id VARCHAR ( 20 ) NOT NULL ,
username VARCHAR ( 100 ) NOT NULL ,
bot_id VARCHAR ( 20 ) NOT NULL ,
bot_name VARCHAR ( 100 ) NOT NULL ,
credits_awarded BOOLEAN DEFAULT false,
ad_blocker BOOLEAN DEFAULT false,
voted_at TIMESTAMP NOT NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_votes_user_id ON votes(user_id);
CREATE INDEX idx_votes_bot_id ON votes(bot_id);
CREATE INDEX idx_votes_voted_at ON votes(voted_at);
CREATE UNIQUE INDEX idx_votes_dedup ON votes(user_id, voted_at);
Reward Log Table
CREATE TABLE reward_log (
id SERIAL PRIMARY KEY ,
user_id VARCHAR ( 20 ) NOT NULL ,
bot_id VARCHAR ( 20 ) NOT NULL ,
type VARCHAR ( 50 ) NOT NULL ,
amount INTEGER NOT NULL ,
awarded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_reward_log_user_id ON reward_log(user_id);
CREATE INDEX idx_reward_log_awarded_at ON reward_log(awarded_at);
Security Best Practices
Verify Signatures Always validate webhook signatures before processing any vote data.
Use HTTPS Ensure your webhook endpoint uses TLS encryption.
Rate Limiting Implement rate limiting on your webhook endpoint to prevent abuse.
Async Processing Process votes asynchronously to avoid timeout errors.
Monitor Failures Log and monitor signature verification failures for security issues.
Rotate Secrets Regenerate webhook secrets immediately if compromised.
Troubleshooting
Cause: Endpoint not publicly accessibleSolutions:
Verify your server is reachable from the internet
Check firewall rules allow inbound HTTPS traffic
Confirm endpoint URL is correct in Rankly dashboard
Test: curl -X POST https://your-endpoint.com/webhook/votes
Signature verification fails
Cause: Wrong secret or payload modificationSolutions:
Ensure youβre using RANKLY_VOTE_WEBHOOK_SECRET (not premium)
Donβt modify the payload before verification
Check for encoding issues (UTF-8)
Verify secret hasnβt been regenerated
Cause: Processing takes too longSolutions:
Return HTTP 200 immediately
Process reward grants asynchronously
Use background job queue (Bull, Celery, etc.)
Set up monitoring for long-running tasks
Duplicate rewards granted
Cause: Missing idempotency implementationSolutions:
Implement deduplication using voter.userId:timestamp
Use Redis for high-traffic scenarios
Add database constraints for uniqueness
Record votes before processing rewards
creditsAwarded is always false
Cause: Could be ad blocker, duplicate vote, or other restrictionsSolutions:
Check if adBlocker flag is true in payload
Review creditDenialReason for specific cause
Still grant your own rewards for engagement
Monitor credit denial patterns for issues
Best Practices
Next Steps