Wallet drains are no longer rare, one-off news stories. TRM Labs put total crypto theft at roughly $972 million across 207 incidents in the first half of 2026, and Blockaid’s own tracking counted 212 onchain exploits worth $1.1 billion over the same stretch (TRM Labs, Blockaid). If you hold any meaningful balance in a hot wallet, cold wallet, or exchange account, you need a way to know the moment funds move without you touching a signing device. This tutorial builds exactly that: a working Node.js service that watches one or more wallet addresses, flags risky activity, and pushes an alert to Telegram and email within seconds of a transaction hitting the chain.

You will end up with a deployable project, not a toy script. It covers webhook-based monitoring through Alchemy Notify, a polling fallback built on ethers.js 6.17.0 for chains or providers without webhook support, risk scoring for token approvals and large transfers, and a dual-channel alert pipeline. Budget about 90 minutes if you follow every step in order, less if you skip the optional polling fallback.

Why wallet monitoring matters right now

2026 has been a rough year for anyone assuming their wallet is safe just because it isn’t sitting on an exchange. CoinLaw tallied $86.01 million lost across 16 hacks in January and another $26.5 million across 15 major hacks in February, then CryptoTrendTracker recorded a brutal $629.69 million drained industry-wide in April, with $614.17 million of that coming out of DeFi protocols alone. The pattern holds through the summer. Crystal Intelligence traced the July 30 Coldcard hardware wallet incident to 1,082.65 BTC (about $70.2 million at the time) pulled from affected addresses, and by August 5, Coindesk reported the same campaign had drained at least 1,816 BTC (roughly $114 million) from more than 5,200 addresses. Gadgets360 separately documented one wave that hit 500 wallets holding 584 BTC, worth about $38 million.

None of these victims found out from their wallet software. Most found out from a block explorer, a worried friend, or a balance that suddenly read zero. A monitoring pipeline compresses that discovery window from hours (or days) down to seconds, which is often the difference between catching a partial drain mid-flight and losing everything before you even open your phone.

How wallet drains actually happen

Security firm Coinspect documented a cluster of drains it calls the “Ill Bloom” incidents, running from May through July 2026 across Bitcoin, Ethereum, Polygon, Rootstock, and Tron. The common thread wasn’t a smart contract bug. It was wallets derived from weak recovery phrases, phrases short enough or predictable enough that an attacker could brute-force the private key offline and sweep funds the moment a balance appeared. That’s a reminder that monitoring is a second layer, not a replacement for proper seed phrase hygiene.

Beyond weak keys, the other two dominant drain patterns in 2026 are malicious token approvals (you sign a transaction that grants a contract unlimited spending rights, then the contract drains you weeks later) and hot-wallet compromise at custodians, the same failure mode behind the 2025 Phemex breach (~$85 million from hot wallets) and the Nobitex attack (~$80-90 million), both cited in The Block’s incident roundup. A good alert system needs to catch all three: unexpected outbound transfers, new approval grants, and unusual transaction volume from a single address in a short window.

IncidentDateLossRoot cause
Coldcard hardware wallet campaignJul 30 – Aug 5, 2026~$114M, 1,816+ BTC, 5,200+ addressesDevice/firmware compromise
“Ill Bloom” multi-chain drainsMay – Jul 2026Undisclosed total, 5 chains affectedWeak recovery phrases
April 2026 DeFi drain waveApr 2026$629.69M ($614.17M from DeFi)Protocol exploits
Balancer multi-chain exploitNov 3, 2025~$128MSmart contract vulnerability
Bybit hot wallet theftFeb 21, 2025~$1.4B (401,000 ETH)Hot wallet compromise

Prerequisites: what you need before you start

This build uses Node.js and a handful of small, well-maintained packages. Install the current LTS release before you begin. Everything below was tested against the versions listed.

