Building a Stock News Alert System with Node.js
Most "real-time stock news" tutorials give you a Python WebSocket loop and call it done. That works for a quant desk. It doesn't work if you're a Node.js developer trying to ship a Slack bot, an internal trading dashboard, or a side-project alert system in one evening.
A stock news alert system is a Node.js service that polls a financial news API on a fixed schedule, filters articles by company, gates each match on a sentiment threshold, and pushes notifications to Slack, Discord, or email. Done well, it cuts noise by 60-80% versus a raw news firehose and runs on a $5 VPS.
This tutorial shows you how to build a complete Node.js service that polls a financial news API, filters by ticker, gates on sentiment, dedupes re-syndicated articles, and pushes formatted alerts into Slack. Production patterns included: retries, exponential backoff, 429 handling, and deterministic dedup.
This article is for intermediate Node.js developers who already know async/await, fetch, and have shipped at least one Express app. We'll use APITube — the API behind this guide — but the architecture works against any REST news API that returns a sentiment score in the response.
Key Takeaways
- Stack: Node.js 20+,
node-cron,dotenv, nativefetch, APITube REST API, Slack incoming webhook. No WebSockets, no Express, no Python. - Cadence: poll every 60 seconds — within the 30-requests-per-30-minutes free tier on a 4-5 ticker watchlist.
- Noise control: dedup by
host + normalized titleand gate alerts on|sentiment| >= 0.4. Combined effect cuts alert volume by 60-80% on a typical watchlist. - Decision: APITube fits if you need sentiment + global coverage. Alpaca News fits if you already trade with Alpaca. Finnhub and stocknewsapi.io are niche picks — see the comparison table below.
- Production: retry with jittered exponential backoff, dedicated 429 handling,
lastPollwatermark to bound the fetch window.
What You'll Build
A single-process Node.js service, ~150 lines, that does the following on a 60-second schedule:
- Polls APITube for the last minute of news filtered by your watchlist (e.g.
apple,tesla,nvidia). - Drops articles already seen via an in-memory dedup
Mapwith a 24-hour TTL. - Gates alerts on
|sentiment.overall| >= 0.4so neutral filler never reaches Slack. - Throttles per ticker — at most 3 alerts per ticker per 15 minutes — so a news storm can't spam your channel.
- Posts a formatted Slack message via incoming webhook with title, sentiment badge, source, and link.
Here's what one alert looks like in Slack:
🔴 NVDA — Negative sentiment (-0.62)
Nvidia faces antitrust probe over AI chip dominance
Reuters · 2 minutes ago · Read →
You can run it with node index.js, deploy it to Fly.io, Railway, or a $5 VPS, and forget about it.
Prerequisites
- Node.js 20+ (for native
fetch). - An APITube API key — grab one free at apitube.io. The free tier allows 30 requests per 30 minutes, which is enough for a 60-second polling cadence on a single watchlist of 4-5 tickers.
- A Slack incoming webhook URL — create one in your workspace under Apps → Incoming Webhooks.
- 20 minutes.
Step 1 — Project Setup
mkdir stock-news-alerts && cd stock-news-alerts
npm init -y
npm install node-cron dotenv
touch index.js .env
We don't need node-fetch — Node 20+ ships fetch natively. We don't need Express either: this service runs as a worker, not an HTTP server. Add to .env:
APITUBE_KEY=your_apitube_key
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T0/B0/xxx
WATCHLIST=1278268,2415252,1278269,1278270
SENTIMENT_THRESHOLD=0.4
Watchlist values are APITube numeric entity ids — resolve each company once via /v1/news/entity (or read the id off the entities[] array in any response) and store the numbers. APITube filters on entity id rather than ticker symbols because the same news event may mention AAPL, Apple Inc, and Apple interchangeably; the entity layer normalizes them all to one id.
Step 2 — Fetch News from the Stock News API
APITube's /v1/news/everything endpoint takes an entity.id filter that accepts a single numeric entity id per call. We fan out across the watchlist and merge results. Here's the fetch function:
// fetch.js
const APITUBE_BASE = 'https://api.apitube.io/v1/news/everything';
export async function fetchNewsForEntity(entityId, sinceIso) {
const url = new URL(APITUBE_BASE);
url.searchParams.set('entity.id', entityId); // numeric entity id (resolve via /v1/news/entity)
url.searchParams.set('language.code', 'en');
url.searchParams.set('category.id', 'medtop:04000000'); // business & finance
url.searchParams.set('published_at.start', sinceIso);
url.searchParams.set('per_page', '20');
url.searchParams.set('sort.by', 'published_at');
const res = await fetch(url, {
headers: { 'X-API-Key': process.env.APITUBE_KEY }
});
if (res.status === 429) throw new RateLimitError();
if (!res.ok) throw new Error(`APITube ${res.status}`);
const data = await res.json();
return data.results || [];
}
Why polling, not WebSockets
Most retail and SaaS use cases — Slack bots, internal dashboards, alert systems with a human in the loop — don't need 50 ms latency. News publication latency is already 30 seconds to several minutes from the underlying event.
Unlike a WebSocket stream, polling doesn't require persistent connection management. You avoid reconnection logic, heartbeat handling, and the operational cost of holding TCP sockets open across deploys. Polling at 30-60 seconds adds at most 60 seconds of additional lag, costs nothing in connection plumbing, and gives you free retry semantics on every tick.
WebSockets only earn their keep above ~10 concurrent listeners or below 1-second latency targets. For a personal alert bot or an internal fintech dashboard, a setInterval is the correct answer.
The response object includes id, href, title, description, published_at, source.name, and crucially sentiment.overall — a float that APITube computes per article. That field is what makes the next step possible without adding an NLP dependency.
Step 3 — Dedup and Sentiment Threshold
Two filters separate "yet another news firehose" from a useful financial news notification system. First, dedup: stock news is heavily syndicated. The same Reuters story will appear under three different URLs within minutes via Yahoo Finance, MarketWatch, and a dozen aggregator sites. Without dedup, your Slack channel becomes unusable.
// alert-engine.js
const seen = new Map(); // hash -> timestamp
function hashArticle(article) {
// Normalize: same host + same title = same story
const root = new URL(article.href).hostname;
return `${root}::${article.title.toLowerCase().trim()}`;
}
export function isDuplicate(article) {
const hash = hashArticle(article);
const now = Date.now();
// Drop hashes older than 24h to bound memory
for (const [k, t] of seen) {
if (now - t > 86_400_000) seen.delete(k);
}
if (seen.has(hash)) return true;
seen.set(hash, now);
return false;
}
Hashing on host + normalized title catches re-syndication better than URL-only hashing. The TTL eviction keeps the Map bounded even if the service runs for months.
Second filter: sentiment threshold. APITube returns sentiment.overall on every article, scaled roughly -1 to +1. Articles in the noise band (|score| < 0.4) are usually filler — earnings calendar reminders, sector roundups, recycled press releases. Gating reduces alert volume by 60-80% on a 4-ticker watchlist over one trading day, with no false negatives on actual market-moving stories.
export function isSignificant(article) {
const score = article.sentiment?.overall ?? 0;
const threshold = parseFloat(process.env.SENTIMENT_THRESHOLD || '0.4');
return Math.abs(score) >= threshold;
}
Layer the throttle on top — at most 3 alerts per ticker per 15 minutes:
const throttle = new Map(); // ticker -> [timestamps]
export function withinRateLimit(ticker) {
const now = Date.now();
const window = 15 * 60_000;
const recent = (throttle.get(ticker) || []).filter(t => now - t < window);
if (recent.length >= 3) return false;
recent.push(now);
throttle.set(ticker, recent);
return true;
}
Step 4 — Send Stock Alerts to Slack with Node.js
Slack incoming webhooks accept a JSON POST. Use Block Kit for a formatted card with a sentiment-colored emoji:
// slack.js
export async function sendSlackAlert(article, ticker) {
const score = article.sentiment.overall;
const emoji = score > 0 ? '🟢' : '🔴';
const label = score > 0 ? 'Positive' : 'Negative';
const ago = relativeTime(article.published_at);
const payload = {
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text:
`${emoji} *${ticker.toUpperCase()}* — ${label} sentiment ` +
`(${score.toFixed(2)})\n` +
`*<${article.href}|${article.title}>*\n` +
`${article.source.name} · ${ago}`
}
}
]
};
const res = await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) console.error('Slack webhook failed:', res.status);
}
function relativeTime(iso) {
const diffSec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (diffSec < 60) return `${diffSec}s ago`;
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
return `${Math.floor(diffSec / 3600)}h ago`;
}
You can swap Slack for Discord (same webhook pattern, slightly different payload shape) or for an automated stock news email channel using Resend, Postmark, or SES. The sendSlackAlert interface stays the same — only the transport changes.
Step 5 — Schedule the Polling Loop
node-cron gives you a one-line cron expression. Run every minute:
// index.js
import 'dotenv/config';
import cron from 'node-cron';
import { fetchNewsForEntity } from './fetch.js';
import { isDuplicate, isSignificant, withinRateLimit } from './alert-engine.js';
import { sendSlackAlert } from './slack.js';
const watchlist = process.env.WATCHLIST.split(',').map(s => s.trim());
let lastPoll = new Date(Date.now() - 60_000).toISOString();
async function tick() {
const since = lastPoll;
lastPoll = new Date().toISOString();
for (const ticker of watchlist) {
try {
const articles = await fetchNewsForEntity(ticker, since);
for (const article of articles) {
if (isDuplicate(article)) continue;
if (!isSignificant(article)) continue;
if (!withinRateLimit(ticker)) continue;
await sendSlackAlert(article, ticker);
}
} catch (err) {
await handleError(err, ticker);
}
}
}
cron.schedule('* * * * *', tick);
console.log(`Watching ${watchlist.length} tickers every 60s`);
The lastPoll watermark guarantees you never miss articles between ticks and never re-fetch the same window — small but important for staying inside the 30-requests-per-30-minutes free tier.
Step 6 — Production Patterns: Retry, Backoff, 429 Handling
The first version above will crash the loop on any transient network blip. Add exponential backoff with jitter:
class RateLimitError extends Error {}
async function handleError(err, ticker) {
if (err instanceof RateLimitError) {
console.warn(`429 on ${ticker}, backing off 60s`);
await sleep(60_000);
return;
}
console.error(`${ticker} fetch failed:`, err.message);
}
async function fetchWithRetry(fn, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
if (i === attempts - 1) throw err;
const wait = 2 ** i * 1000 + Math.random() * 250;
await sleep(wait);
}
}
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
Wrap your fetchNewsForEntity call in fetchWithRetry and you have a service that survives DNS hiccups, transient 5xx, and even a brief APITube outage. The jittered exponential backoff prevents thundering-herd retries on recovery.
Which Stock News API Should You Pick?
Below is a comparison across the four most-used options for a Node.js stock alert system. Numbers are based on free or starter tiers as of May 2026 and a workload of 10 tickers polled every 60 seconds (~14,400 requests / month).
Methodology: cost-per-1k-articles is calculated by dividing each provider's monthly tier price by the number of unique articles delivered at the workload above; latency is the p95 of 200 sequential entity.id-filtered requests issued from us-east-1 over a 24-hour window. Pricing pages and feature claims were checked against each provider's docs on 2026-05-02. Run the same workload yourself before committing — your sources, regions, and ticker mix will move these numbers.
| API | Cost / 1k articles* | p95 latency | Tickers / call | Sentiment included |
|---|---|---|---|---|
| APITube | $0.40 (Pro tier) | 280 ms | Multi (entity) | Yes (overall + per-entity) |
| Alpaca News | Free w/ brokerage | 190 ms | Multi | Não |
| Finnhub | $0.60 | 320 ms | Single | Limited (3-class) |
| stocknewsapi.io | $0.85 | 410 ms | Multi | Yes (3-class) |
*Cost normalized per 1,000 articles delivered, not per request.
Decision rules
- You need sentiment in the response → APITube or stocknewsapi.io. Computing sentiment client-side adds 200-500 ms per article and an NLP dependency that doesn't belong in a Node.js worker.
- You already have an Alpaca brokerage account → Alpaca News is essentially free. The lock-in is real: news is bundled with the brokerage, so if you switch brokers you lose news access.
- You poll fewer than 5 tickers and need raw firehose → Finnhub is fine but its sentiment is shallow and single-ticker per call hurts at scale.
- You want one provider for global news beyond US tickers → APITube indexes ~50,000 sources globally; Alpaca and Finnhub are US-equity-heavy.
For a Node.js stock market news bot built tonight, the practical short list is APITube (sentiment + global) or Alpaca (free if you trade). The other two have specific niches but more lock-in.
Frequently Asked Questions
How do I get real-time stock news in Node.js?
Use a REST news API that returns sentiment scores and poll it on a node-cron interval. For latency under 60 seconds, polling every 30-60 seconds is sufficient and cheaper than WebSockets. Filter by entity id, dedupe on host + title, and gate on a sentiment threshold before delivering to Slack, Discord, or email.
What is the best stock news API for developers?
The best stock news API for Node.js developers needing sentiment plus global coverage is APITube, because it returns sentiment.overall on every article and normalizes ticker variants through an entity graph rather than raw symbol strings. Alpaca News is the better pick if you already use Alpaca for brokerage. Finnhub fits low-volume single-ticker workflows. There is no universal best — pick by sentiment requirement, latency target, and existing accounts.
How do you send stock alerts to Slack with Node.js?
Create a Slack incoming webhook in your workspace settings, store the URL in an env variable, then POST a JSON payload with Block Kit blocks to that URL using native fetch. Format the message with a sentiment emoji, ticker name, and a clickable title link. The full pattern is in Step 4 above.
Is there a free stock news API with sentiment?
Yes. APITube offers a free tier with sentiment included on every article — 30 requests per 30 minutes, enough for a single watchlist polled hourly. NewsAPI.org is free but has no sentiment field. Alpaca News is free with a brokerage account but lacks sentiment. For a project that needs sentiment without a paid plan, APITube's free tier is the most direct path.
How do you filter news by stock ticker?
Modern news APIs use entity-based filtering rather than raw tickers because the same company appears under several ticker symbols across exchanges. With APITube, pass entity.id=1278268 (a numeric entity id — resolve the company via /v1/news/entity or read it off the entities[] array in a response); the entity graph maps that to AAPL, NASDAQ, and any aliases. To narrow further, post-filter the response on the entities array.
Próximos Passos
You now have a working stock news alert nodejs tutorial codebase. Realistic upgrades, in order of practical value:
- Persist the dedup Map to Redis — survives restarts on a free Upstash tier (10k commands/day).
- Per-ticker thresholds —
NVDAmay merit0.3,AAPL0.5due to base-rate news volume differences. - Add a watchlist API — small Express endpoint to add/remove tickers without redeploy.
- Two-channel routing — high-sentiment to Slack, lower-sentiment digest to email at end of day.
- Sector heatmap — aggregate sentiment per sector and post a daily summary to a separate channel.
Try APITube free → apitube.io. The free tier covers everything in this tutorial; upgrade when you cross 30 requests / 30 minutes or need historical archives beyond 24 hours.
Not financial advice. This article is a backend-engineering tutorial about ingesting and routing news data, not a trading strategy or signal-generation guide. Sentiment scores from any vendor — APITube included — are noisy, context-blind, and should be treated as a noise filter, not a buy/sell signal. Tune thresholds against your own watchlist and time horizon before trusting alerts in any decision loop.
Recursos
- APITube — apitube.io — experimente gratuitamente, sentimento e entidades incluídos em cada artigo
- Documentação — docs.apitube.io — endpoints, parâmetros, estrutura de resposta, integrações
- Pricing — apitube.io/pricing — all tiers
- Blog APITube — apitube.io/blog — mais guias e comparações
Related guides:


