Discord is where most crypto trading communities actually operate. Putting deployer alerts into a dedicated Discord channel means your team sees them in the same place they discuss trades, with no separate app to check and no polling dashboard to refresh. The alert arrives as a rich embed with color coding, links, and the deployer's track record, and it is pushed the moment the on-chain event fires.
This guide covers two approaches:
- Discord webhook URL (simple, no bot account needed) — a channel-specific URL you POST formatted embeds to
- Discord.js bot (more control, can respond to slash commands, manage permissions)
We will focus on approach one because it is faster to set up and handles this use case without the overhead of managing a bot application. Approach two is mentioned where relevant. If your community lives on Telegram instead, the same webhook flow drives our Solana deployer alert Telegram bot tutorial.
Prerequisites
- A Discord server where you have Manage Webhooks permission
- Node.js 18+
- A MadeOnSol PRO or ULTRA API key — webhook delivery requires a paid tier. Free BASIC keys cover REST polling only. See pricing at madeonsol.com/pricing.
- A server with a public HTTPS URL to receive MadeOnSol's outbound webhook POSTs
Step 1: Create a Discord Webhook in Your Channel
In Discord, open the channel where you want alerts posted. Go to Edit Channel (gear icon) → Integrations → Webhooks → New Webhook.
Give it a name (e.g., "Deployer Alerts") and optionally a custom avatar. Click Copy Webhook URL — you will get a URL like:
https://discord.com/api/webhooks/1234567890123456789/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Store this in your environment as DISCORD_WEBHOOK_URL. Treat it like a password — anyone with this URL can post to your channel.
Step 2: Set Up the Express Server
mkdir deployer-discord-bot && cd deployer-discord-bot
npm init -y
npm install express dotenv
npm install -D @types/express @types/node typescript ts-node
Create your .env file:
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your/url
MADEONSOL_API_KEY=msk_your_key_here
PORT=4000
# WEBHOOK_SECRET is added in Step 3: MadeOnSol generates it when you register the webhook
Create src/server.ts with the raw body middleware on the webhook route — this is required for HMAC verification:
import express, { Request, Response } from 'express';
import crypto from 'crypto';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
// Raw body on webhook route — must come before express.json()
app.use('/webhook', express.raw({ type: 'application/json' }));
app.use(express.json());
app.post('/webhook', async (req: Request, res: Response) => {
const ok = verifyMadeOnSol(
req.body,
req.get('X-MadeOnSol-Timestamp'),
req.get('X-MadeOnSol-Signature'),
process.env.WEBHOOK_SECRET!,
);
if (!ok) {
console.warn('Signature mismatch or stale timestamp, dropping request');
return res.status(401).send('Unauthorized');
}
// Acknowledge first: each delivery attempt times out after 10 seconds
res.status(200).send('OK');
const payload = JSON.parse(req.body.toString('utf8'));
handleEvent(payload).catch(console.error);
});
app.listen(process.env.PORT || 4000, () => {
console.log(`Server listening on port ${process.env.PORT || 4000}`);
});
Step 3: Register Deployer Events with MadeOnSol
Tell MadeOnSol to deliver deployer:alert and deployer:bond events to your server. The deployer:alert event fires when a tracked deployer creates a new token. The deployer:bond event fires when one of their tokens hits the bonding curve graduation threshold.
// scripts/register-webhook.ts
import dotenv from 'dotenv';
dotenv.config();
async function register() {
const res = await fetch('https://madeonsol.com/api/v1/webhooks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.MADEONSOL_API_KEY}`,
},
body: JSON.stringify({
url: 'https://your-server.example.com/webhook',
events: ['deployer:alert', 'deployer:bond'],
// Optional: only deliver elite and good deployers
filters: { deployer_tier: ['elite', 'good'] },
}),
});
const data = await res.json();
if (!res.ok) throw new Error(JSON.stringify(data));
// The secret is generated by MadeOnSol and shown ONCE. Save it now.
console.log('Webhook id:', data.webhook.id);
console.log('WEBHOOK_SECRET=' + data.webhook.secret);
}
register().catch(console.error);
Run it once with npx ts-node scripts/register-webhook.ts. MadeOnSol generates the signing secret and returns it only in this response, as webhook.secret: copy it into your .env as WEBHOOK_SECRET right away, because it cannot be read back later. You do not choose the secret, and the create call ignores any secret field you send. Also save the returned webhook id; you need it to test, update, or disable the webhook. New webhooks are active immediately.
Step 4: Verify the HMAC Signature
MadeOnSol signs every webhook with HMAC-SHA256 over <timestamp>.<body>, where the timestamp comes from the X-MadeOnSol-Timestamp header (unix milliseconds, new for every delivery attempt) and the signature is the bare lowercase hex digest in X-MadeOnSol-Signature. The key is your WEBHOOK_SECRET string exactly as MadeOnSol returned it. Including the timestamp in the signed material plus a freshness check stops anyone who captures one webhook from replaying it later. The freshness window is your choice; MadeOnSol does not enforce one, and because every retry is signed again it never rejects a legitimate retry.
// rawBody: the exact request bytes (never re-serialized JSON)
// secret: the webhook secret exactly as the API returned it (do not hex-decode)
function verifyMadeOnSol(rawBody: Buffer | string, timestamp: string | undefined, signature: string | undefined, secret: string): boolean {
// X-MadeOnSol-Timestamp: unix milliseconds, fresh on every retry
if (typeof timestamp !== "string" || !/^\d{1,16}$/.test(timestamp)) return false;
// Replay window: your choice, the server does not enforce one
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60_000) return false;
// X-MadeOnSol-Signature: 64 hex characters, bare (no algorithm prefix)
if (typeof signature !== "string" || !/^[0-9a-f]{64}$/i.test(signature)) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
// Both sides are exactly 32 bytes here, so this compares in constant time and cannot throw
return crypto.timingSafeEqual(expected, Buffer.from(signature, "hex"));
}
Two critical details:
- Compute the HMAC over the raw request bytes, not a re-serialized JSON object. That is why the express route uses
express.raw(). If you parse to JSON first and then re-stringify, whitespace and key ordering differences will break the comparison.
- The signed payload is the literal string
${timestamp}. followed by the body bytes, not JSON. Concatenate, then HMAC.
Step 5: Format Discord Embed Messages with Tier Color Coding
Discord webhooks accept a JSON body with an embeds array. Each embed can have a color (as a decimal integer), fields, a title, and a URL. This is much more readable than plain text.
Color coding by deployer tier makes it easy to scan the alert feed at a glance. Deployer alerts are sent for the elite, good and rising tiers:
const TIER_COLORS: Record<string, number> = {
elite: 0xFFD700, // Gold
good: 0x00C851, // Green
rising: 0x33B5E5, // Blue
};
const TIER_LABELS: Record<string, string> = {
elite: 'Elite',
good: 'Good',
rising: 'Rising',
};
Build the embed:
// The fields this bot reads. deployer:alert and deployer:bond carry more
// (bonding_rate, recent_bond_rate, runner_rate, deployer_sol_balance, ...).
interface DeployerData {
alert_id?: string | null; // deployer:alert only
tx_signature?: string | null; // deployer:alert only (the create transaction)
deployer_wallet: string;
deployer_tier: string;
token_mint: string;
token_name: string | null;
token_symbol: string | null;
total_bonded: number;
total_deployed: number;
market_cap_at_alert: number | null;
dev_buy_sol?: number | null; // deployer:alert only
time_to_bond_minutes?: number; // deployer:bond only
}
function buildEmbed(event: string, data: DeployerData) {
const color = TIER_COLORS[data.deployer_tier] ?? 0x9E9E9E;
const tierLabel = TIER_LABELS[data.deployer_tier] ?? data.deployer_tier;
const eventLabel = event === 'deployer:bond' ? 'Token Bonded' : 'New Token Launch';
const mcap = data.market_cap_at_alert
? `$${(data.market_cap_at_alert / 1000).toFixed(1)}k`
: 'Unknown';
return {
title: `${tierLabel} Deployer: ${eventLabel}`,
url: `https://pump.fun/coin/${data.token_mint}`,
color,
fields: [
{
name: 'Token',
value: `**${data.token_name ?? '?'}** (${data.token_symbol ?? '?'})`,
inline: true,
},
{
name: 'Market Cap',
value: mcap,
inline: true,
},
{
name: 'Bonded / Deployed',
value: `${data.total_bonded} / ${data.total_deployed}`,
inline: true,
},
{
name: 'Tier',
value: tierLabel,
inline: true,
},
{
name: 'Contract Address',
value: `\`${data.token_mint}\``,
inline: false,
},
{
name: 'Links',
value: [
`[pump.fun](https://pump.fun/coin/${data.token_mint})`,
`[DexScreener](https://dexscreener.com/solana/${data.token_mint})`,
`[Birdeye](https://birdeye.so/token/${data.token_mint})`,
].join(' | '),
inline: false,
},
],
footer: {
text: `MadeOnSol Deployer Hunter • ${new Date().toUTCString()}`,
},
};
}
Step 6: POST to the Discord Webhook URL
async function sendDiscordEmbed(embed: object): Promise<void> {
const response = await fetch(process.env.DISCORD_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ embeds: [embed] }),
});
if (!response.ok) {
const text = await response.text();
console.error(`Discord webhook failed: ${response.status} — ${text}`);
}
}
Discord's webhook API accepts up to 10 embeds per request. For this use case, one embed per alert is cleaner.
Highlighting a Large Dev Buy
The deployer:alert payload includes dev_buy_sol: how much SOL the deployer spent buying their own token in the create transaction (null when there was no dev buy). A large self-buy from a proven deployer is worth highlighting separately.
Extend the embed conditionally:
if ((data.dev_buy_sol ?? 0) >= 5) {
embed.fields.push({
name: 'Dev Buy',
value: `Deployer bought ${data.dev_buy_sol!.toFixed(2)} SOL at launch`,
inline: false,
});
}
You can also add a @here mention in the webhook body for those events:
await fetch(process.env.DISCORD_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: (data.dev_buy_sol ?? 0) >= 5 ? '@here Large dev buy' : null,
embeds: [embed],
}),
});
Filtering: Elite and Good Tiers Only
Rising deployers launch frequently. Without filtering, you will get noise that drowns out the useful signals. The filters: { deployer_tier: ['elite', 'good'] } you passed in Step 3 already drops them on MadeOnSol's side; a gate in your handler keeps the bot correct even if you change that filter later:
const ALLOWED_TIERS = new Set(['elite', 'good']);
async function handleEvent(payload: { event: string; data: DeployerData }) {
const { event, data } = payload;
if (event !== 'deployer:alert' && event !== 'deployer:bond') return;
if (!ALLOWED_TIERS.has(data.deployer_tier)) {
console.log(`Skipping ${data.token_symbol}, tier: ${data.deployer_tier}`);
return;
}
const embed = buildEmbed(event, data);
await sendDiscordEmbed(embed);
console.log(`[${new Date().toISOString()}] Sent: ${data.token_symbol} (${data.deployer_tier})`);
}
If you want separate channels for elite vs. good alerts, keep two Discord webhook URLs and route based on tier.
Complete Server Code
import express, { Request, Response } from 'express';
import crypto from 'crypto';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
app.use('/webhook', express.raw({ type: 'application/json' }));
app.use(express.json());
const TIER_COLORS: Record<string, number> = {
elite: 0xFFD700,
good: 0x00C851,
rising: 0x33B5E5,
};
const ALLOWED_TIERS = new Set(['elite', 'good']);
const seen = new Set<string>(); // use Redis or a DB unique key in production
// rawBody: the exact request bytes (never re-serialized JSON)
// secret: the webhook secret exactly as the API returned it (do not hex-decode)
function verifyMadeOnSol(rawBody: Buffer | string, timestamp: string | undefined, signature: string | undefined, secret: string): boolean {
// X-MadeOnSol-Timestamp: unix milliseconds, fresh on every retry
if (typeof timestamp !== "string" || !/^\d{1,16}$/.test(timestamp)) return false;
// Replay window: your choice, the server does not enforce one
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60_000) return false;
// X-MadeOnSol-Signature: 64 hex characters, bare (no algorithm prefix)
if (typeof signature !== "string" || !/^[0-9a-f]{64}$/i.test(signature)) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
// Both sides are exactly 32 bytes here, so this compares in constant time and cannot throw
return crypto.timingSafeEqual(expected, Buffer.from(signature, "hex"));
}
function buildEmbed(event: string, data: any) {
const color = TIER_COLORS[data.deployer_tier] ?? 0x9E9E9E;
const eventLabel = event === 'deployer:bond' ? 'Token Bonded' : 'New Token Launch';
const mcap = data.market_cap_at_alert ? `$${(data.market_cap_at_alert / 1000).toFixed(1)}k` : 'Unknown';
const tierLabel = data.deployer_tier.charAt(0).toUpperCase() + data.deployer_tier.slice(1);
return {
title: `${tierLabel} Deployer: ${eventLabel}`,
url: `https://pump.fun/coin/${data.token_mint}`,
color,
fields: [
{ name: 'Token', value: `**${data.token_name ?? '?'}** (${data.token_symbol ?? '?'})`, inline: true },
{ name: 'Market Cap', value: mcap, inline: true },
{ name: 'Bonded / Deployed', value: `${data.total_bonded} / ${data.total_deployed}`, inline: true },
{ name: 'Tier', value: tierLabel, inline: true },
{ name: 'CA', value: `\`${data.token_mint}\``, inline: false },
{
name: 'Links',
value: `[pump.fun](https://pump.fun/coin/${data.token_mint}) | [DexScreener](https://dexscreener.com/solana/${data.token_mint})`,
inline: false,
},
],
footer: { text: `MadeOnSol • ${new Date().toUTCString()}` },
};
}
async function sendDiscordEmbed(embed: object, content?: string): Promise<void> {
const res = await fetch(process.env.DISCORD_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content ?? null, embeds: [embed] }),
});
if (!res.ok) console.error(`Discord error: ${res.status} ${await res.text()}`);
}
app.post('/webhook', async (req: Request, res: Response) => {
const ok = verifyMadeOnSol(
req.body,
req.get('X-MadeOnSol-Timestamp'),
req.get('X-MadeOnSol-Signature'),
process.env.WEBHOOK_SECRET!,
);
if (!ok) return res.status(401).send('Unauthorized');
res.status(200).send('OK'); // acknowledge inside the 10 s delivery timeout
const { event, data } = JSON.parse(req.body.toString('utf8'));
if (event !== 'deployer:alert' && event !== 'deployer:bond') return;
if (!ALLOWED_TIERS.has(data.deployer_tier)) return;
// No event id on the envelope: a retry resends the same body, so dedupe on
// alert_id for launches and on the mint for bonds (a token bonds once).
const key = event === 'deployer:alert' ? `alert:${data.alert_id ?? data.tx_signature}` : `bond:${data.token_mint}`;
if (seen.has(key)) return;
seen.add(key);
const embed = buildEmbed(event, data);
const mention = (data.dev_buy_sol ?? 0) >= 5 ? '@here Large dev buy' : undefined;
await sendDiscordEmbed(embed, mention).catch(console.error);
console.log(`[${new Date().toISOString()}] Sent: ${data.token_symbol} (${data.deployer_tier})`);
});
app.listen(process.env.PORT || 4000, () => console.log(`Listening on ${process.env.PORT || 4000}`));