Tool / packageVersion usedPurpose
Node.js24 LTS “Krypton” (v24.20.0)Runtime
express5.2.1Webhook receiver server
ethers6.17.0Chain reads and polling fallback
axios1.20.0Outbound HTTP calls (Telegram, provider APIs)
node-telegram-bot-api2.1.0Telegram alert delivery
nodemailer9.0.5Email alert delivery
dotenv17.4.2Environment configuration
  • A free Alchemy account for webhook-based monitoring (30M compute units/month free, then $0.45 per 1M CU up to 300M, $0.40 per 1M CU beyond that).
  • A Telegram account to create a bot via @BotFather (free, takes two minutes).
  • An SMTP-capable email account or transactional email provider for the backup channel.
  • A small always-on server or serverless function for deployment (a $5-6/month VPS is enough for monitoring a handful of addresses).
  • Basic comfort with the command line and reading JSON.

Step 1: Pick a monitoring architecture

There are two ways to watch a wallet: webhooks or polling. A webhook means your chosen provider (Alchemy Notify, QuickNode Streams) watches the chain on its infrastructure and pushes an HTTP POST to your server the moment a matching transaction confirms. Polling means your own code asks a node or an explorer API “anything new?” on a fixed interval, say every 15 seconds.

Webhooks are faster and cheaper at scale because you’re not burning API calls checking empty results. Polling is simpler to reason about, works with free-tier explorer APIs like Etherscan API v2, and doesn’t require your server to have a public, reachable endpoint. This tutorial builds both: a webhook receiver as the primary path, and an ethers.js polling loop as a fallback for chains where you don’t have webhook coverage. Running both in production isn’t wasteful, it’s redundancy, and redundancy is the entire point of an alert system.

Step 2: Set up the Node.js project

Create a fresh project directory and install the dependencies listed above.

mkdir wallet-watch && cd wallet-watch
npm init -y
npm install [email protected] [email protected] [email protected] \
  [email protected] [email protected] [email protected]
mkdir src
touch src/server.js src/poller.js src/alerts.js src/risk.js .env

Open package.json and add "type": "module" so you can use ES module imports throughout, which keeps the code below consistent and easier to extend later.

Step 3: Choose a blockchain data provider

You don’t need to pick just one. This build defaults to Alchemy for webhook delivery because its free tier (30M CU/month) comfortably covers monitoring a dozen or so addresses, and falls back to a public RPC endpoint via ethers.js for the polling script so the whole pipeline still works even if you skip signing up for anything paid.

ProviderDelivery modelFree tierBest for
Alchemy NotifyWebhook push30M CU/month, then $0.45/1M CUReal-time EVM alerts, low setup effort
QuickNode StreamsWebhook pushFree trial, Build tier from $49/moHigh-volume, multi-chain pipelines
Etherscan API v2PollingFree tier with rate limitsSimple single-chain polling, no server exposure needed
Public RPC + ethers.jsPollingFreeZero-cost fallback, any EVM chain

Sign up for a free Alchemy account, create an app scoped to the chain you care about (Ethereum, Polygon, Arbitrum, and Base are all supported), and grab the API key from the dashboard. Drop it into your .env file along with the wallet address you want to watch.

# .env
ALCHEMY_API_KEY=your_alchemy_key_here
ALCHEMY_AUTH_TOKEN=your_notify_auth_token
WATCH_ADDRESS=0xYourWalletAddressHere
RPC_URL=https://eth-mainnet.g.alchemy.com/v2/your_alchemy_key_here
TELEGRAM_BOT_TOKEN=your_bot_token
TELEGRAM_CHAT_ID=your_chat_id
SMTP_HOST=smtp.yourprovider.com
[email protected]
SMTP_PASS=your_smtp_password
[email protected]
PORT=3000

Step 4: Build the Express webhook receiver

This server does one job: accept incoming POST requests from Alchemy, verify they’re legitimate, extract the transaction details, and hand them off to your alert logic.

// src/server.js
import express from 'express';
import crypto from 'crypto';
import 'dotenv/config';
import { sendTelegramAlert, sendEmailAlert } from './alerts.js';
import { scoreEvent } from './risk.js';

const app = express();
app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = buf; }
}));

function isValidSignature(req) {
  const signature = req.headers['x-alchemy-signature'];
  const hmac = crypto.createHmac('sha256', process.env.ALCHEMY_AUTH_TOKEN);
  hmac.update(req.rawBody);
  return hmac.digest('hex') === signature;
}

