Receive real-time notifications when users purchase premium tiers for your bot. Rankly sends webhook events that enable you to activate premium features immediately without polling.
Overview
When a user purchases a premium tier, Rankly delivers an HTTP POST request to your configured webhook endpoint. This allows your bot to:
β¨ Activate premium features instantly
π Process transactions in real-time
π Track premium user base accurately
π― Handle both user and server-level premium
Premium webhooks are separate from vote webhooks. Ensure youβre using the correct webhook secret (RANKLY_PREMIUM_WEBHOOK_SECRET).
Webhook Delivery
All premium purchase webhooks are delivered as POST requests with JSON payloads:
POST https://your-endpoint.com/webhook/premium
Content-Type: application/json
X-Webhook-Signature: < hmac_sha256_he x >
Payload Structure
{
"event" : "premium_purchase" ,
"purchaseId" : "1732525200000-987654321098765432" ,
"timestamp" : "2025-11-25T10:00:00.000Z" ,
"buyer" : {
"userId" : "987654321098765432" ,
"username" : "exampleuser"
},
"tier" : {
"id" : "pro-monthly" ,
"name" : "Pro Plan" ,
"duration" : "monthly" ,
"planType" : "user"
},
"serverId" : null
}
Field Reference
Event identifier. Always premium_purchase for premium webhooks.
Unique identifier for the transaction. Use this for deduplication and audit trails.
ISO 8601 formatted timestamp when the purchase occurred.
The Discord user ID of the purchaser.
The Discord username of the purchaser.
The unique identifier of the tier (e.g., pro-monthly).
Display name of the tier (e.g., Pro Plan).
Subscription length: weekly, monthly, or lifetime.
Plan scope: user (personal) or server (guild-wide).
Discord server ID. Only present when planType is server.
Plan Types Explained
Type Scope serverIduser Applies to purchaserβs account across all servers nullserver Applies to a specific Discord server Discord Server ID
User-level premium is ideal for personal features, while server-level premium works great for server management features.
Signature Verification
All incoming webhook requests must be verified using the X-Webhook-Signature header. This prevents unauthorized or spoofed requests.
Verification Steps
Extract the X-Webhook-Signature header
Stringify the JSON payload exactly as received
Compute HMAC SHA256 using your webhook secret
Use timing-safe comparison to validate
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
}
Always verify signatures before activating premium features. Never trust unverified requests.
Complete Handler Implementation
Hereβs a production-ready webhook handler with signature verification, idempotency, and proper error handling:
const crypto = require ( 'crypto' );
const express = require ( 'express' );
const app = express ();
app . use ( express . json ());
const WEBHOOK_SECRET = process . env . RANKLY_PREMIUM_WEBHOOK_SECRET ;
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 ;
}
}
function calculateExpiry ( duration ) {
const now = new Date ();
switch ( duration ) {
case 'weekly' :
return new Date ( now . getTime () + 7 * 24 * 60 * 60 * 1000 );
case 'monthly' :
const monthly = new Date ( now );
monthly . setMonth ( monthly . getMonth () + 1 );
return monthly ;
case 'lifetime' :
return null ;
default :
throw new Error ( `Unknown duration: ${ duration } ` );
}
}
app . post ( '/webhook/premium' , 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 { purchaseId , buyer , tier , serverId } = payload ;
// Check for duplicates
const existingPurchase = await database . purchases . findOne ({ purchaseId });
if ( existingPurchase ) {
return res . status ( 200 ). json ({ received: true , duplicate: true });
}
// Calculate expiry
const expiresAt = calculateExpiry ( tier . duration );
console . log ( `Premium purchase: ${ tier . name } by ${ buyer . username } ( ${ buyer . userId } )` );
try {
// Record the purchase
await database . purchases . insertOne ({
purchaseId ,
processedAt: new Date ()
});
// Activate premium based on plan type
if ( tier . planType === 'user' ) {
await activateUserPremium ({
discordId: buyer . userId ,
tierId: tier . id ,
tierName: tier . name ,
expiresAt ,
purchaseId
});
} else if ( tier . planType === 'server' ) {
await activateServerPremium ({
guildId: serverId ,
tierId: tier . id ,
tierName: tier . name ,
expiresAt ,
purchaseId ,
purchasedBy: buyer . userId
});
}
return res . status ( 200 ). json ({ received: true , purchaseId });
} catch ( error ) {
console . error ( 'Error processing premium purchase:' , error );
return res . status ( 500 ). json ({ error: 'Processing failed' });
}
});
async function activateUserPremium ( data ) {
// Insert or update in your database
console . log ( `Activated ${ data . tierName } for user ${ data . discordId } ` );
}
async function activateServerPremium ( data ) {
// Insert or update in your database
console . log ( `Activated ${ data . tierName } for server ${ data . guildId } ` );
}
app . listen ( 3000 , () => {
console . log ( 'Premium webhook server running on port 3000' );
});
Duration Handling
Calculate expiry dates correctly based on subscription type:
Duration Calculation Example weeklyCurrent date + 7 days Today + 1 week monthlyCurrent date + 1 month Dec 25 β Jan 25 lifetimeNever expires null
For monthly subscriptions, use proper calendar month addition instead of adding 30 days to handle varying month lengths correctly.
Response Requirements
Your endpoint must meet these requirements for reliable webhook processing:
Requirement Specification Response Time Return within 5 seconds Success Status HTTP 200 to acknowledge receipt Idempotency Handle duplicate deliveries gracefully Retry Behavior Non-2xx responses trigger retries
Recommended Response
{
"received" : true ,
"purchaseId" : "1732525200000-987654321098765432"
}
Idempotency & Deduplication
Always implement idempotency to handle potential duplicate deliveries:
app . post ( '/webhook/premium' , async ( req , res ) => {
const { purchaseId } = req . body ;
// Check if already processed
const existingPurchase = await database . purchases . findOne ({ purchaseId });
if ( existingPurchase ) {
// Return success to prevent retries
return res . status ( 200 ). json ({ received: true , duplicate: true });
}
// Record immediately to prevent race conditions
await database . purchases . insertOne ({
purchaseId ,
processedAt: new Date ()
});
// Activate premium...
// Process asynchronously to avoid timeout
});
Record the purchase ID in your database immediately after verification to prevent race conditions in high-traffic scenarios.
Database Schema
User Premium
CREATE TABLE user_premium (
id SERIAL PRIMARY KEY ,
discord_id VARCHAR ( 20 ) NOT NULL UNIQUE ,
tier_id VARCHAR ( 50 ) NOT NULL ,
tier_name VARCHAR ( 100 ) NOT NULL ,
expires_at TIMESTAMP NULL ,
purchase_id VARCHAR ( 100 ) NOT NULL UNIQUE ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_user_premium_discord_id ON user_premium(discord_id);
CREATE INDEX idx_user_premium_expires_at ON user_premium(expires_at);
Server Premium
CREATE TABLE server_premium (
id SERIAL PRIMARY KEY ,
guild_id VARCHAR ( 20 ) NOT NULL UNIQUE ,
tier_id VARCHAR ( 50 ) NOT NULL ,
tier_name VARCHAR ( 100 ) NOT NULL ,
expires_at TIMESTAMP NULL ,
purchase_id VARCHAR ( 100 ) NOT NULL UNIQUE ,
purchased_by VARCHAR ( 20 ) NOT NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_server_premium_guild_id ON server_premium(guild_id);
CREATE INDEX idx_server_premium_expires_at ON server_premium(expires_at);
Security Best Practices
Verify Signatures Always validate webhook signatures using timing-safe comparison before processing any data.
Use HTTPS Ensure your webhook endpoint uses TLS encryption to protect sensitive data in transit.
Rotate Secrets Regenerate webhook secrets immediately if compromised via the Rankly dashboard.
Audit Trails Maintain records of all purchaseId values for compliance and support purposes.
Validate Plan Types Handle both user and server plan types correctly in your activation logic.
Async Processing Process premium activation asynchronously to avoid timeout errors and improve reliability.
Troubleshooting
Cause: Endpoint not publicly accessibleSolutions:
Verify your server is reachable from the internet
Check firewall rules allow inbound traffic
Ensure correct endpoint URL in Rankly dashboard
Test with curl: curl -X POST https://your-endpoint.com/webhook/premium
Signature verification fails
Cause: Wrong secret or payload modificationSolutions:
Confirm youβre using RANKLY_PREMIUM_WEBHOOK_SECRET (not vote secret)
Donβt modify the payload before verification
Check for encoding issues (UTF-8)
Regenerate secret if compromised
Cause: Expected for server-type plansSolutions:
serverId is only present when planType is server
Handle null value gracefully for user plans
Check payload structure matches your plan type
Cause: Missing idempotency implementationSolutions:
Implement deduplication using purchaseId
Record purchase IDs before activation
Use database constraints for uniqueness
Cause: Processing takes too longSolutions:
Return HTTP 200 immediately
Process premium activation asynchronously
Use background jobs (Bull, Celery, etc.)
Add monitoring and error logging
Next Steps