A Telegram/Discord alert bot built on the poll-once, serve-many pattern: a scheduled poller makes one batched call per cycle into a cache, and every user is served from that cache. User count never touches an API, so free tiers comfortably serve thousands of subscribers.
Two data sources, because no single one covers both needs:
- Prices — CoinGecko
/simple/price - On-chain / whale transfers — blockchain.com Explorer Gateway
Status: complete, running Laravel 12 app, wired to the real APIs and verified live — CoinGecko returns real prices, the explorer returns real chain heights, the whale scanner found 5 transfers ≥ 50 BTC (top 535 BTC) in a real block, wallet-activity (BTC/BCH/ETH/SOL) isolates new transactions on live addresses, token-activity (ERC-20/SPL) detects real ERC-20 movement on a live ETH address, and internal-transaction activity isolates new contract-driven txs on a live ETH address. 39 feature tests green (
vendor/bin/phpunit).
CoinGecko ──(batched /simple/price, 2 min)──▶ poll-prices ─┐
├─▶ PriceCache (Redis + DB mirror)
RuleMatcher ◀─────┘
│ price rules past cooldown
blockchain.com ──(new blocks, 15 min)───▶ poll-blocks ──▶ WhaleScanner
blockchain.com ──(watched addrs, 15 min)─▶ poll-wallets ──▶ WalletWatcher
blockchain.com ──(token holders, 30 min)─▶ poll-tokens ──▶ TokenWatcher
blockchain.com ──(ETH addrs, 30 min)─────▶ poll-internal ─▶ WalletWatcher (id-diff)
│ whale_tx / wallet_activity / token_activity / internal_activity rules past cooldown
▼
AlertEvent + Delivery ──▶ queue ──▶ DeliverAlertJob
│
TelegramChannel / DiscordChannel
Bot commands (/watch, /whale, /wallet, /token, /internal, /list, /stop) ──▶ TelegramWebhookController ──▶ AlertRule store
(this path NEVER calls any external API)
| Command | Type | Source | How it fires |
|---|---|---|---|
/watch BTC > 70000 |
price_above |
CoinGecko | price ≥ threshold |
/watch BTC < 60000 |
price_below |
CoinGecko | price ≤ threshold |
/watch ETH %5 |
percent_move |
CoinGecko | |Δ| ≥ N% vs last reading |
/whale BTC 500 |
whale_tx |
blockchain.com | a single tx output ≥ N coins in a new block |
/wallet BTC bc1q… |
wallet_activity |
blockchain.com | any new transaction on a watched address |
/token ETH 0x… |
token_activity |
blockchain.com | ERC-20/SPL token holdings on a watched address moved |
/internal ETH 0x… |
internal_activity |
blockchain.com | a new contract-driven (internal) transaction touched a watched address |
Chain coverage: prices — BTC/ETH/SOL/BCH · whale — BTC/BCH only (see budget) ·
wallet — BTC/BCH/ETH/SOL · token — ETH/SOL only (ERC-20/SPL) · internal —
ETH only (EVM concept). To track a big ETH/SOL wallet, /wallet its address — that's
the feasible substitute for network-wide ETH/SOL whale scanning; add /token to also
catch its ERC-20/SPL movements, and /internal (ETH) to catch contract-driven value moves
the external feed never shows.
/wallet vs /token: they read different data and are complementary. /wallet
diffs the address's transaction feed (external txs, newest-first ids). /token diffs
the address's token-balance snapshot — the explorer exposes no token-transfer feed,
only balances, so a move is inferred from a changed balance (SOL) or an incremented
per-token transferCount (ETH). See "The two APIs".
| Layer | File |
|---|---|
| Config + env | config/alertbot.php, env.alertbot.example |
| Schema | database/migrations/2026_07_19_2000* (subscribers, alert_rules, alert_events + context, deliveries, price_snapshots) |
| Models | app/Models/{Subscriber,AlertRule,AlertEvent,Delivery,PriceSnapshot}.php |
| Price seam | app/Services/PriceApiClient.php (CoinGecko) |
| On-chain seam | app/Services/ExplorerApiClient.php (blockchain.com) |
| Whale logic | app/Services/Whale/WhaleScanner.php |
| Wallet logic | app/Services/Wallet/WalletWatcher.php |
| Token logic | app/Services/Token/TokenWatcher.php |
| Internal-tx logic | reuses app/Services/Wallet/WalletWatcher.php (same id-diff) |
| Cache | app/Services/PriceCache.php |
| Price matching | app/Services/RuleMatcher.php |
| Delivery | app/Services/Channels/{AlertChannel,TelegramChannel,DiscordChannel,ChannelManager}.php |
| Pollers | app/Console/Commands/{PollPricesCommand,PollBlocksCommand,PollWalletsCommand,PollTokensCommand,PollInternalCommand}.php |
| Queue worker | app/Jobs/DeliverAlertJob.php |
| Bot commands | app/Http/Controllers/TelegramWebhookController.php |
| Wiring | routes/api.php, routes/console.php |
CoinGecko — GET {base}/simple/price?ids=bitcoin,ethereum,…&vs_currencies=usd&include_24hr_change=true
→ {"bitcoin":{"usd":64457,"usd_24h_change":-0.17}, …}. No key required at low
volume; a demo key (x-cg-demo-api-key) lifts the free rate/monthly caps. Symbol
→ id map lives in config/alertbot.php (prices.ids).
blockchain.com Explorer Gateway — base https://api.blockchain.info/explorer-gateway-kt,
all endpoints POST, API key optional in the X-Explorer-Auth-Key header
(no key = lower default rate limits). Used:
- Whale (BTC/BCH):
POST /{c}/blocks {}→{currentHeight, blocks}, thenPOST /{c}/block {height}→{txs:[{txId, inputs:[{coinbase}], outputs:[{value(sats),address}]}]} - Wallet BTC/BCH:
POST /{c}/address/transactions {address}→{transactions:[{txId, …}]}(newest-first) - Wallet ETH:
POST /eth/address {address, network}→{transactions:[{hash, from, to, value(wei)}]} - Wallet SOL:
POST /sol/address {address, network}→{transactions:[{txId, …}]} - Token ETH:
POST /eth/address/tokens {address, network}→{tokens:[{contractAddress, balance, totalSent, totalReceived, transferCount, symbol}]} - Token SOL:
POST /sol/address/tokens {address, network}→{tokens:[{mint, tokenWallet, balance, symbol}]} - Internal ETH:
POST /eth/address/internal-transactions {address, network}→{internalTransactions:[{transactionHash, blockNumber, timestamp, from, to, value, type}]}(newest-first)
ETH/SOL calls require a network (default mainnet, see explorer.networks).
ExplorerApiClient::addressTransactionIds() hides these per-chain field/endpoint
quirks and returns a normalised, newest-first list of id strings; WalletWatcher
diffs them against the stored watermark.
Token endpoints are balance SNAPSHOTS, not transfer feeds. The gateway exposes
no ERC-20/SPL transfer feed (/eth/address/token-transfers, /transfers, etc. all
404), so there is no per-transfer id to diff. ExplorerApiClient::addressTokenBalances()
normalises each holding to id → {counter, symbol} (id = ERC-20 contract / SPL mint),
and TokenWatcher diffs two snapshots instead of an id list. The counter differs by
chain: ETH exposes a monotonic per-token transferCount, so the diff yields the
exact number of new transfers; SOL exposes only balance, so a change just signals
that a position moved (magnitude = number of positions that changed/appeared/disappeared).
This is why token-activity is a separate poller (PollTokensCommand, snapshot-diff)
from wallet-activity (PollWalletsCommand, id-diff) rather than a branch inside it. One
caveat of balance-diffing on SOL: airdropped dust changes the snapshot and can alert.
Internal transactions ARE a real id feed (unlike tokens). /eth/address/internal-transactions
returns newest-first entries keyed by transactionHash, so addressInternalTransactionIds()
reuses the exact same id-diff as external wallet activity — PollInternalCommand shares
WalletWatcher untouched, with its own internal:last:* watermark. One quirk: a single
transaction can spawn several internal txs that all carry the same parent transactionHash,
so the ids are de-duplicated (order-preserving) — one transaction = one unit of activity.
It's kept as an opt-in /internal command (ETH only — internal txs are an EVM concept)
rather than folded into /wallet, so the extra explorer call is spent only for addresses a
user explicitly watches, and existing /wallet behavior is unchanged.
Whale metric: a transaction's largest single output ≥ threshold (sats ÷ 1e8),
excluding coinbase — see WhaleScanner. ETH/SOL wallet-activity (/wallet) covers
external transactions only; ERC-20/SPL token movement is covered by /token, and
ETH internal (contract-driven) transactions by /internal — three complementary
feeds over the same watched address.
Cadences ship conservative by default so a keyless blockchain.com free tier
(1,000 calls/day) stays under budget, and every cadence is tunable via ALERTBOT_CRON_*
env vars (see config/alertbot.php) — no code change, just redeploy. With an
EXPLORER_API_KEY (raises the ceiling) you can shorten blocks/wallets for fresher
alerts. Defaults: prices */2, blocks/wallets */15, tokens/internal */30.
Prices (CoinGecko, its own limits): one batched call covers every coin and every user. Every 2 min = 720/day. If you hit CoinGecko's monthly cap, add a demo key or lengthen the cadence.
Whale (blockchain.com, 1,000/day free tier):
| Chains | Cadence | ~Calls/day | Verdict |
|---|---|---|---|
| BTC + BCH | every 15 min (default) | ~480 | ✅ leaves headroom for address polls |
| BTC + BCH | every 5 min | ~600–900 | ✅ under 1,000 alone (needs a key to add the rest) |
| + ETH/SOL | any | ✗ | ❌ fast blocks — too many block bodies/day |
The fixed-cost floor is the per-block body fetches (~288/day for BTC+BCH regardless of
cadence); a longer cadence mainly trims the latest-blocks calls. Whale scanning is
poll-once-serve-many and viable only on slow UTXO chains (BTC/BCH); ETH/SOL blocks
are far too fast to fetch per-block on the free tier. A per-cycle block cap
(ALERTBOT_WHALE_MAX_BLOCKS) stops a post-downtime catch-up from blowing the day.
Wallet activity (blockchain.com, shares the same daily quota): one call per
distinct watched address per cycle (shared addresses deduped), so cost scales
with unique addresses, not users. At the default 15 min that's 96 × (unique addresses)
calls/day, competing with whale for the 1,000/day budget — so use an API key
(X-Explorer-Auth-Key) as usage grows, and lengthen the cadence or cap watched
addresses if you're near the ceiling.
Token activity (blockchain.com, shares the same daily quota): same poll-once
shape — one /address/tokens call per distinct watched ETH/SOL address per cycle.
At the default 30 min that's 48 × (unique token addresses) calls/day, on top of whale +
wallet — the lightest of the on-chain polls.
Internal transactions (blockchain.com, shares the same daily quota): one
/eth/address/internal-transactions call per distinct watched ETH address per cycle.
At the default 30 min that's 48 × (unique internal addresses) calls/day. It's opt-in
(/internal), so it costs nothing until a user watches an address — but with whale +
wallet + token + internal all active and real usage, budget for an API key.
Already scaffolded, migrated (SQLite), tested, and live-verified. To run locally:
composer install # if vendor/ is missing
php artisan migrate # already done here; safe to re-run
# For production, back cache + queue with Redis (edit .env):
# CACHE_STORE=redis QUEUE_CONNECTION=redis
composer require predis/predis # or the phpredis extension
# Fill in the keys (see env.alertbot.example), then run four processes:
php artisan schedule:work # poll-prices (2m) + poll-blocks/wallets (15m) + poll-tokens/internal (30m)
php artisan queue:work # delivers alerts
php artisan serve # receives the Telegram webhookTry the pollers directly (they hit the live APIs):
php artisan alertbot:poll-prices # → "Polled 4 assets, fired N alerts."
php artisan alertbot:poll-blocks # → initializes chain heights, then scans new blocks
php artisan alertbot:poll-wallets # → seeds watched addresses, then detects new txs
php artisan alertbot:poll-tokens # → seeds token holdings, then detects token movement
php artisan alertbot:poll-internal # → seeds internal txs, then detects new contract-driven txsRun the tests with vendor/bin/phpunit (39 feature tests: price pipeline,
cooldown, API-outage fallback, whale detection + first-run seeding, wallet
activity across BTC/ETH/SOL incl. shared-address dedupe, token activity (ETH
transferCount deltas + SOL balance diffs) incl. shared-address dedupe, internal-tx
activity incl. parent-hash de-dupe + shared-address dedupe, webhook commands incl.
/whale, /wallet, /token & /internal, and the plan cap).
Windows/XAMPP note:
php artisan testmay fail with a "Cannot declare class ComposerAutoloaderInit…" fatal — a known collision-wrapper quirk. Runphp vendor/phpunit/phpunit/phpunit(orvendor/bin/phpunit) instead.
The whole bot ships as one Railway service (Docker) running web + queue + scheduler
under supervisor, backed by Railway PostgreSQL (which also holds the cache + queue —
no Redis needed to start). All artifacts are in the repo: Dockerfile,
docker/entrypoint.sh, docker/supervisord.conf, railway.toml, .dockerignore,
.env.railway.example. Full step-by-step (BotFather → Railway → setWebhook → verify):
see DEPLOY.md.
curl "https://api.telegram.org/bot<TOKEN>/setWebhook" \
-d "url=https://your-domain/api/webhooks/telegram" \
-d "secret_token=<TELEGRAM_WEBHOOK_SECRET>"Put your real keys in .env (never in code):
EXPLORER_API_KEY— your blockchain.com Explorer key (optional; raises limits).COINGECKO_API_KEY— optional demo key.
- ETH/SOL whale (network-wide) — infeasible on a modest quota: ETH mines ~7,200
blocks/day and SOL ~200k+ slots/day, so you can't scan every block. Watch specific
large addresses via
/walletinstead — the practical whale-tracking substitute. - ETH/SOL token transfers — ✅ done via
/token(PollTokensCommand+TokenWatcher), by diffing the/address/tokensbalance snapshot (the gateway has no token-transfer feed). ETH gets exact transfer counts (transferCountdelta); SOL gets movement signals from balance changes. See "The two APIs". - ETH internal transactions — ✅ done via
/internal(PollInternalCommand+addressInternalTransactionIds(), reusingWalletWatcher's id-diff). Kept opt-in and separate from/walleton purpose (budget + no behavior change). See "The two APIs". Could be folded into/wallet ETHlater (both feeds carryblockNumber, so a merge-by-block would be safe) if one-command completeness is preferred over opt-in cost. - ERC-20/SPL token amounts —
/tokenalerts that holdings moved, not by how much. ETHtotalSent/totalReceivedand SOL balance deltas are the hooks to add a threshold (e.g. "alert only on token moves ≥ X"). - No buy/sell signals or advice — data and alerts only, by design.
- Reselling raw API data usually violates vendor terms; the value you sell is the alerts/UI on top.
- Payment/subscription flow (
plancolumn +Subscriber::ruleLimit()are the hooks; wire your billing provider to flipplan). - Discord slash-command intake (only Telegram intake is built; Discord is wired as an outbound channel).