# Telegram Channel Scraper & Monitor – Change Feed (`changefeeds/telegram-channel-change-feed`) Actor

Scrape public Telegram channel messages and get a change feed run over run: only new messages, edits, deletions and view-count moves versus last time, plus subscriber counts. No Telegram login, no phone number, no API ID — reads the public t.me/s preview.

- **URL**: https://apify.com/changefeeds/telegram-channel-change-feed.md
- **Developed by:** [Changefeeds Tools](https://apify.com/changefeeds) (community)
- **Categories:** Social media, News
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## 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](https://docs.apify.com/platform/integrations) endpoint,
or point it at a Zapier/Make "Catch Hook" step, or an
[Apify Slack/Google Sheets integration](https://apify.com/integrations) 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.

### 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`
- `deleted` — was in the previous snapshot, within the id range this run
  fetched, and is gone now (see limitations)
- `unchanged` — nothing 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`, `charged_events`,
`charge_limit_reached`). If you set `webhookUrl`, the same summary is POSTed
there as JSON when the run ends.

### 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 for view re-checks). |
| `includeUnchanged` | false | Also emit a (free) row for messages that did not change. |
| `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. |

```json
{
  "channels": ["durov", "telegram"],
  "maxMessagesPerChannel": 100,
  "includeUnchanged": false
}
```

If a run finds nothing new, the dataset holds one `no_changes` row (not charged), so a quiet run is never mistaken for a broken one.

### Sample output

```json
{
  "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 }
}
```

```json
{
  "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"]
}
```

```json
{
  "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**.

**Steady-state daily run, same 10 channels**, once history exists: a channel
typically shows 2–5 new/edited/view-changed messages a day.
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**.

**Per month**, running the steady-state case once a day (30 runs): 30 ×
$0.04–$0.07 ≈ **$1.20–$2.10/month** for those 10 channels, plus the one-time
$1.02 baseline the first time you watch that list.

First runs are the expensive ones; the routine is the cheap part — that's the
whole point of the change feed.

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
  view-count and edit 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 charges the most; steady-state runs charge only what changed.
- 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

```bash
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.

# Actor input Schema

## `channels` (type: `array`):

Public Telegram channels to watch: usernames (durov), @durov, t.me/durov, or https://t.me/s/durov. Public channels only; private ones and non-existent names are reported as not\_found. Up to 1,000 per run.

## `maxMessagesPerChannel` (type: `integer`):

How many of the most recent messages to read per channel (1 to 2,000). The first run fetches up to this many; later runs only fetch what is new plus the newest page for view-count re-checks.

## `includeUnchanged` (type: `boolean`):

Also return a row for messages that did not change since the previous run (not billed).

## `snapshotKey` (type: `string`):

Name of the saved state used to compute changes between runs. Leave empty to derive it from the sorted channel list, so the same list always compares against its own last run. Set it explicitly to keep history when you edit the list.

## `webhookUrl` (type: `string`):

Optional. When the run finishes, the OUTPUT summary is POSTed here as JSON.

## Actor input object example

```json
{
  "channels": [
    "durov"
  ],
  "maxMessagesPerChannel": 100,
  "includeUnchanged": false
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `summary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "channels": [
        "durov"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("changefeeds/telegram-channel-change-feed").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "channels": ["durov"] }

# Run the Actor and wait for it to finish
run = client.actor("changefeeds/telegram-channel-change-feed").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "channels": [
    "durov"
  ]
}' |
apify call changefeeds/telegram-channel-change-feed --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,changefeeds/telegram-channel-change-feed"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/wsS4guy9ydls2t7Vo/builds/f7lABv9JnC3DDBA8U/openapi.json
