Telegram Channel Monitor: New, Edited & Deleted Posts
Pricing
Pay per event
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
Maintained by CommunityActor stats
0
Bookmarked
1
Total users
1
Monthly active users
2 days ago
Last modified
Categories
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.mjsconst 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, '&').replace(/</g, '<').replace(/>/g, '>');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 moreawait 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,telegramnode 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.06621: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 | YouTubehttps://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, checked | Checks per 30 days | Floor per 30 days |
|---|---|---|
| once a day | 30 | $0.18 |
| every hour | 720 | $4.32 |
| every 15 minutes | 2,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 asnew, 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 aserror, 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_typeedited,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
textfield), not against a real Slack workspace.
What you get
One channel row per channel (type: "channel"):
- title, description, subscriber count
subscribers_before,subscribers_deltaversus the previous runis_baseline(first run of the list),previous_checked_atmessages_fetchedand the per-typechangescountsstatus:ok,not_found(missing or private channel),error(a page failed to load) orinvalid(the input entry is not a channel username)
One message row per message (type: "message"), with a change_type:
baseline— first run: every fetched messagenew— not in the previous run's snapshotedited— the text changed, or the "edited" marker appearedviews_changed— the view counter moved; carriesviews_before,views_after,views_delta. Billed like any other change; settrackViewChanges: trueto 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 raisedmaxMessagesPerChannel); billed once likebaseline, not an alertdeleted— 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, withtrackViewChangesoff, the default, only the view counter moved); only emitted when you setincludeUnchanged: 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
| Field | Default | Notes |
|---|---|---|
channels | required | Channel 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. |
maxMessagesPerChannel | 100 | 1 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). |
includeUnchanged | false | Also emit a (free) row for messages that did not change. |
trackViewChanges | false | When 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. |
snapshotKey | derived | Which 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. |
webhookUrl | none | Receives 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_changedordeleted. 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"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 tomaxMessagesPerChannel, and every message is abaselinerow. - 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
trackViewChangesis 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: falsea view-only movement is not a change and the stored view count is not updated for it (unlessincludeUnchangedis 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 asnot_found. Channels that don't exist also come back asnot_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
deletedrow 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
snapshotKeyis 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
errorrow, 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 networkpnpm --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.