Telegram Channel Monitor: New, Edited & Deleted Posts avatar

Telegram Channel Monitor: New, Edited & Deleted Posts

Pricing

Pay per event

Go to Apify Store
Telegram Channel Monitor: New, Edited & Deleted Posts

Telegram Channel Monitor: New, Edited & Deleted Posts

Monitor public Telegram channels with no login or API ID. After a baseline run, each run returns what changed in a channel's latest posts: new, edited and deleted posts, optional view-count moves, plus subscriber counts. Schedule it; an optional webhook gets each run's change counts.

Pricing

Pay per event

Rating

0.0

(0)

Developer

Changefeeds Tools

Changefeeds Tools

Maintained by Community

Actor stats

0

Bookmarked

1

Total users

1

Monthly active users

2 days ago

Last modified

Share

Telegram Channel Scraper & Monitor – Change Feed

For anyone who needs to scrape public Telegram channel messages on a schedule — competitor trackers, market/news researchers, agencies building client digests — without paying for every message every time. Give it a list of public Telegram channels. Each run returns their newest messages compared with the previous run of the same list: which messages are new, which were edited, which view counters moved, and which messages were deleted. Plus the channel's title, description and subscriber count, with the change since last time.

Schedule it daily or weekly and the dataset becomes a change feed for competitor tracking, channel monitoring, or digest building — using only the public t.me/s/<channel> web preview. No Telegram login, no API ID, no phone number.

Use cases

Daily competitor/channel digest to Slack. Schedule the actor to run once a day against your watch list (Apify Schedules). Set webhookUrl to an Apify integration webhook endpoint, or point it at a Zapier/Make "Catch Hook" step, or an Apify Slack/Google Sheets integration run after the actor finishes. The run's OUTPUT summary (new/edited/deleted counts per channel) posts as JSON, so the next step in Zapier/Make can format it into a Slack message or append rows to a Google Sheet — only what changed, never a full re-dump of every message.

New-post monitoring only. This is the default: a message whose only difference from the last run is its view counter is treated as unchanged, so you get a feed of "this channel posted" without the view-count rows. Set trackViewChanges: true to also get view-count moves.

Tutorial: a Slack alert when a public Telegram channel posts

One script, no dependencies, Node 20 or newer. It runs this actor with trackViewChanges: false, keeps only the rows marked new, and posts each one to a Slack incoming webhook. The actor remembers what it has already seen, so the script itself stores nothing.

You need an Apify API token (Console → Settings → API & Integrations), a Slack incoming webhook URL, and the usernames of the channels to watch.