app.post('/webhook/alchemy', async (req, res) => {
  if (!isValidSignature(req)) {
    return res.status(401).send('invalid signature');
  }

  const activity = req.body?.event?.activity || [];
  for (const tx of activity) {
    const risk = scoreEvent(tx);
    if (risk.level !== 'low') {
      const message = `${risk.level.toUpperCase()} alert: ${tx.value} ${tx.asset} ` +
        `from ${tx.fromAddress} to ${tx.toAddress} (${risk.reason})`;
      await sendTelegramAlert(message);
      await sendEmailAlert('Wallet activity detected', message);
    }
  }
  res.status(200).send('ok');
});

app.listen(process.env.PORT, () => {
  console.log(`Webhook receiver listening on port ${process.env.PORT}`);
});

The signature check matters more than it looks. Without it, anyone who finds your endpoint URL can POST fake activity and either spam you into ignoring real alerts or, worse, feed you false “all clear” noise. Never skip signature verification on a public endpoint.

Step 5: Register the webhook with Alchemy Notify

With your server deployed (or exposed locally through a tunnel for testing), register it with Alchemy’s Notify API so it knows where to send activity for your address.

curl -X POST https://dashboard.alchemy.com/api/create-webhook \
  -H "X-Alchemy-Token: $ALCHEMY_AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "network": "ETH_MAINNET",
    "webhook_type": "ADDRESS_ACTIVITY",
    "webhook_url": "https://yourdomain.com/webhook/alchemy",
    "addresses": ["0xYourWalletAddressHere"]
  }'

If you’re testing locally before you have a public domain, tools like ngrok or Cloudflare Tunnel give you a temporary public URL that forwards to your local port. Just remember to re-register the webhook once you move to your real deployment URL, and delete the temporary one so you’re not paying for compute on a dead endpoint.

Step 6: Build an ethers.js polling fallback

Webhooks can fail silently, a misconfigured URL, a provider outage, a firewall change. A polling script that checks the same address independently is cheap insurance. This version checks balance and recent transaction count every 20 seconds and flags anything that changed unexpectedly.

// src/poller.js
import { ethers } from 'ethers';
import 'dotenv/config';
import { sendTelegramAlert } from './alerts.js';

const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const address = process.env.WATCH_ADDRESS;
let lastBalance = null;
let lastNonce = null;

async function checkWallet() {
  try {
    const balance = await provider.getBalance(address);
    const nonce = await provider.getTransactionCount(address);

    if (lastBalance !== null && balance < lastBalance) {
      const diff = ethers.formatEther(lastBalance - balance);
      await sendTelegramAlert(
        `Outbound transfer detected: -${diff} ETH from ${address}`
      );
    }

    if (lastNonce !== null && nonce > lastNonce) {
      await sendTelegramAlert(
        `New outbound transaction confirmed from ${address} (nonce ${nonce})`
      );
    }

    lastBalance = balance;
    lastNonce = nonce;
  } catch (err) {
    console.error('Polling error:', err.message);
  }
}

setInterval(checkWallet, 20_000);
checkWallet();

Twenty seconds is a reasonable default for a personal wallet. Drop it to 10 seconds if you’re watching a high-value hot wallet, but check your RPC provider’s rate limits first, polling too aggressively on a free tier will get you throttled right when you need it most.

Step 7: Wire up Telegram alerts

Telegram is the fastest delivery channel available for this kind of alert: no email delay, push notification on your phone within a second or two of the message being sent. Create a bot by messaging @BotFather, save the token, then message your new bot once so you can retrieve your chat ID from the getUpdates endpoint.

// src/alerts.js
import TelegramBot from 'node-telegram-bot-api';
import nodemailer from 'nodemailer';
import 'dotenv/config';

const bot = new TelegramBot(process.env.TELEGRAM_BOT_TOKEN, { polling: false });

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: 587,
  secure: false,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});

export async function sendTelegramAlert(message) {
  try {
    await bot.sendMessage(process.env.TELEGRAM_CHAT_ID, `🔔 ${message}`);
  } catch (err) {
    console.error('Telegram send failed:', err.message);
  }
}

export async function sendEmailAlert(subject, body) {
  try {
    await transporter.sendMail({
      from: process.env.SMTP_USER,
      to: process.env.ALERT_EMAIL_TO,
      subject: `[Wallet Alert] ${subject}`,
      text: body,
    });
  } catch (err) {
    console.error('Email send failed:', err.message);
  }
}

Step 8: Add email as a backup channel

Telegram can go down, your phone can die, a notification can get buried. Email is slower but it’s a second, independent path that doesn’t depend on the same service staying online. The sendEmailAlert function above already handles this, it fires alongside every Telegram message in the webhook handler from Step 4. If you want email reserved only for high-severity events so you’re not flooding an inbox, gate the call behind a check on risk.level === 'critical' instead of firing on every non-low event.

Test the email path independently before you trust it in production. SMTP misconfigurations (wrong port, missing app-specific password, provider blocking unfamiliar senders) are the single most common reason people discover their “backup” channel never worked, usually right after it was the only channel that mattered.

Step 9: Detect high-risk events, not just any event

A wallet that alerts on every incoming and outgoing transaction becomes noise within a day, and noise gets muted. The fix is a small scoring function that separates routine activity from the patterns that actually correlate with drains: token approvals, especially unlimited ones, and transfers above a threshold you define.

// src/risk.js
const UNLIMITED_APPROVAL = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
const LARGE_TRANSFER_ETH = 0.5;

export function scoreEvent(tx) {
  const category = tx.category || '';
  const rawContract = tx.rawContract || {};

  if (category === 'token' && rawContract.address &&
      tx.log?.topics?.[0]?.includes('Approval')) {
    const approvedAmount = tx.log?.data;
    if (approvedAmount === UNLIMITED_APPROVAL) {
      return { level: 'critical', reason: 'unlimited token approval granted' };
    }
    return { level: 'medium', reason: 'token approval granted' };
  }

  const value = parseFloat(tx.value || 0);
  if (tx.category === 'external' && value >= LARGE_TRANSFER_ETH) {
    return { level: 'critical', reason: `large transfer of ${value} ETH` };
  }

  if (value > 0) {
    return { level: 'medium', reason: 'standard transfer' };
  }

  return { level: 'low', reason: 'no value transferred' };
}

Tune LARGE_TRANSFER_ETH to whatever fraction of your holdings would actually worry you. For a wallet holding 10 ETH, a 0.5 ETH threshold might be too sensitive, while for a wallet holding 0.2 ETH, it’s too loose. There’s no universal number, only the one that matches your own risk tolerance.

Understanding alert severity and cutting false positives

The three-tier scoring in risk.js (low, medium, critical) is deliberately simple, but the labels only mean something if you map them to a consistent response. Treat “critical” as “stop what you’re doing and check the transaction on a block explorer right now.” Treat “medium” as “worth a glance within the hour.” Treat “low” as “log it, don’t interrupt anyone.” Mixing those up defeats the purpose of scoring at all, you end up back at alert fatigue with extra steps.

SeverityTrigger conditionResponse timeChannel
CriticalUnlimited approval, large transfer above thresholdImmediateTelegram + email
MediumStandard approval, routine transferWithin the hourTelegram only
LowZero-value or informational eventNo action neededLogged, no push

False positives are the biggest threat to a monitoring system’s credibility, not false negatives. A system that cries wolf on ordinary DeFi interactions (staking deposits, routine swaps, gas refunds) trains you to skim past every message, including the one that matters. Two habits fix most of that noise. First, whitelist contract addresses you interact with regularly, a known staking contract or DEX router shouldn’t trigger the same alert tier as an unknown address. Second, add a short cooldown window, five minutes is usually enough, so a single transaction that emits multiple log events doesn’t fire three near-identical alerts back to back.

// simple cooldown guard, add near the top of risk.js
const recentAlerts = new Map();
const COOLDOWN_MS = 5 * 60 * 1000;

export function shouldSuppress(txHash) {
  const last = recentAlerts.get(txHash);
  const now = Date.now();
  if (last && now - last < COOLDOWN_MS) return true;
  recentAlerts.set(txHash, now);
  return false;
}

Call shouldSuppress(tx.hash) at the top of the webhook handler’s loop, before scoring, and skip the alert entirely if it returns true. It’s a five-line change that removes most of the duplicate-notification complaints you’d otherwise get from anyone else on your alert channel.

