DEV Community

Cover image for Stock News Alerts on Telegram in 15 Minutes (No News API Key, No Server)
Vamsi Krishna
Vamsi Krishna

Posted on AI-assisted

Stock News Alerts on Telegram in 15 Minutes (No News API Key, No Server)

I wanted a simple thing: whenever news breaks about a stock I follow, send a notification to my phone.

Not another dashboard. Not a terminal subscription. Just a Telegram message with the headline, a quick sentiment label, the type of event, and a link to the original article.

You can build this with three tools:

  • News collection: Apify's Stock Market News Scraper collects headlines from financial news RSS feeds and filters them by ticker, company, or keyword.
  • Scheduling: GitHub Actions runs the script automatically every 15 minutes. No server to maintain.
  • Delivery: A Telegram bot sends the alerts directly to your phone.

A small JavaScript script adds rule-based sentiment and event labels. An Apify Key-Value Store remembers which article links have already been sent, so subsequent runs don't keep sending the same headlines.

One clarification: you don't need a separate news-provider API key, but you do need an Apify API token and a Telegram bot token.

Here's what we're building.

Preview of a Telegram stock-news bot showing fictional positive, negative, and neutral stock news alerts

The architecture

The workflow is intentionally simple:

Stock news alert architecture showing financial news sources flowing through Apify, GitHub Actions, and a Telegram bot

The Actor collects the articles. GitHub Actions starts the script on a schedule. JavaScript filters previously sent links, classifies the headlines, and sends the remaining alerts to Telegram.

Let's build it.

Step 1: Try the Apify Actor

Open the Stock Market News Scraper on Apify and click Try for free.

In the JSON input editor, try this configuration:

{
  "feedPreset": "us_markets",
  "tickers": ["AAPL", "NVDA", "TSLA"],
  "hoursBack": 24,
  "maxItems": 20,
  "deduplicate": true
}
Enter fullscreen mode Exit fullscreen mode

Click Start. The Actor collects matching articles and displays the results in its output.

You should see fields such as:

Field What it contains
headline The article headline
source The news publisher
tickers Detected stock tickers
categories Publisher or article categories
publishedDate The article's publication date
summary A short summary when available
articleLink The original article URL

You can also use the companies input to filter by company names. If the default financial-keyword filter is too restrictive, set includeKeywords to an empty array ([]) to disable that filter.

The Actor's deduplicate option removes duplicate article links within a collection run. In the next step, we'll handle the separate problem of remembering links across scheduled runs.

A limitation worth knowing: the Actor primarily collects headlines and RSS summaries. It doesn't guarantee full article text, and ticker detection is best-effort. Always open important articles and verify their details yourself.

Step 2: Create a Telegram bot

You need a bot that can send messages to your Telegram account.

  1. Open Telegram and start a conversation with @BotFather.
  2. Send /newbot and follow the prompts.
  3. Copy the bot token that BotFather provides. Keep it private.
  4. Open a conversation with your new bot and send it a message, such as hi.

Now retrieve your chat ID.

Open this URL in a browser after replacing the placeholder with your actual bot token:

https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
Enter fullscreen mode Exit fullscreen mode

Look for a section resembling this:

{
  "message": {
    "chat": {
      "id": 123456789
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The number in chat.id is your Telegram chat ID.

If you want alerts in a group, add the bot to the group, send a message there, and retrieve the group's chat ID instead.

Keep your bot token private. Anyone who obtains it may be able to control your bot.

Step 3: Write the alert script

Create a GitHub repository for the project. A private repository is perfectly fine.

We'll use two files.

package.json

{
  "name": "stock-news-telegram-alerts",
  "private": true,
  "type": "module",
  "dependencies": {
    "apify-client": "^2.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

alert.mjs

This script runs the Actor, compares article links against previously sent links, assigns simple rule-based labels, and sends new alerts to Telegram.

import { ApifyClient } from 'apify-client';

const {
    APIFY_TOKEN,
    APIFY_STATE_STORE_ID,
    TELEGRAM_BOT_TOKEN,
    TELEGRAM_CHAT_ID,
} = process.env;

const requiredEnv = {
    APIFY_TOKEN,
    APIFY_STATE_STORE_ID,
    TELEGRAM_BOT_TOKEN,
    TELEGRAM_CHAT_ID,
};

for (const [name, value] of Object.entries(requiredEnv)) {
    if (!value) {
        throw new Error(`Missing required environment variable: ${name}`);
    }
}

const client = new ApifyClient({ token: APIFY_TOKEN });

// Persistent storage for article links already sent to Telegram.
const store = client.keyValueStore(APIFY_STATE_STORE_ID);
const stateRecordKey = 'telegram-watchlist-state';

const savedState = await store.getRecord(stateRecordKey);

const seenUrls = new Set(
    Array.isArray(savedState?.value?.seenUrls)
        ? savedState.value.seenUrls
        : []
);

// Collect recent news for the watchlist.
const run = await client
    .actor('vamsi-krishna/stock-market-news-collector')
    .call({
        feedPreset: 'us_markets',
        tickers: ['AAPL', 'NVDA', 'TSLA'],
        hoursBack: 6,
        maxItems: 20,
        deduplicate: true,
    });

const { items = [] } = await client
    .dataset(run.defaultDatasetId)
    .listItems();

function getArticleKey(article) {
    return (
        article.articleLink ||
        `${article.headline ?? ''}|${article.publishedDate ?? ''}`
    );
}

function classifySentiment(article) {
    const text = `${article.headline ?? ''} ${article.summary ?? ''}`;

    const positive =
        /\b(beat|beats|surpass|surpasses|upgrade|upgraded|surge|surges|rally|rallies|raises guidance|raised guidance|record profit|strong results|growth|approved|approval)\b/i.test(text);

    const negative =
        /\b(miss|misses|missed|plunge|plunges|plunged|downgrade|downgraded|cuts guidance|cut guidance|loss|losses|warning|warns|investigation|lawsuit|recall|recalls|decline|drops|fell|falls|weak results)\b/i.test(text);

    if (positive && !negative) return 'positive';
    if (negative && !positive) return 'negative';

    return 'neutral';
}

function classifyEvents(article) {
    const text =
        `${article.headline ?? ''} ${article.summary ?? ''}`.toLowerCase();

    const events = [];

    if (/\b(earnings|quarterly results|revenue|eps|profit)\b/.test(text)) {
        events.push('earnings');
    }

    if (/\b(guidance|outlook|forecast)\b/.test(text)) {
        events.push('guidance');
    }

    if (/\b(analyst|price target|upgrade|downgrade|rating)\b/.test(text)) {
        events.push('analyst_rating');
    }

    if (/\b(merger|acquisition|acquires|acquired|buyout)\b/.test(text)) {
        events.push('mergers_acquisitions');
    }

    if (/\b(regulator|regulatory|sec|ftc|lawsuit|investigation|antitrust|approval)\b/.test(text)) {
        events.push('regulatory');
    }

    if (/\b(launch|launches|unveils|introduces|product)\b/.test(text)) {
        events.push('product_news');
    }

    return events.length ? events : ['general'];
}

function escapeHtml(value) {
    const entities = {
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        "'": '&#39;',
    };

    return String(value ?? '').replace(
        /[&<>"']/g,
        (character) => entities[character]
    );
}

function getSafeArticleUrl(value) {
    try {
        const url = new URL(String(value ?? ''));

        if (url.protocol === 'https:' || url.protocol === 'http:') {
            return url.href;
        }
    } catch {
        // Ignore missing or invalid article links.
    }

    return null;
}

async function saveSeenUrls() {
    // Retain a bounded history rather than growing forever.
    const recentUrls = [...seenUrls].slice(-2000);

    await store.setRecord({
        key: stateRecordKey,
        value: {
            seenUrls: recentUrls,
            updatedAt: new Date().toISOString(),
        },
    });
}

const icons = {
    positive: '🟢',
    negative: 'đź”´',
    neutral: '⚪',
};

// The Actor returns articles sorted by publication date, newest first.
// Send older new headlines first so the newest appears last in the chat.
const newItems = items.filter((article) => {
    const key = getArticleKey(article);
    return key && !seenUrls.has(key);
});

for (const article of [...newItems].reverse()) {
    const key = getArticleKey(article);
    const sentiment = classifySentiment(article);
    const eventTypes = classifyEvents(article);

    const details = [
        article.source,
        Array.isArray(article.tickers)
            ? article.tickers.join(', ')
            : '',
        eventTypes.join(', '),
    ]
        .filter(Boolean)
        .join(' · ');

    const articleUrl = getSafeArticleUrl(article.articleLink);

    const message = [
        `${icons[sentiment]} <b>${escapeHtml(article.headline || 'Stock news update')}</b>`,
        escapeHtml(details),
        articleUrl
            ? `<a href="${escapeHtml(articleUrl)}">Read article</a>`
            : '',
    ]
        .filter(Boolean)
        .join('\n');

    const response = await fetch(
        `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`,
        {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                chat_id: TELEGRAM_CHAT_ID,
                text: message,
                parse_mode: 'HTML',
                link_preview_options: {
                    is_disabled: true,
                },
            }),
        }
    );

    const result = await response.json().catch(() => ({}));

    if (!response.ok || result.ok !== true) {
        throw new Error(
            `Telegram API error: ${result.description || response.status}`
        );
    }

    // Save after a successful send so the next run skips this link.
    seenUrls.add(key);
    await saveSeenUrls();

    // Space messages out to avoid sending a burst too quickly.
    await new Promise((resolve) => setTimeout(resolve, 1100));
}

console.log(`Sent ${newItems.length} new article(s).`);
Enter fullscreen mode Exit fullscreen mode

What makes this work?

There are four important pieces:

  • Article-link deduplication: the script records each successfully sent article link in Apify Key-Value Store. A later run can skip it.
  • Persistent state: the store survives between GitHub Actions runs; the workflow doesn't depend on a local file surviving.
  • Rule-based tags: the sentiment and event functions look for common headline phrases. They're simple heuristics, not a financial sentiment model.
  • Telegram HTML formatting: headlines and metadata are escaped before being inserted into the message.

The state history is capped at 2,000 article identifiers. That keeps the stored record bounded; very old links may eventually be eligible again if they reappear after leaving the history.

Step 4: Add your secrets and schedule the workflow

First, create an Apify Key-Value Store in Apify Console under Storage → Key-value stores. Copy its store ID. This is where the script will save the history of sent links.

In your GitHub repository, open:

Settings → Secrets and variables → Actions

Create these four repository secrets:

Secret Value
APIFY_TOKEN Your Apify API token
APIFY_STATE_STORE_ID The ID of the Key-Value Store you created
TELEGRAM_BOT_TOKEN Your Telegram bot token
TELEGRAM_CHAT_ID Your Telegram chat ID

Never put these values directly in your JavaScript file or commit them to Git.

Next, create the following file in your repository:

.github/workflows/stock-alerts.yml

name: Stock news alerts

on:
  schedule:
    - cron: '*/15 * * * *'
  workflow_dispatch:

concurrency:
  group: stock-news-alerts
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  alert:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Install dependencies
        run: npm install

      - name: Send stock-news alerts
        run: node alert.mjs
        env:
          APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }}
          APIFY_STATE_STORE_ID: ${{ secrets.APIFY_STATE_STORE_ID }}
          TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
          TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
Enter fullscreen mode Exit fullscreen mode

Commit and push the files. Make sure the workflow exists on your repository's default branch for scheduled runs to work.

Open the Actions tab, select Stock news alerts, and click Run workflow to test the setup immediately.

If everything is configured correctly, new headlines should arrive in Telegram. Subsequent runs should skip article links already recorded in the Key-Value Store.

GitHub's scheduler is not a real-time service: scheduled jobs can start late during busy periods. The cron expression means every 15 minutes, not a guarantee that every run starts at the exact minute.

Make it yours

The same setup can be adapted to different watchlists and news sources.

Watch different US stocks

Change the tickers array in alert.mjs:

tickers: ['MSFT', 'AMZN', 'GOOGL'],
Enter fullscreen mode Exit fullscreen mode

You can also add company names through the Actor's companies input if name matching is more useful for your watchlist.

Add Indian-market RSS feeds

The Actor's current built-in presets focus on US-market sources. For other regions, use feedPreset: 'custom' and provide RSS or Atom feeds that you are allowed to access.

For example:

const run = await client
    .actor('vamsi-krishna/stock-market-news-collector')
    .call({
        feedPreset: 'custom',
        feeds: [
            {
                name: 'Regional market news',
                url: 'https://example.com/rss.xml'
            }
        ],
        tickers: ['RELIANCE.NS', 'INFY.NS'],
        companies: ['Reliance Industries', 'Infosys'],
        hoursBack: 24,
        maxItems: 20,
        deduplicate: true
    });
Enter fullscreen mode Exit fullscreen mode

Replace the example URL with a genuine RSS or Atom feed. Regional coverage depends on the feeds you configure, and ticker matching is not guaranteed to catch every relevant article.

Follow a topic instead of a ticker

You can use keyword matching with a custom feed:

{
    feedPreset: 'custom',
    feeds: [
        {
            name: 'Topic news',
            url: 'https://example.com/rss.xml'
        }
    ],
    includeKeywords: ['Tata Motors', 'lithium prices'],
    hoursBack: 24,
    maxItems: 20,
    deduplicate: true
}
Enter fullscreen mode Exit fullscreen mode

This is useful for following companies, commodities, industries, or other topics represented in your RSS feeds. Replace the example feed URL before running it.

Only send certain event types

You can filter articles before sending them. For example, to focus on negative headlines and selected event types, apply the existing classifySentiment() and classifyEvents() functions to the newItems array.

Remember that rule-based sentiment can misclassify headlines: words such as “raises” or “investigation” may need context. Treat the labels as a triage signal, not an investment recommendation.

What does it cost?

There are three main considerations:

  • Apify: the Actor's pricing page currently lists pricing from $2.50 per 1,000 returned articles, plus a small run fee. Check the live Actor pricing before relying on a particular budget.
  • Apify platform usage: your plan's included credits and storage usage may affect the final cost.
  • GitHub Actions: usage is subject to the included minutes and limits for your repository and plan.

With a small watchlist and a modest number of headlines, this can be a lightweight setup. Still, check your account usage periodically rather than assuming that every run is free.

Wrapping up

That's the workflow: one news-collection Actor, a JavaScript script, a managed key-value store for deduplication, and a scheduled GitHub Actions job that sends alerts to Telegram.

There is no server to deploy or maintain, no separate news-provider API key, and no database server to provision.

The sentiment and event tags are deliberately simple, which makes them easy to customize. You could replace the rules with a more sophisticated classifier or route selected alerts into Slack, Discord, a spreadsheet, or another workflow.

If you build on this setup, I'd love to hear what you add.

Useful links

Top comments (0)