Getting a Telegram message the moment a known pump.fun deployer launches a new token is more useful than a dashboard you have to remember to check. Polling an API on a schedule introduces latency and burns API quota. A webhook-driven Telegram bot solves both problems: MadeOnSol pushes the event to your server, your server formats and forwards it, and you get the alert in under a second — on your phone.
This guide builds a complete, production-ready Telegram alert bot that fires on deployer:alert and deployer:bond events. By the end you will have a running Node.js server that verifies signatures, filters for elite deployers, and sends formatted Telegram messages. If your team lives in Discord instead, the same webhook flow drives our Pump.fun deployer alert Discord bot guide — and if you want the polling-based version that runs on a free key with zero server setup, the deployer-alert Discord starter tutorial is the five-minute path. New to Telegram bots entirely? Start with the from-scratch grammY tutorial.
Prerequisites
- Node.js 18+
- A MadeOnSol PRO or ULTRA API key — webhooks are not available on the BASIC tier. See pricing at madeonsol.com/pricing. PRO is €43/month (≈ $49) with up to 3 registered webhooks.
- A Telegram account to create the bot
- A publicly reachable HTTPS server — Telegram cannot push to localhost. For local development, use ngrok to create a tunnel. For production, any VPS with a domain works.
Architecture
The data flow is straightforward:
MadeOnSol API → POST /webhook → Express server → Verify HMAC → Filter deployer tier → Telegram Bot API
MadeOnSol signs every webhook payload with HMAC-SHA256 over <timestamp>.<body>. Your server verifies the signature and rejects anything outside a 5-minute freshness window before acting on the payload. This prevents both spoofed requests and replays of an intercepted payload.
Step 1: Create a Telegram Bot
Open Telegram and start a conversation with @BotFather. Run these commands:
/newbot
BotFather will ask for a name and username. After that, it gives you a bot token — a string like 7123456789:AAHdqTcvCH1vGWJxfSeofSs0K_h3-4EVzMA. Save this.
Next, get your chat ID. Start a conversation with your new bot by sending it any message. Then open this URL in a browser (replace YOUR_TOKEN):
https://api.telegram.org/botYOUR_TOKEN/getUpdates
Look for "chat":{"id":...} in the JSON response. That number is your chat ID. For group chats, the ID will be negative (e.g., -1001234567890).
Store both in your environment:
TELEGRAM_BOT_TOKEN=7123456789:AAHdqTcvCH1vGWJxfSeofSs0K_h3-4EVzMA
TELEGRAM_CHAT_ID=123456789
MADEONSOL_API_KEY=msk_your_key_here
WEBHOOK_SECRET=choose_a_random_secret_string
Step 2: Set Up the Express Server
Initialize the project:
mkdir deployer-alert-bot && cd deployer-alert-bot
npm init -y
npm install express dotenv
npm install -D @types/express @types/node typescript ts-node
Create src/server.ts:
import express, { Request, Response } from 'express';
import crypto from 'crypto';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
// IMPORTANT: Use raw body for HMAC verification — parsed JSON won't match the signature
app.use('/webhook', express.raw({ type: 'application/json' }));
app.use(express.json());
const PORT = process.env.PORT || 4000;
app.post('/webhook', async (req: Request, res: Response) => {
// Verify timestamp + signature first — reject anything stale or unsigned
const ts = req.headers['x-madeonsol-timestamp'] as string;
const sig = req.headers['x-madeonsol-signature'] as string;
if (!verifySignature(ts, req.body, sig)) {
console.warn('Invalid signature or stale timestamp — rejecting request');
return res.status(401).send('Unauthorized');
}
const payload = JSON.parse(req.body.toString());
await handleWebhookEvent(payload);
res.status(200).send('OK');
});
app.listen(PORT, () => {
console.log(`Webhook server listening on port ${PORT}`);
});
Step 3: Register the Webhook with MadeOnSol
Before events will arrive, you need to tell MadeOnSol where to send them. Make a POST request to https://madeonsol.com/api/v1/webhooks:
// scripts/register-webhook.ts
import dotenv from 'dotenv';
dotenv.config();
async function registerWebhook() {
const response = 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',
secret: process.env.WEBHOOK_SECRET,
events: ['deployer:alert', 'deployer:bond'],
enabled: true,
}),
});
const data = await response.json();
console.log('Webhook registered:', data);
}
registerWebhook().catch(console.error);
Run this once: npx ts-node scripts/register-webhook.ts
The response includes a webhook ID — save it if you need to update or delete the webhook later.
Step 4: Verify the HMAC Signature
Every incoming request includes X-MadeOnSol-Timestamp (unix milliseconds) and X-MadeOnSol-Signature (HMAC-SHA256 hex digest of <timestamp>.<body> keyed with your webhook secret). Reject anything older than 5 minutes — the timestamp + freshness check is what prevents an attacker who captured a single legitimate webhook from replaying it later.
const FRESHNESS_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
function verifySignature(timestamp: string, rawBody: Buffer, signature: string): boolean {
if (!timestamp || !signature) return false;
// Replay protection: reject if outside the freshness window
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > FRESHNESS_WINDOW_MS) {
return false;
}
// Sign `<timestamp>.<rawBody>` — the literal string + raw body bytes
const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(signed)
.digest('hex');
// Use timingSafeEqual to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
} catch {
return false;
}
}
Three important details:
- Use
express.raw() on the webhook route, not express.json(). Once the body is parsed to an object, the byte-for-byte match against the signature will fail.
- Concatenate
${timestamp}. (a string) with the raw body buffer — do not JSON-encode anything. The receiver must reproduce the exact byte sequence the sender signed.
- Use
crypto.timingSafeEqual instead of === to prevent timing-based attacks.
For a deeper, multi-language treatment of this exact signature scheme — including a Python/FastAPI implementation and the common mistakes that break it — see verifying HMAC-signed webhooks in Node.js and Python.
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 ALERT_TIERS = new Set(['elite', 'good']);
const FRESHNESS_WINDOW_MS = 5 * 60 * 1000;
function verifySignature(timestamp: string, rawBody: Buffer, signature: string): boolean {
if (!timestamp || !signature) return false;
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > FRESHNESS_WINDOW_MS) return false;
const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(signed)
.digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
} catch {
return false;
}
}
async function sendTelegram(text: string): Promise<void> {
await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: process.env.TELEGRAM_CHAT_ID,
text,
parse_mode: 'HTML',
disable_web_page_preview: true,
}),
});
}
app.post('/webhook', async (req: Request, res: Response) => {
const ts = req.headers['x-madeonsol-timestamp'] as string;
const sig = req.headers['x-madeonsol-signature'] as string;
if (!verifySignature(ts, req.body, sig)) {
return res.status(401).send('Unauthorized');
}
const payload = JSON.parse(req.body.toString());
const { event, data } = payload;
if ((event === 'deployer:alert' || event === 'deployer:bond') && ALERT_TIERS.has(data.deployer_tier)) {
const eventLabel = event === 'deployer:bond' ? 'Bonded' : 'New Launch';
const mcap = data.market_cap_usd ? `$${(data.market_cap_usd / 1000).toFixed(1)}k` : 'N/A';
const tierLabel = data.deployer_tier === 'elite' ? '[ELITE]' : '[GOOD]';
const message = [
`<b>${tierLabel} Deployer — ${eventLabel}</b>`,
``,
`Token: <b>${data.token_name} (${data.token_symbol})</b>`,
`CA: <code>${data.token_address}</code>`,
``,
`Win Rate: <b>${(data.deployer_win_rate * 100).toFixed(1)}%</b>`,
`Launches: ${data.deployer_total_launches}`,
`Market Cap: ${mcap}`,
`KOL Buys: ${data.kol_buys}`,
``,
`<a href="https://pump.fun/coin/${data.token_address}">pump.fun</a> | <a href="https://dexscreener.com/solana/${data.token_address}">DexScreener</a>`,
].join('\n');
await sendTelegram(message);
console.log(`[${new Date().toISOString()}] Alert sent: ${data.token_symbol} (${data.deployer_tier})`);
}
res.status(200).send('OK');
});
app.listen(process.env.PORT || 4000, () => {
console.log(`Webhook server listening on port ${process.env.PORT || 4000}`);
});