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 on your phone without polling.
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, ULTRA or BUSINESS API key — generic webhooks are not available on BASIC. Current limits are PRO 3, ULTRA 10 and BUSINESS 30 registered webhooks; Enterprise inherits at least the Business floor.
- 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 is added in Step 3: MadeOnSol generates it when you register the webhook
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 ok = verifyMadeOnSol(
req.body,
req.get('X-MadeOnSol-Timestamp'),
req.get('X-MadeOnSol-Signature'),
process.env.WEBHOOK_SECRET!,
);
if (!ok) {
console.warn('Invalid signature or stale timestamp, rejecting 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'));
handleWebhookEvent(payload).catch(console.error);
});
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',
events: ['deployer:alert', 'deployer:bond'],
}),
});
const data = await response.json();
if (!response.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);
}
registerWebhook().catch(console.error);
Run this once: 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; it cannot be read back later. You do not choose the secret: the create call ignores any secret field you send, so a secret you invented would never match the signatures. The webhook is active as soon as it is created. Save the webhook id too; you need it to test, update or delete the webhook later.
Step 4: Verify the HMAC Signature
Every incoming request includes X-MadeOnSol-Timestamp (unix milliseconds, new for every delivery attempt) and X-MadeOnSol-Signature (the bare lowercase hex HMAC-SHA256 of <timestamp>.<body>, keyed with your webhook secret exactly as MadeOnSol returned it). Reject anything older than 5 minutes: the timestamp plus a freshness check is what prevents an attacker who captured a single legitimate webhook from replaying it later. The window is your own check; 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"));
}
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. Validating the header as 64 hex characters first means both buffers are exactly 32 bytes, so the comparison cannot throw on a malformed header.
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.
Step 5: Format and Send the Telegram Message
Add the Telegram send function:
async function sendTelegram(text: string): Promise<void> {
const url = `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`;
await fetch(url, {
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,
}),
});
}
Now write the event handler that formats deployer events:
// The fields this bot reads. deployer:alert and deployer:bond carry more
// (bonding_rate, recent_bond_rate, runner_rate, deployer_sol_balance, ...).
interface DeployerAlertPayload {
event: 'deployer:alert' | 'deployer:bond';
data: {
deployer_wallet: string;
deployer_tier: 'elite' | 'good' | 'rising';
token_mint: string;
token_name: string | null;
token_symbol: string | null;
total_bonded: number;
total_deployed: number;
bonding_rate: number;
runner_rate: number | null;
market_cap_at_alert: number | null;
};
}
// Token names are free text: escape them for parse_mode HTML
const esc = (s: unknown) =>
String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
async function handleWebhookEvent(payload: DeployerAlertPayload): Promise<void> {
const { event, data } = payload;
if (event === 'deployer:alert' || event === 'deployer:bond') {
await handleDeployerEvent(event, data);
}
}
async function handleDeployerEvent(
event: string,
data: DeployerAlertPayload['data']
): Promise<void> {
const tierEmoji: Record<string, string> = {
elite: '[ELITE]',
good: '[GOOD]',
rising: '[RISING]',
};
const eventLabel = event === 'deployer:bond' ? 'Bonded' : 'New Launch';
const tier = tierEmoji[data.deployer_tier] || data.deployer_tier.toUpperCase();
const mcap = data.market_cap_at_alert
? `$${(data.market_cap_at_alert / 1000).toFixed(1)}k`
: 'N/A';
const message = [
`<b>${tier} Deployer: ${eventLabel}</b>`,
``,
`Token: <b>${esc(data.token_name)} (${esc(data.token_symbol)})</b>`,
`CA: <code>${data.token_mint}</code>`,
``,
`Bonded: <b>${data.total_bonded} of ${data.total_deployed}</b> launches`,
`Market Cap: ${mcap}`,
``,
`<a href="https://pump.fun/coin/${data.token_mint}">pump.fun</a> | <a href="https://dexscreener.com/solana/${data.token_mint}">DexScreener</a>`,
].join('\n');
await sendTelegram(message);
console.log(`Alert sent for ${data.token_symbol} (${data.deployer_tier} deployer)`);
}
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 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"));
}
const esc = (s: unknown) =>
String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
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 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 (!ALERT_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 eventLabel = event === 'deployer:bond' ? 'Bonded' : 'New Launch';
const mcap = data.market_cap_at_alert ? `$${(data.market_cap_at_alert / 1000).toFixed(1)}k` : 'N/A';
const tierLabel = data.deployer_tier === 'elite' ? '[ELITE]' : '[GOOD]';
const message = [
`<b>${tierLabel} Deployer: ${eventLabel}</b>`,
``,
`Token: <b>${esc(data.token_name)} (${esc(data.token_symbol)})</b>`,
`CA: <code>${data.token_mint}</code>`,
``,
`Bonded: <b>${data.total_bonded} of ${data.total_deployed}</b> launches`,
`Market Cap: ${mcap}`,
``,
`<a href="https://pump.fun/coin/${data.token_mint}">pump.fun</a> | <a href="https://dexscreener.com/solana/${data.token_mint}">DexScreener</a>`,
].join('\n');
await sendTelegram(message).catch(console.error);
console.log(`[${new Date().toISOString()}] Alert sent: ${data.token_symbol} (${data.deployer_tier})`);
});
app.listen(process.env.PORT || 4000, () => {
console.log(`Webhook server listening on port ${process.env.PORT || 4000}`);
});