Step 10: Test the full alert pipeline

Don’t wait for a real drain to find out your pipeline is broken. Send yourself a small test transaction from a wallet you control to the watched address, or from the watched address to a second wallet, and confirm the alert arrives on both channels within a few seconds.

# start the webhook receiver
node src/server.js

# in a second terminal, start the polling fallback
node src/poller.js

# in a third terminal, simulate a webhook payload
curl -X POST http://localhost:3000/webhook/alchemy \
  -H "Content-Type: application/json" \
  -H "X-Alchemy-Signature: test" \
  -d '{"event":{"activity":[{"fromAddress":"0xabc","toAddress":"0xdef","value":0.75,"asset":"ETH","category":"external"}]}}'

Expect the console to log the incoming request, and (assuming a valid signature in production) a Telegram message that reads something like CRITICAL alert: 0.75 ETH from 0xabc to 0xdef (large transfer of 0.75 ETH). If the message never arrives, check the troubleshooting section below before assuming the whole approach is broken, it’s almost always one misconfigured credential.

Step 11: Deploy the service

A wallet monitor is only useful if it’s running when you’re not looking at a terminal. A small VPS with a process manager is the simplest reliable option, though a serverless cron function works fine for the polling half if you don’t want to manage a server at all.

npm install -g pm2
pm2 start src/server.js --name wallet-webhook
pm2 start src/poller.js --name wallet-poller
pm2 save
pm2 startup

Put the webhook receiver behind a reverse proxy (nginx or Caddy) with a real TLS certificate. Alchemy will refuse to deliver to a plain HTTP endpoint, and you don’t want a wallet monitor that’s itself sending sensitive wallet activity over an unencrypted connection.

Step 12: Track multiple wallets at scale

Once the single-address version works, scaling to a dozen wallets, or a team’s entire treasury, is mostly a data modeling problem, not a code problem. Move the watched addresses out of a single .env variable and into a small JSON or database table with a label, chain, and per-wallet risk threshold for each entry. Register one Alchemy webhook per chain covering all addresses on that chain (Alchemy supports multiple addresses per webhook), and have your handler look up the matching wallet’s threshold before scoring the event.

For a team, route alerts by wallet label into different Telegram channels or group chats so the person responsible for the marketing multisig isn’t waking up at 3 a.m. for an alert that belongs to the treasury lead. That routing logic is a few lines of lookup code, not a rebuild.

Watching ERC-20 tokens, not just native currency

Everything above tracks ETH moving in and out of an address, but most drains in 2026 target stablecoins and other ERC-20 tokens sitting in the same wallet, precisely because those balances are often larger and more liquid than the native gas token. Alchemy’s address-activity webhook already includes token transfers in the same event stream as native transfers, distinguished by a category field set to token instead of external. Extend the risk function to treat those the same way, comparing the transferred amount against a per-token threshold rather than a single ETH figure.

// per-token thresholds, add to risk.js
const TOKEN_THRESHOLDS = {
  USDC: 500,
  USDT: 500,
  DAI: 500,
  WETH: 0.5,
};

export function scoreTokenTransfer(tx) {
  const symbol = (tx.asset || '').toUpperCase();
  const threshold = TOKEN_THRESHOLDS[symbol];
  const amount = parseFloat(tx.value || 0);

  if (threshold && amount >= threshold) {
    return { level: 'critical', reason: `${amount} ${symbol} transfer above threshold` };
  }
  if (amount > 0) {
    return { level: 'medium', reason: `${amount} ${symbol} transfer` };
  }
  return { level: 'low', reason: 'no token value transferred' };
}

Call scoreTokenTransfer instead of scoreEvent whenever tx.category === 'token', and keep both functions in risk.js so you can maintain the thresholds in one place. If you hold tokens across several chains, remember that the same symbol can mean different contract addresses on Ethereum versus Polygon versus Arbitrum, so validate against the contract address, not just the ticker, before trusting a match in a production deployment.