// telegram-to-slack.mjs
const APIFY_TOKEN = process.env.APIFY_TOKEN;
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL;
const TG_CHANNELS = process.env.TG_CHANNELS;
const APIFY_BASE = process.env.APIFY_BASE || 'https://api.apify.com';
// Newest messages read per channel per check. Posts beyond this between two checks are missed.
const MAX_PER_CHECK = 20;
for (const [name, val] of [['APIFY_TOKEN', APIFY_TOKEN], ['SLACK_WEBHOOK_URL', SLACK_WEBHOOK_URL], ['TG_CHANNELS', TG_CHANNELS]]) {
if (!val) { console.error(`Missing env: ${name}`); process.exit(2); }
}
const channels = TG_CHANNELS.split(',').map(s => s.trim()).filter(Boolean);
const res = await fetch(`${APIFY_BASE}/v2/acts/changefeeds~telegram-channel-change-feed/run-sync-get-dataset-items?format=json`, {
method: 'POST',
headers: { Authorization: `Bearer ${APIFY_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ channels, maxMessagesPerChannel: MAX_PER_CHECK, trackViewChanges: false }),
});
if (!res.ok) {
const body = await res.text();
console.error(`Apify status ${res.status}: ${body.slice(0, 300)}`);
process.exit(1);
}
const rows = await res.json();
let baseline = 0, newCount = 0, posted = 0, exitCode = 0;
const alerts = [];
for (const row of rows) {
if (row.type === 'channel' && row.status !== 'ok') {
console.error(`Channel warning: ${row.input} is ${row.status}`);
if (row.status === 'error') exitCode = 1; // the check failed; not_found and invalid are input problems
} else if (row.type === 'message' && row.change_type === 'baseline') {
baseline++;
} else if (row.type === 'message' && row.change_type === 'new') {
newCount++;
alerts.push(row);
}
}
alerts.sort((a, b) => new Date(a.date) - new Date(b.date));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const escapeSlack = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const send = (text) => fetch(SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
for (const row of alerts) {
const raw = (row.text || '').replace(/\s+/g, ' ').trim();
const body = raw ? (raw.length > 280 ? raw.slice(0, 280) + '…' : raw) : '(media, no text)';
const text = `*${row.channel}* posted: ${escapeSlack(body)}\n${row.url}`;
try {
let r = await send(text);
if (r.status === 429) { // Slack rate limit: wait as told, try this one once more
await sleep((Number(r.headers.get('retry-after')) || 2) * 1000);
r = await send(text);
}
if (r.ok) posted++;
else { console.error(`Slack status ${r.status} for ${row.url}`); exitCode = 1; }
} catch (err) {
console.error(`Slack request failed for ${row.url}: ${err.message}`); exitCode = 1;
}
await sleep(1100); // Slack accepts about one message per second per webhook
}
console.log(`channels=${channels.length} baseline=${baseline} new=${newCount} posted=${posted}`);
process.exit(exitCode);
export APIFY_TOKEN=apify_api_...
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
export TG_CHANNELS=cointelegraph,WatcherGuru,telegram
node telegram-to-slack.mjs

The first run is silent on purpose. The first run of a channel list returns up to the newest 20 messages of each channel as baseline rows; the script counts them and posts none, so Slack is not flooded with old posts. Every later run posts only the messages it has not seen before. Measured on 2026-10-07 with the three channels above:

21:30Z channels=3 baseline=60 new=0 posted=0 charged: 3 channels + 60 messages = $0.066
21:32Z channels=3 baseline=0 new=1 posted=1 charged: 3 channels + 1 message = $0.007

The payload the script sent for that one new post (captured by the local test server described below):

*cointelegraph* posted: 🚨 LATEST: Oracle, Broadcom and SpaceX are seeking debt deals worth tens of billions of dollars each to fund AI chips, with Apollo, Blackstone and Goldman Sachs among lenders in talks. News | Markets | YouTube
https://t.me/cointelegraph/72539

Schedule it with whatever already runs your scripts. With cron, hourly:

0 * * * * cd /path/to/script && node --env-file=.env telegram-to-slack.mjs >> tg-alerts.log 2>&1

What it costs. A run with nothing new still pays the channel check, $0.002 per channel, so the schedule sets the floor. Each new post adds $0.001. Edited and deleted posts are billed at $0.001 too, although this script does not alert on them.

3 channels, checkedChecks per 30 daysFloor per 30 days
once a day30$0.18
every hour720$4.32
every 15 minutes2,880$17.28

The first run costs more, once: $0.066 for these three channels (the $0.006 check plus 60 baseline messages). Changing the channel list starts a new baseline unless you set snapshotKey.

Know before you rely on it:

  • Busy channels can outrun it. Each check reads at most the newest 20 messages per channel (MAX_PER_CHECK). If a channel posts more than that between two checks, the older ones are not alerted. Check more often, or raise the number; the first run then bills up to that many baseline messages per channel.
  • Older posts that slide into the window (after a deletion) or that appear because you raised the limit come back as backfill, billed once like a baseline row, never as new, so the script does not alert on them.
  • A channel that does not exist or is private comes back as not_found. The script prints a warning for it and you are not charged for it. A channel whose page failed to load comes back as error, and the script exits with code 1.
  • If Slack rejects a message or the request fails, that post is not sent again on the next run: the actor has already recorded it as seen. The script waits about a second between messages, retries once on Slack's rate limit (HTTP 429), prints the post's URL for anything that still failed and exits with code 1, so alert on a failing job.
  • You see posts when the script runs, not when they are published. Edits and deletions are in the dataset (change_type edited, deleted) but this script ignores them.
  • How this was tested: the actor calls and charges above are real runs. The Slack side was tested against a local server that accepts what Slack's webhook documents (a JSON body with a text field), not against a real Slack workspace.

What you get

One channel row per channel (type: "channel"):

  • title, description, subscriber count
  • subscribers_before, subscribers_delta versus the previous run
  • is_baseline (first run of the list), previous_checked_at
  • messages_fetched and the per-type changes counts
  • status: ok, not_found (missing or private channel), error (a page failed to load) or invalid (the input entry is not a channel username)

One message row per message (type: "message"), with a change_type:

  • baseline — first run: every fetched message
  • new — not in the previous run's snapshot
  • edited — the text changed, or the "edited" marker appeared
  • views_changed — the view counter moved; carries views_before, views_after, views_delta. Billed like any other change; set trackViewChanges: true to turn these rows on (off by default)
  • backfill — an older message this snapshot never delivered (it slid into the fetched window after a deletion, or you raised maxMessagesPerChannel); billed once like baseline, not an alert
  • deleted — was in the previous snapshot, is within the fetched recent posts (the id range this run read), and is gone now; deletions of older posts are not detected (see limitations)
  • unchanged — nothing moved (or, with trackViewChanges off, the default, only the view counter moved); only emitted when you set includeUnchanged: true

Each row carries id, url (a t.me link), date, text, views, edited, has_media, forwarded_from and the links found in the message text.

The key-value store record OUTPUT holds a per-channel summary plus totals (changes, messages_returned, first_run, track_view_changes, charged_events, charge_limit_reached). If you set webhookUrl, the same summary is POSTed there as JSON when the run ends. OUTPUT also gets a webhook field (status delivered/failed/not_configured, attempts, http_status, error); the webhook is best effort: up to 3 attempts on network errors, 429 and 5xx, and a failed delivery is recorded in OUTPUT but not re-sent on a later run. Each POST carries run_id (also sent as an idempotency-key header); a retry after a timeout can deliver the same summary twice, so deduplicate on run_id. A 429 with Retry-After is honoured up to 30 s.

Input

FieldDefaultNotes
channelsrequiredChannel usernames: durov, @durov, t.me/durov, https://t.me/s/durov all work. Up to 1,000 per run. Invalid entries become an error row; the run continues.
maxMessagesPerChannel1001 to 2,000. The first run fetches up to this many; later runs only fetch what is new (plus the newest page, which is re-checked for edits and, if trackViewChanges is on, view counts).
includeUnchangedfalseAlso emit a (free) row for messages that did not change.
trackViewChangesfalseWhen true, each message whose view counter moved since the last run is returned and billed as a views_changed row. When false (the default), only new, edited and deleted messages are returned; a message that only gained views counts as unchanged.
snapshotKeyderivedWhich saved state to compare against. By default it is derived from the sorted channel list, so the same list always compares with itself. Set it explicitly to keep history when you edit the list. Distinct explicit keys always get distinct storage records (the key is sanitised and suffixed with a hash of the full original), so watch/a and watch?a never share history.
webhookUrlnoneReceives the run summary as a JSON POST when the run ends. Best effort: up to 3 attempts; the result is in OUTPUT.webhook.
{
"channels": ["durov", "telegram"],
"maxMessagesPerChannel": 100,
"includeUnchanged": false
}

A run that finds nothing new still returns one channel row per channel, with every count in changes at zero, so a quiet run is never mistaken for a broken one.

Sample output

{
"type": "channel",
"input": "durov",
"channel": "durov",
"change_type": "channel",
"status": "ok",
"title": "Pavel Durov",
"description": "Founder of Telegram.",
"subscribers": 10600000,
"subscribers_before": 10590000,
"subscribers_delta": 10000,
"is_baseline": false,
"messages_fetched": 3,
"changes": { "baseline": 0, "new": 1, "edited": 1, "views_changed": 1, "deleted": 0, "unchanged": 0, "backfill": 0 }
}
{
"type": "message",
"input": "durov",
"channel": "durov",
"change_type": "new",
"id": 549,
"url": "https://t.me/durov/549",
"date": "2026-09-05T10:00:00+00:00",
"text": "Something new happened today.",
"views": 812000,
"views_before": null,
"views_after": null,
"views_delta": null,
"edited": false,
"has_media": false,
"forwarded_from": null,
"links": ["https://telegram.org"]
}
{
"type": "message",
"channel": "durov",
"change_type": "views_changed",
"id": 528,
"url": "https://t.me/durov/528",
"views_before": 18900000,
"views_after": 19500000,
"views_delta": 600000
}

Pricing

Pay per event, nothing else:

  • $0.002 per channel checked (a channel that was fetched and compared). Missing, private, invalid or failed channels are not charged.
  • $0.001 per message returned with a change: baseline, new, edited, views_changed or deleted. Unchanged rows and the channel row are free.

What it costs

Baseline (first) run, 10 channels, 100 messages each: 10 channels × $0.002 = $0.02 channel charges, plus every fetched message is a baseline row: 10 × 100 × $0.001 = $1.00. Total: $1.02.

Later runs, view tracking on. A later run re-checks the newest page (about 20 messages) of every channel, and each message whose public view counter moved is a billed views_changed row. On a large active channel that is most of the page: measured on 2026-10-07 on durov, 19 of 20 messages had moved after 8 days with no new posts. So for that one re-checked page, budget up to about $0.02 per channel per run on top of the $0.002 check: for 10 such channels with no new posts, up to $0.22 per run, about $6.60/month if you run daily. New, edited and deleted messages are billed on top of that, at $0.001 each. Small or quiet channels' counters move less.

Later runs, trackViewChanges: false (the default). You pay the $0.002 check plus only the new, edited and deleted messages. For 10 channels with 2–5 new posts a day each: 10 × $0.002 = $0.02 channel charges, plus (2–5) × 10 × $0.001 = $0.02–$0.05 message charges. Total: $0.04–$0.07 per run, about $1.20–$2.10/month run daily, plus the one-time $1.02 baseline the first time you watch that list.

A later run can cost more than these examples when a channel posts a lot between runs: every new message is a billed row, up to maxMessagesPerChannel per channel. If you only want to know when channels post, leave trackViewChanges off (the default); that is what keeps the routine cost low.

If you set a maximum total charge for the run, the actor returns only as many rows as fit, saves state for what it returned, stops fetching further channels, and says so in OUTPUT (

stopped_reason: "max_total_charge_reached"
). It never charges for a row it did not deliver.

How the change feed works

  • State lives in the named key-value store tgwatch-snapshots, one record per channel and snapshot key. The record holds the channel info and, per message id, the text's SHA-256 hash, the view count and the edited flag. The storage key for an explicit snapshot key is the sanitised original (max 60 chars) plus a 16-hex-digit hash of the full original, keeping every key within Apify's 256-character, [a-zA-Z0-9!\-_.'()] limit and collision-free.
  • The first run of a snapshot key is the baseline: it walks the preview pages (newest first, then older via ?before=<id>) up to maxMessagesPerChannel, and every message is a baseline row.
  • Later runs fetch page 1 (the ~20 newest messages). If every id on it is already known, they stop there — but those messages are still re-checked for edit changes and, if trackViewChanges is on, view-count changes. If there are new messages, paging continues until it reaches known territory or the message limit.
  • A known id inside the fetched id range that is missing from the pages is reported as deleted. Outside the fetched range, deletions are invisible (the preview only shows ~20 newest messages per page), so they are never guessed.
  • The first run bills every fetched message. Later runs charge for what changed, and with view tracking on that includes every re-checked message whose counter moved. With trackViewChanges: false a view-only movement is not a change and the stored view count is not updated for it (unless includeUnchanged is on), so turning tracking back on later reports one delta against the last stored count.
  • If a page fails after earlier pages' rows were already delivered and charged, those rows are still saved to the snapshot — an identical retry is not billed twice for the same messages. A failed check never infers deletions and never updates the channel's title/subscriber metadata or its "last checked" time.
  • Snapshot saves merge with what is currently stored instead of overwriting it, so two overlapping runs of the same snapshot key (for example a manual run started while a scheduled one is still going) cannot erase each other's history: the union of message ids is kept, and channel metadata comes from whichever run checked the channel later.

Limits, stated plainly

  • Public channels only. The actor reads https://t.me/s/<channel> — exactly what a browser without a Telegram account sees. Private channels and groups are not readable this way and come back as not_found. Channels that don't exist also come back as not_found (not an error, not charged).
  • The preview is what you get. The web preview shows a limited recent window and may differ subtly from what clients render (e.g. some polls or paid media show placeholders). Message text is parsed from the page's HTML, stripped of markup.
  • No media download. Photos and videos only set has_media: true; no files are fetched.
  • Deletions are only detected within the fetched id range. A message older than what this run read can be deleted without this actor noticing — and a run in which a page failed never reports deletions at all. Reads are also per-run: between two runs you see one delta, not every intermediate step.
  • View counts are approximate by nature. Telegram rounds the public counter (that is why the parser understands 2.67M); a changed counter may reflect hundreds of real views, and the counter may lag.
  • Deleted-message ids are remembered, not full messages. A deleted row cannot show the old text — only the id, url and last known view count.
  • It stays polite: at most 2 channels fetched at a time, at least 1.5 s between page requests of the same channel, HTTP 429 honoured (Retry-After, capped at 60 s, max 3 retries; a longer Retry-After skips that channel for this run), a normal browser-like User-Agent, 20 s timeouts. A channel needs roughly 1 page per 20 messages, so a fresh watch of 10 channels × 100 messages takes about a minute.
  • One run at a time per snapshot key. If a scheduled run starts while the previous one with the same snapshotKey is still going, the new run fails immediately, before fetching or charging anything. A run that crashed without cleaning up blocks its key for at most 30 minutes. (The lock is best effort: two runs started within the same couple of seconds can, rarely, both proceed; their saved history is merged, not overwritten.)
  • If saved history can't be read, the channel is skipped, with a free error row, rather than re-sent as a paid baseline. If it can't be saved after 3 attempts, the run is marked failed and names the channels, because the next run would report those messages again.
  • A page that fails mid-history is picked up next time. Rows delivered before the failure are remembered; the next run pages back past them to the messages it missed.

Local development

pnpm --filter @mmnm/tgwatch test # unit tests, no network
pnpm --filter @mmnm/tgwatch build

node src/main.ts runs the actor locally with Apify's local storage (./storage); ACTOR_TEST_PAY_PER_EVENT=true ACTOR_MAX_TOTAL_CHARGE_USD=1 exercises the charging path with the SDK's $1 local test price.