Common pitfalls to avoid

  • Skipping signature verification. An unverified webhook endpoint accepts forged payloads from anyone who finds the URL, which defeats the entire purpose of the alert.
  • Alerting on every transaction. Without a risk filter like the one in Step 9, you’ll get alert fatigue within a week and start ignoring the channel entirely, right when a real drain needs your attention.
  • Relying on a single alert channel. Telegram outages happen. Email-only setups get caught in spam filters. Run at least two independent channels.
  • Hardcoding secrets in source files. API keys and bot tokens belong in .env, excluded from version control via .gitignore, never committed to a public repo.
  • Polling too aggressively on a free-tier RPC. Providers rate-limit or ban keys that hammer endpoints, which silently kills your fallback exactly when the primary webhook path also fails.
  • Forgetting to monitor the monitor. If your VPS reboots and pm2 doesn’t restart the processes, you have zero coverage and no way to know it. Set up a simple uptime check that pings your webhook server’s health endpoint.

Troubleshooting guide

  • Webhook never fires: Confirm the URL registered with Alchemy exactly matches your deployed endpoint, including the path, and that your server is publicly reachable on port 443.
  • Signature check always fails: You’re likely hashing the parsed JSON body instead of the raw request bytes. Express’s verify callback in Step 4 must run before body parsing completes.
  • Telegram bot doesn’t respond: Make sure you’ve sent at least one message to the bot first. Telegram won’t let a bot message a chat it hasn’t been messaged by.
  • Telegram chat ID is wrong: Hit https://api.telegram.org/bot<TOKEN>/getUpdates in a browser after messaging your bot to find the correct numeric chat ID.
  • Email alerts land in spam: Use a transactional email provider with proper SPF/DKIM records rather than a personal Gmail SMTP relay, which most spam filters distrust for automated sends.
  • Polling script shows constant false positives: Gas-only transactions (failed transactions that still consume gas) can shift balance slightly. Add a minimum threshold before triggering an alert.
  • Alchemy webhook returns 401 on registration: You’re using the API key instead of the separate Notify auth token. Check the dashboard for the correct token under the Notify tab.
  • pm2 processes die silently after server reboot: Run pm2 startup and follow the printed command to register pm2 with your OS’s init system, otherwise processes don’t survive a restart.
  • RPC calls time out intermittently: Free-tier public RPC endpoints get congested. Switch to your Alchemy RPC URL instead of a generic public one for the polling fallback.

Advanced tips for production use

Once the basic pipeline is stable, a few upgrades meaningfully raise the ceiling. First, add a dead man’s switch: if the poller hasn’t logged a successful check-in within, say, five minutes, fire a separate “monitoring may be down” alert through a different service entirely (a simple cron job hitting a healthcheck URL like Healthchecks.io works well and stays outside your own infrastructure’s blast radius).

Second, layer in a decentralized detection network like Forta for contract-level threat intelligence, catching known malicious addresses and drainer contracts before they interact with your wallet at all, not just after. Third, if you’re monitoring a multisig or DAO treasury, pair this alert system with an on-chain circuit breaker, a pausable contract pattern that lets a quorum of signers freeze outbound transfers the instant an alert fires, buying time to investigate before more funds move. None of these are required for a personal wallet, but they close the gap between “I found out fast” and “I stopped it in time.”

Finally, don’t throw away the events you capture. Write every scored event, timestamp, address, severity, and reason, to a small database (SQLite is enough for personal use, Postgres if a team is sharing the deployment) instead of only pushing them to Telegram and forgetting them. That log becomes useful in three ways later: it lets you tune thresholds against real history instead of guessing, it gives you an audit trail if you ever need to reconstruct what happened during an incident, and it lets you build a simple dashboard on top of the same data without re-architecting anything. A single Postgres table with columns for tx_hash, address, severity, reason, and created_at covers most of what you’ll need for months.

DIY monitoring versus paid wallet-watching services

Hosted wallet-alerting services exist, and for a lot of people they’re the right call. If you want alerts running in ten minutes with no server to maintain, a hosted tool that lets you paste an address and pick a notification channel gets you there faster than anything in this tutorial. The tradeoff is control: you’re trusting a third party with the mapping between your address and your notification channel, you’re limited to whatever event types the vendor exposes, and you’re often paying a recurring fee once you go past a small number of watched addresses on the free tier.

Building it yourself, as this tutorial does, costs more setup time up front but pays off in three ways. You control exactly what counts as “risky” instead of accepting a vendor’s default thresholds. You own the alert routing, so adding a new channel, a new wallet, or a new chain is a code change you control rather than a support ticket or a plan upgrade. And you’re not exposing your watched addresses to a service whose own security posture you haven’t audited, which matters more the larger the balance you’re protecting.

FactorDIY (this tutorial)Hosted service
Setup time60-90 minutes5-10 minutes
Monthly cost (1 wallet)~$0-6 (VPS only)Often free, paid tiers for scale
Custom risk logicFull controlLimited to vendor’s rules
Multi-chain supportAs many as your provider supportsDepends on vendor coverage
Data exposureStays on your own infrastructureShared with a third-party vendor

A reasonable middle path: start with the DIY pipeline in this tutorial for the wallets that matter most (a treasury, a cold-storage hot-path, a multisig signer’s hot wallet), and use a hosted tool as a secondary, redundant check on the same addresses. That mirrors the webhook-plus-polling redundancy built into Steps 4 through 6, just implemented at the vendor level instead of the code level.

The complete working project

Put together, the project structure looks like this, five files totaling under 200 lines of code, deployable to any VPS or container platform that runs Node.js 24 LTS.

wallet-watch/
├── .env                  # secrets and configuration
├── package.json
└── src/
    ├── server.js         # Express webhook receiver (Step 4)
    ├── poller.js         # ethers.js polling fallback (Step 6)
    ├── alerts.js         # Telegram + email delivery (Step 7-8)
    └── risk.js           # event scoring logic (Step 9)

Run pm2 start src/server.js --name wallet-webhook && pm2 start src/poller.js --name wallet-poller and you have two independent, redundant paths watching your address, both feeding the same two-channel alert system. That redundancy is the actual point. A webhook alone fails silently on a provider outage. A poller alone misses the sub-second speed of push delivery. Together, they cover each other’s blind spots.

Frequently asked questions

Can I monitor a Bitcoin wallet with this same setup?

The code above targets EVM chains, but the architecture carries over. Swap Alchemy Notify for a Bitcoin-focused service or run a polling script against a block explorer API that supports address lookups, then feed the results into the same alerts.js module. The risk-scoring thresholds will need rewriting since Bitcoin doesn’t have token approvals, only value transfers.

Is Alchemy’s free tier really enough for personal use?

Yes, for monitoring a handful of addresses. The free tier covers 30 million compute units a month, and address-activity webhooks are cheap per event, so a single wallet with normal transaction volume won’t come close to the limit.

Will this stop a hack from happening?

No. It’s a detection system, not a prevention system. It shortens the time between a drain starting and you knowing about it, which matters if funds are moving in multiple transactions or if you can revoke a malicious approval before it’s used. It does nothing against a single instant sweep of a fully compromised key.

How do I revoke a malicious token approval once I’m alerted?

Use a revocation tool that reads your approvals directly from the chain and lets you submit a transaction setting the allowance back to zero. Do this the moment you see a “token approval granted” alert you didn’t authorize, don’t wait to confirm intent first.

Should I run this on the same machine as my wallet software?

No. Run the monitoring service on separate infrastructure, ideally a small cloud VPS with no signing keys or wallet software installed at all. It only ever needs a public wallet address, never a private key, so keeping it isolated limits what an attacker gains if the monitoring server itself is ever compromised.

What’s the difference between this and a portfolio tracker app?

A portfolio tracker shows you balances when you open the app. This system pushes a notification the moment activity happens, without you checking anything, and applies risk logic to decide what’s worth interrupting you for. They’re complementary, not competing.

Can I add SMS alerts instead of or alongside Telegram?

Yes. Add a third function in alerts.js using an SMS API provider’s SDK, then call it alongside sendTelegramAlert and sendEmailAlert in the webhook handler. The pattern is identical, only the delivery mechanism changes.

How much does running this cost per month?

For a single wallet: effectively $0 on Alchemy’s free tier, plus the cost of a small VPS, typically $5-6 a month for a provider like DigitalOcean or Hetzner. Telegram and basic SMTP delivery are free at this scale.