# Web3 Aggregator API

Posts of crypto Telegram channels and chats, each with its class (approved for the aggregator, an ad, a duplicate, … or unchecked); full-text search over the whole base; channel scores with their risk flags and reasons; hot topics; frequent tokens; a WebSocket stream of new posts.

- Base URL: `https://api.web3aggregator.io/api/v1`
- This text: https://api.web3aggregator.io/llms.txt; OpenAPI: https://api.web3aggregator.io/api/v1/openapi.json

## Quick start

1. Log in with Telegram and create a key: https://api.web3aggregator.io/keys
2. Keep it in the environment variable W3A_API_KEY, never in a prompt, a URL or a web page.
3. Make a request, or hand a ready task to your agent:

```sh
curl -H "Authorization: Bearer $W3A_API_KEY" "https://api.web3aggregator.io/api/v1/posts?limit=5"
```

## Recipes

- What is hot now (`GET /api/v1/hot?hours=6`): tell me what crypto Telegram talks about most in the last 6 hours: take GET https://api.web3aggregator.io/api/v1/hot?hours=6, read the main post of each story, and give me each story in one line with its link and how many channels told it.
- Follow a token (`GET /api/v1/search?q=$XYZ&sort=time`): track the token $XYZ: find what was said about it in the last 7 days (GET https://api.web3aggregator.io/api/v1/search?q=%24XYZ&sort=time&since=<the date 7 days ago>), sum it up with links, then open the stream with "cashtags": ["XYZ"] and tell me about each new post.
- Vet a channel (`GET /api/v1/channels/<username>`): vet the Telegram channel @<username>: take GET https://api.web3aggregator.io/api/v1/channels/<username>, report its score, its flags and reasons and its last 30 days by class, and say whether it is worth following and why.
- Use it in your work (`GET /api/v1/posts?limit=100`): my goals are <your goals>. Read the newest posts of the aggregator (GET https://api.web3aggregator.io/api/v1/posts?limit=100), keep what earns money, has a long-term impact or saves time for these goals, and for each name the step to take, with its link.

## Access and limits

- A key goes in the header `Authorization: Bearer $W3A_API_KEY`. Get it at https://api.web3aggregator.io/keys (log in with Telegram; up to 5 keys). Keep it in an environment variable, never in a prompt, a URL or a web page.
- Without a key: only `GET /api/v1/posts`, `GET /api/v1/search` and the stream, limited by your address — 30 requests a minute, 6 of them searches, 2000 a day (from 00:00 UTC), 1 stream at once; a page holds up to 100 posts as with a key. Searches without a key also share a common budget, and when too many requests without a key run at once the answer is `503` with `Retry-After`; requests with a key never wait for these. Searches with a regex (`re:/…/`) run 2 at a time, with a key or without: another one is a `503` with `Retry-After: 1`. A key that comes must be good: a wrong or revoked key is a `401` (`4401` on the stream), never the keyless tier. Every other route needs a key.
- Limits per key: 120 requests a minute, 30 of them searches, 100000 a day (from 00:00 UTC), 3 streams at once. A minute's quota is a bucket that refills evenly (2.0 requests a second): a burst passes at once, and an empty bucket asks for a second or two, not the rest of the minute.
- Every answer tells what is left, in the IETF RateLimit fields; `/search` adds its `"search"` policy:

```http
RateLimit-Policy: "minute";q=120;w=60, "day";q=100000;w=86400
RateLimit: "minute";r=57;t=41, "day";r=9874;t=36120
```

  `q` — the quota, `w` — its window in seconds, `r` — requests left now, `t` — seconds until the quota is full again.
- Over a limit: `429` with `Retry-After` in seconds — wait that long, then retry; retrying sooner gets `429` again.

## Answers

- JSON (`application/json`, UTF-8), field names in snake_case. Times are RFC 3339 in UTC: `2026-10-21T09:14:05+00:00`. A field without a value is `null`, a list without values is `[]`; every field is always there, unless `fields=` trims a post.
- One thing comes as an object; a list as `{"items": [...], "next_cursor": "..."}`. Pass `next_cursor` back as `cursor` for the next page; `null` — no more. A cursor is opaque: do not build or change it.
- Channels, hot topics and tokens send `ETag` and `Cache-Control: private, max-age=…`: send the ETag back in `If-None-Match` and an unchanged answer is `304` without a body. Other answers are `Cache-Control: no-store`.
- Every answer has `X-Request-ID`; send your own (letters, digits, `.`, `_`, `-`, up to 64) to trace a request, and name it when you report a problem.
- Status codes: `200` OK, `304` not modified, `400` invalid request, `401` no key where one is needed, or a bad one, `404` not found, `405` wrong method, `422` a search's regex too slow, `429` over a limit, `500` our error, `503` busy (without a key, or too many searches with a regex).

## Errors

An error is `application/problem+json` (RFC 9457):

```json
{
  "type": "https://api.web3aggregator.io/docs#invalid-request",
  "title": "Invalid request",
  "status": 400,
  "detail": "limit: at most 100",
  "instance": "/api/v1/posts",
  "request_id": "6f1c2b9e0d8a4f3c9b7e5d1a2c4e6f80",
  "errors": [
    {
      "parameter": "limit",
      "detail": "at most 100"
    }
  ]
}
```

| Field | Type | Meaning |
|---|---|---|
| `type` | string | A link to the error's description in the docs |
| `title` | string | The same for every error of the type |
| `status` | integer | The HTTP status |
| `detail` | string or null, optional | What went wrong this time |
| `instance` | string or null, optional | The path asked |
| `request_id` | string | As in the `X-Request-ID` header |
| `errors` | array of ProblemError or null, optional | Each wrong parameter (400) |

`errors` items:

| Field | Type | Meaning |
|---|---|---|
| `parameter` | string | The query or path parameter |
| `detail` | string | What is wrong with it |

A `401` carries `WWW-Authenticate: Bearer realm="api"`, with `error="invalid_token"` when the key is wrong or revoked (RFC 6750); a `405` — `Allow`; a `429` — `Retry-After`. The types:

### invalid-request

`400` Invalid request: a parameter is missing, of a wrong type or out of range; `errors` names each one. Fix the request; repeating it gives the same answer.

### unauthorized

`401` Missing or invalid API key: a route that needs a key got none, or the key is wrong or revoked (also on the routes open without a key: a key that comes must be good). Get a key at `/keys`; do not retry with the same key.

### not-found

`404` Not found: no such post, channel or route.

### method-not-allowed

`405` Method not allowed: the route exists but not with this method; `Allow` lists the methods it takes.

### rate-limited

`429` Too many requests: a limit of the key (or, without a key, of your address) is spent: `detail` says which. Wait `Retry-After` seconds, then retry.

### query-too-slow

`422` Query too slow: the regex scanned too many posts within API_REGEX_TIMEOUT_MS; add words, a $TOKEN or a shorter since/until.

### busy

`503` Busy: too many requests without a key are being served at once, or too many searches with a regex (`re:/…/`, keys included). Wait `Retry-After` seconds and retry; without a key, a key helps with the first: requests with a key never wait for those.

### internal-error

`500` Internal error: our fault. Retry later; if it stays, report the `request_id`.

## Posts

A post is the same object in every answer and in the stream:

| Field | Type | Meaning |
|---|---|---|
| `id` | integer | Our id of the post: ids grow with the time we saved posts |
| `channel` | PostChannel | The channel or group it came from |
| `message_id` | integer | The message's id in its channel |
| `url` | string or null | The original post on t.me |
| `date` | time (RFC 3339) | When it was posted |
| `edited_at` | time (RFC 3339) or null | Its last edit we saw |
| `text` | string | Its text or caption; empty for bare media |
| `html` | string or null | The text with Telegram's formatting as safe HTML: `b`/`strong`, `i`/`em`, `u`, `s`/`del`, `code`, `pre`, `blockquote`, `br`, http(s) links (`rel` `nofollow ugc`), spoilers as `<span class="spoiler" tabindex="0">`; nothing else, no attributes but these. Null without text |
| `language` | string or null | ISO 639-1 code of its text (`en`, `ru` …) |
| `class` | string | `approved` (fit for the aggregator), `ad`, `exchange_promo`, `off_topic`, `generic_noise`, `low_quality`, `announcement`, `secondary` (retells another channel's post), `duplicate`, `rules` (hard rules, stop words), `unchecked` (no decision: chats, old history); a moderator's own reason may appear too |
| `duplicate_of` | integer or null | Of a `duplicate`: our id of the post it copies (`GET /api/v1/posts/{id}`); null for other classes or when that post is not in the base |
| `secondary_of` | integer or null | Of a `secondary`: our id of the earlier post whose news it retells; null for other classes or when the moderator named none |
| `in_aggregator` | boolean | Its copy is in the aggregator channel now |
| `aggregator_url` | string or null | That copy on t.me |
| `deleted` | boolean | Deleted from its channel; we keep it |
| `deleted_at` | time (RFC 3339) or null | When we saw it deleted from its channel; null while it is there |
| `views` | integer or null | Views when we last read it; null in chats |
| `forwards` | integer or null | Forwards when we last read it; null in chats |
| `sender_id` | integer or null | The author's Telegram id, in chats |
| `cashtags` | array of string | Tokens named as `$XYZ`, upper case, without `$` |
| `hashtags` | array of string | Hashtags in lower case, without `#` |
| `addresses` | array of string | Contract and wallet addresses in the text |
| `domains` | array of string | Domains of its links |
| `media` | array of Media | Its photos, videos and files, in order; with `link_preview` set, only the preview's picture (a message with a preview has no file of its own) |
| `link_preview` | LinkPreview or null | The preview of the link in its text, as Telegram shows it |
| `album` | Album or null | The album it is a part of (each part is a post of its own); null for a single message |
| `topic` | string or null | The classifier's topic of its current decision (`alpha`, `news_general`, `news_projects`, `memecoins`, `prediction`, `trading`, `giveaways`, `nft_ton`, `stats`, `research` …); null without one |

`channel`:

| Field | Type | Meaning |
|---|---|---|
| `id` | integer | Our id of the channel or group |
| `username` | string or null | Its @username without `@`; null for a private one |
| `title` | string or null | Its title in Telegram |
| `kind` | `channel`, `group`, `topic`, `own` | `channel`, `group` (a chat), `topic` (a forum topic) or `own` (ours) |
| `private` | boolean | A private channel or chat (joined by an invite) |

`media` items:

| Field | Type | Meaning |
|---|---|---|
| `type` | `photo`, `video`, `animation`, `document`, `audio`, `voice`, `sticker`, `other` | What it is |
| `url` | string or null | A permanent link to the photo (kept at most 1280 px on the long side) or to a JPEG preview of the video or animation; null for files we do not keep (voice, audio, documents, stickers) or have not kept yet |
| `duration` | number or null | Seconds, for video, audio and voice |
| `width` | integer or null | Pixels of the original, for photos and videos |
| `height` | integer or null | Pixels of the original, for photos and videos |
| `preview` | boolean | `true`: `url` is a still preview picture of a video or an animation, not the file itself (open the post's `url` to play it) |

We keep photos (at most 1280 px on the long side; `width` and `height` are the original's) and a still preview of each video and animation (`preview: true`; the post's `url` plays it); voice, audio, documents and stickers have `url: null`. When `link_preview` is set, `media` holds only the preview's picture, the same as `link_preview.image`: draw `media` only for posts without a link preview.

`link_preview`:

| Field | Type | Meaning |
|---|---|---|
| `url` | string | The page the preview shows (http or https) |
| `site` | string or null | The site's name |
| `title` | string or null | The page's title |
| `description` | string or null | The page's description |
| `image` | LinkPreviewImage or null | The preview's picture |

`link_preview.image`:

| Field | Type | Meaning |
|---|---|---|
| `url` | string | A permanent link to the preview's picture |
| `width` | integer or null | Pixels of the original |
| `height` | integer or null | Pixels of the original |

`album` — each part of an album is a post of its own with the same `album.id`:

| Field | Type | Meaning |
|---|---|---|
| `id` | string | The album's id in its channel (Telegram's `grouped_id`) |
| `size` | integer | Its parts we keep, deleted ones left out (unless all are) |
| `position` | integer | This part's place in it, from 1, by message id |

### GET /api/v1/posts

Posts, newest first, or the new ones after a post. Filters combine with AND; the values of one filter with OR (`cashtag`, `address` and
`domain` together: a post naming any of them). Reaches back `API_FEED_DAYS` (30) days;
older posts — through `/search`. Open without a key.

| Parameter | In | Type | Default | Meaning |
|---|---|---|---|---|
| `scope` | query | `aggregator`, `all` | `"aggregator"` | `aggregator`: what the aggregator published; `all`: every post with its class |
| `channel` | query | array of string | `[]` | Channel ids or usernames (`@` optional); repeat it or separate with commas |
| `class` | query | array of string | `[]` | Post classes, e.g. `approved,ad`; see the post's `class` |
| `cashtag` | query | array of string | `[]` | Tokens, e.g. `BTC` or `$BTC`; repeat or separate with commas |
| `address` | query | array of string | `[]` | Contract or wallet addresses |
| `domain` | query | array of string | `[]` | Domains of links, e.g. `pump.fun` |
| `language` | query | string |  | ISO 639-1 code, e.g. `en` |
| `since` | query | time (RFC 3339) |  | Posts from this time on (ISO 8601, e.g. `2026-10-20`) |
| `until` | query | time (RFC 3339) |  | Posts before this time (ISO 8601) |
| `after` | query | integer |  | Posts saved after this post id, oldest first: catch up from the last |
| `cursor` | query | string |  | `next_cursor` of the page before |
| `limit` | query | integer | `50` | Items a page: 50 by default, at most 100 |
| `fields` | query | string |  | Only these fields of each post (and `id`), e.g. `text,class,url` |

```sh
curl -H "Authorization: Bearer $W3A_API_KEY" "https://api.web3aggregator.io/api/v1/posts?cashtag=ETH&limit=1"
```

`200` — PostPage:

```json
{
  "items": [
    {
      "id": 2918034,
      "channel": {
        "id": 214,
        "username": "alpha_news",
        "title": "Alpha News",
        "kind": "channel",
        "private": false
      },
      "message_id": 18341,
      "url": "https://t.me/alpha_news/18341",
      "date": "2026-10-21T09:14:05+00:00",
      "edited_at": null,
      "text": "$ETH spot ETFs took in $412M yesterday, the biggest day since August. Flows: farside.co.uk/eth",
      "html": "$ETH spot ETFs took in <strong>$412M</strong> yesterday, the biggest day since August. Flows: <a href=\"https://farside.co.uk/eth\" target=\"_blank\" rel=\"noopener noreferrer nofollow ugc\">farside.co.uk/eth</a>",
      "language": "en",
      "class": "approved",
      "duplicate_of": null,
      "secondary_of": null,
      "in_aggregator": true,
      "aggregator_url": "https://t.me/c/2387701915/52310",
      "deleted": false,
      "deleted_at": null,
      "views": 12840,
      "forwards": 37,
      "sender_id": null,
      "cashtags": [
        "ETH"
      ],
      "hashtags": [],
      "addresses": [],
      "domains": [
        "farside.co.uk"
      ],
      "media": [
        {
          "type": "photo",
          "url": "https://hel1.your-objectstorage.com/hmonitors/telegram-aggregator/prod/media/AQADTr0xG2l9eUh-.jpg",
          "duration": null,
          "width": 1280,
          "height": 720,
          "preview": false
        }
      ],
      "link_preview": null,
      "album": {
        "id": "13871526540871123",
        "size": 2,
        "position": 1
      },
      "topic": "news_general"
    }
  ],
  "next_cursor": "YjI5MTgwMzQ"
}
```

Errors: `400` [invalid-request](#invalid-request), `401` [unauthorized](#unauthorized), `429` [rate-limited](#rate-limited), `500` [internal-error](#internal-error), `503` [busy](#busy).

### GET /api/v1/posts/by-link

A post by its t.me link.

| Parameter | In | Type | Default | Meaning |
|---|---|---|---|---|
| `url` (required) | query | string |  | `https://t.me/<channel>/<message id>` or `https://t.me/c/<id>/<message id>`; an old username of the channel works too |
| `fields` | query | string |  | Only these fields of each post (and `id`), e.g. `text,class,url` |

```sh
curl -H "Authorization: Bearer $W3A_API_KEY" "https://api.web3aggregator.io/api/v1/posts/by-link?url=https://t.me/alpha_news/18341"
```

`200` — Post:

```json
{
  "id": 2918034,
  "channel": {
    "id": 214,
    "username": "alpha_news",
    "title": "Alpha News",
    "kind": "channel",
    "private": false
  },
  "message_id": 18341,
  "url": "https://t.me/alpha_news/18341",
  "date": "2026-10-21T09:14:05+00:00",
  "edited_at": null,
  "text": "$ETH spot ETFs took in $412M yesterday, the biggest day since August. Flows: farside.co.uk/eth",
  "html": "$ETH spot ETFs took in <strong>$412M</strong> yesterday, the biggest day since August. Flows: <a href=\"https://farside.co.uk/eth\" target=\"_blank\" rel=\"noopener noreferrer nofollow ugc\">farside.co.uk/eth</a>",
  "language": "en",
  "class": "approved",
  "duplicate_of": null,
  "secondary_of": null,
  "in_aggregator": true,
  "aggregator_url": "https://t.me/c/2387701915/52310",
  "deleted": false,
  "deleted_at": null,
  "views": 12840,
  "forwards": 37,
  "sender_id": null,
  "cashtags": [
    "ETH"
  ],
  "hashtags": [],
  "addresses": [],
  "domains": [
    "farside.co.uk"
  ],
  "media": [
    {
      "type": "photo",
      "url": "https://hel1.your-objectstorage.com/hmonitors/telegram-aggregator/prod/media/AQADTr0xG2l9eUh-.jpg",
      "duration": null,
      "width": 1280,
      "height": 720,
      "preview": false
    }
  ],
  "link_preview": null,
  "album": {
    "id": "13871526540871123",
    "size": 2,
    "position": 1
  },
  "topic": "news_general"
}
```

Errors: `400` [invalid-request](#invalid-request), `401` [unauthorized](#unauthorized), `404` [not-found](#not-found), `429` [rate-limited](#rate-limited), `500` [internal-error](#internal-error).

### GET /api/v1/posts/{post_id}

A post by its id.

| Parameter | In | Type | Default | Meaning |
|---|---|---|---|---|
| `post_id` (required) | path | integer |  | The post's `id` |
| `fields` | query | string |  | Only these fields of each post (and `id`), e.g. `text,class,url` |

```sh
curl -H "Authorization: Bearer $W3A_API_KEY" "https://api.web3aggregator.io/api/v1/posts/2918034"
```

`200` — Post:

```json
{
  "id": 2918034,
  "channel": {
    "id": 214,
    "username": "alpha_news",
    "title": "Alpha News",
    "kind": "channel",
    "private": false
  },
  "message_id": 18341,
  "url": "https://t.me/alpha_news/18341",
  "date": "2026-10-21T09:14:05+00:00",
  "edited_at": null,
  "text": "$ETH spot ETFs took in $412M yesterday, the biggest day since August. Flows: farside.co.uk/eth",
  "html": "$ETH spot ETFs took in <strong>$412M</strong> yesterday, the biggest day since August. Flows: <a href=\"https://farside.co.uk/eth\" target=\"_blank\" rel=\"noopener noreferrer nofollow ugc\">farside.co.uk/eth</a>",
  "language": "en",
  "class": "approved",
  "duplicate_of": null,
  "secondary_of": null,
  "in_aggregator": true,
  "aggregator_url": "https://t.me/c/2387701915/52310",
  "deleted": false,
  "deleted_at": null,
  "views": 12840,
  "forwards": 37,
  "sender_id": null,
  "cashtags": [
    "ETH"
  ],
  "hashtags": [],
  "addresses": [],
  "domains": [
    "farside.co.uk"
  ],
  "media": [
    {
      "type": "photo",
      "url": "https://hel1.your-objectstorage.com/hmonitors/telegram-aggregator/prod/media/AQADTr0xG2l9eUh-.jpg",
      "duration": null,
      "width": 1280,
      "height": 720,
      "preview": false
    }
  ],
  "link_preview": null,
  "album": {
    "id": "13871526540871123",
    "size": 2,
    "position": 1
  },
  "topic": "news_general"
}
```

Errors: `400` [invalid-request](#invalid-request), `401` [unauthorized](#unauthorized), `404` [not-found](#not-found), `429` [rate-limited](#rate-limited), `500` [internal-error](#internal-error).

## Search

The query `q`, its parts ANDed:

- Words, found in any form (Russian and English stemming) in the text and in what we know of its pictures and link previews; `"exact phrase"`; `-word` leaves out posts with it; `or` between two words takes either.
- `$BTC` (the cashtag only, not the word), `#tag`, an address (EVM, Solana, Tron, TON, bech32), a domain or `site:domain` (its subdomains too): `$BTC $ETH` wants both; `-$ETH`, `-#tag`, `-site:x.com` leave them out.
- One regular expression `re:/…/` on the post's text (not on what we know of its pictures and previews), case-insensitive: `скрипт re:/airdrop.?checker/`, `$BTC re:/\yetf\y/`. It is always ANDed with the rest, even after `or`: `btc or re:/x/` is `btc` and the regex. It ends at a `/` followed by a space or the end of `q`; `\/` is a slash inside it. Up to 64 characters, one per query, never negated (`-re:/…/` is a `400`). The dialect is Postgres' ARE: `\y` a word boundary, `\m` and `\M` a word's start and end (`\b` is refused: it is a backspace there), no named groups `(?P<x>…)`, no possessive quantifiers (`a*+`), no backreferences, repeat counts up to 255; a pattern Postgres rejects is a `400` with its message.
- A regex needs something to narrow the posts first: a word, a wanted key ($TOKEN, #tag, address, site:), or a `since` at most 30 days before `until` (or now); minus words, excluded keys and `until` alone do not count (`400`). It has 2000 ms in all: past that the answer is `422` [query-too-slow](#query-too-slow) — add words, a $TOKEN or a shorter since/until. At most 2 searches with a regex run at once, keys included; another one gets `503` [busy](#busy) with `Retry-After: 1`.

### GET /api/v1/search

Full-text search over every post. The whole base: rejected, unchecked, old and deleted posts too, each with its class —
judging them is up to you. Up to `API_EXPORT_MAX` (1000) results a query; a search also
spends the key's search limit (without a key, the address's).

| Parameter | In | Type | Default | Meaning |
|---|---|---|---|---|
| `q` (required) | query | string |  | The query: words, `"exact phrase"`, `-word`, `or`, `$BTC`, `#tag`, an address, `site:domain`, one `re:/regex/` (see Search) |
| `sort` | query | `relevance`, `time` | `"relevance"` | `relevance` (best first) or `time` (newest first) |
| `channel` | query | array of string | `[]` | Channel ids or usernames (`@` optional); repeat it or separate with commas |
| `class` | query | array of string | `[]` | Post classes, e.g. `approved,ad`; see the post's `class` |
| `since` | query | time (RFC 3339) |  | Posts from this time on (ISO 8601, e.g. `2026-10-20`) |
| `until` | query | time (RFC 3339) |  | Posts before this time (ISO 8601) |
| `cursor` | query | string |  | `next_cursor` of the page before |
| `limit` | query | integer | `20` | Items a page: 20 by default, at most 100 |
| `fields` | query | string |  | Only these fields of each post (and `id`), e.g. `text,class,url` |

```sh
curl -H "Authorization: Bearer $W3A_API_KEY" "https://api.web3aggregator.io/api/v1/search?q=%22spot%20ETF%22%20%24ETH&sort=time&limit=1"
```

`200` — SearchPage:

```json
{
  "items": [
    {
      "id": 2918034,
      "channel": {
        "id": 214,
        "username": "alpha_news",
        "title": "Alpha News",
        "kind": "channel",
        "private": false
      },
      "message_id": 18341,
      "url": "https://t.me/alpha_news/18341",
      "date": "2026-10-21T09:14:05+00:00",
      "edited_at": null,
      "text": "$ETH spot ETFs took in $412M yesterday, the biggest day since August. Flows: farside.co.uk/eth",
      "html": "$ETH spot ETFs took in <strong>$412M</strong> yesterday, the biggest day since August. Flows: <a href=\"https://farside.co.uk/eth\" target=\"_blank\" rel=\"noopener noreferrer nofollow ugc\">farside.co.uk/eth</a>",
      "language": "en",
      "class": "approved",
      "duplicate_of": null,
      "secondary_of": null,
      "in_aggregator": true,
      "aggregator_url": "https://t.me/c/2387701915/52310",
      "deleted": false,
      "deleted_at": null,
      "views": 12840,
      "forwards": 37,
      "sender_id": null,
      "cashtags": [
        "ETH"
      ],
      "hashtags": [],
      "addresses": [],
      "domains": [
        "farside.co.uk"
      ],
      "media": [
        {
          "type": "photo",
          "url": "https://hel1.your-objectstorage.com/hmonitors/telegram-aggregator/prod/media/AQADTr0xG2l9eUh-.jpg",
          "duration": null,
          "width": 1280,
          "height": 720,
          "preview": false
        }
      ],
      "link_preview": null,
      "album": {
        "id": "13871526540871123",
        "size": 2,
        "position": 1
      },
      "topic": "news_general",
      "score": 0.42
    }
  ],
  "next_cursor": "bzE"
}
```

Errors: `400` [invalid-request](#invalid-request), `401` [unauthorized](#unauthorized), `422` [query-too-slow](#query-too-slow), `429` [rate-limited](#rate-limited), `500` [internal-error](#internal-error), `503` [busy](#busy).

A search item is a post with `score` (relevance).

## Channels

A channel in a list:

| Field | Type | Meaning |
|---|---|---|
| `id` | integer | Our id of the channel or group: `channel.id` of its posts |
| `username` | string or null | Its @username without `@`; null for a private one |
| `title` | string or null | Its title in Telegram |
| `kind` | `channel`, `group` | `channel`, or `group` (a chat) |
| `active` | boolean | We still read it |
| `subscribers` | integer or null | Subscribers or members on the last count |
| `score` | Score or null | Its latest score; null before the first one |

`score`:

| Field | Type | Meaning |
|---|---|---|
| `value` | integer | 0-100: the channel's worth for the aggregator |
| `benefit` | number | 0-1: the share of its posts worth reading |
| `flags` | array of Flag | The risks it found |
| `reasons` | array of string | How the score was made, line by line, in Russian |
| `basis` | `days`, `candidate` | `days`: the nightly run over its last 90 days; `candidate`: a first look |
| `computed_at` | time (RFC 3339) | When it was made |

`flags` items:

| Field | Type | Meaning |
|---|---|---|
| `key` | string | `commercial` (ads, referral links, funnels), `inactive`, `rare`, `few`, `no_chat`, `owner` … |
| `points` | number | Points it takes off the score |
| `label` | string or null, optional | What it saw, in Russian |
| `parts` | object of number or null, optional | Its parts, each 0-1 |

A channel by itself adds `last_30_days`:

| Field | Type | Meaning |
|---|---|---|
| `posts` | integer | Posts it published |
| `approved` | integer | Of them `approved` |
| `master` | integer | Copied to the aggregator channel |
| `ad` | integer | Of class `ad` |
| `exchange_promo` | integer | Of class `exchange_promo` |
| `off_topic` | integer | Of class `off_topic` |
| `generic_noise` | integer | Of class `generic_noise` |
| `low_quality` | integer | Of class `low_quality` |
| `secondary` | integer | Of class `secondary` |
| `duplicates` | integer | Of class `duplicate` |
| `rules` | integer | Of class `rules` |

### GET /api/v1/channels

Our channels and groups with their scores. By id; scores are made nightly.

| Parameter | In | Type | Default | Meaning |
|---|---|---|---|---|
| `cursor` | query | string |  | `next_cursor` of the page before |
| `limit` | query | integer | `50` | Items a page: 50 by default, at most 100 |

```sh
curl -H "Authorization: Bearer $W3A_API_KEY" "https://api.web3aggregator.io/api/v1/channels?limit=1"
```

`200` — ChannelPage:

```json
{
  "items": [
    {
      "id": 214,
      "username": "alpha_news",
      "title": "Alpha News",
      "kind": "channel",
      "active": true,
      "subscribers": 48210,
      "score": {
        "value": 72,
        "benefit": 0.64,
        "flags": [
          {
            "key": "commercial",
            "points": 6.2,
            "parts": {
              "рефки": 0.12,
              "воронка": 0.0,
              "реклама": 0.09
            }
          },
          {
            "key": "no_chat",
            "points": 0,
            "label": "нет чата комментариев"
          }
        ],
        "reasons": [
          "польза +45 (U 0.64): 210 постов, одобрено 134 (альфа 41, ресёрч 22, новости 63, прочее 8), отклонено 76: 📢 31, 💩 45",
          "− коммерция 6: рефки 12%, реклама 9%"
        ],
        "basis": "days",
        "computed_at": "2026-10-21T03:10:44+00:00"
      }
    }
  ],
  "next_cursor": "YzIxNA"
}
```

Errors: `400` [invalid-request](#invalid-request), `401` [unauthorized](#unauthorized), `429` [rate-limited](#rate-limited), `500` [internal-error](#internal-error). An unchanged answer to `If-None-Match` is `304` without a body.

### GET /api/v1/channels/{ref}

A channel: its score with flags and reasons, 30 days.

| Parameter | In | Type | Default | Meaning |
|---|---|---|---|---|
| `ref` (required) | path | string |  | The channel's id or username (`@` optional) |

```sh
curl -H "Authorization: Bearer $W3A_API_KEY" "https://api.web3aggregator.io/api/v1/channels/alpha_news"
```

`200` — ChannelDetail:

```json
{
  "id": 214,
  "username": "alpha_news",
  "title": "Alpha News",
  "kind": "channel",
  "active": true,
  "subscribers": 48210,
  "score": {
    "value": 72,
    "benefit": 0.64,
    "flags": [
      {
        "key": "commercial",
        "points": 6.2,
        "parts": {
          "рефки": 0.12,
          "воронка": 0.0,
          "реклама": 0.09
        }
      },
      {
        "key": "no_chat",
        "points": 0,
        "label": "нет чата комментариев"
      }
    ],
    "reasons": [
      "польза +45 (U 0.64): 210 постов, одобрено 134 (альфа 41, ресёрч 22, новости 63, прочее 8), отклонено 76: 📢 31, 💩 45",
      "− коммерция 6: рефки 12%, реклама 9%"
    ],
    "basis": "days",
    "computed_at": "2026-10-21T03:10:44+00:00"
  },
  "last_30_days": {
    "posts": 210,
    "approved": 134,
    "master": 98,
    "ad": 31,
    "exchange_promo": 4,
    "off_topic": 9,
    "generic_noise": 18,
    "low_quality": 8,
    "secondary": 3,
    "duplicates": 2,
    "rules": 1
  }
}
```

Errors: `400` [invalid-request](#invalid-request), `401` [unauthorized](#unauthorized), `404` [not-found](#not-found), `429` [rate-limited](#rate-limited), `500` [internal-error](#internal-error). An unchanged answer to `If-None-Match` is `304` without a body.

## Topics and tokens

A hot topic:

| Field | Type | Meaning |
|---|---|---|
| `post` | Post | The story's main post |
| `channels` | integer | Channels that told the story |
| `posts` | array of integer | Ids of its posts |
| `first_post_id` | integer or null | Who told it first |
| `first_at` | time (RFC 3339) or null | When it was told first |

A token:

| Field | Type | Meaning |
|---|---|---|
| `kind` | `cashtag`, `address` | A `$XYZ` cashtag or an address |
| `token` | string | The cashtag without `$`, or the address |
| `channels` | integer | Channels that named it in the period |
| `posts` | integer | Posts that named it in the period |
| `channels_before` | integer | Channels that named it in the period before |
| `growth` | number or null | `channels / channels_before - 1`; null when nobody named it before |

### GET /api/v1/hot

Hot topics: stories told by the most channels. A story: posts of several channels about one event; the most told first.

| Parameter | In | Type | Default | Meaning |
|---|---|---|---|---|
| `hours` | query | integer | `24` | The last hours, 1-72 |

```sh
curl -H "Authorization: Bearer $W3A_API_KEY" "https://api.web3aggregator.io/api/v1/hot?hours=6"
```

`200` — HotTopics:

```json
{
  "items": [
    {
      "post": {
        "id": 2918034,
        "channel": {
          "id": 214,
          "username": "alpha_news",
          "title": "Alpha News",
          "kind": "channel",
          "private": false
        },
        "message_id": 18341,
        "url": "https://t.me/alpha_news/18341",
        "date": "2026-10-21T09:14:05+00:00",
        "edited_at": null,
        "text": "$ETH spot ETFs took in $412M yesterday, the biggest day since August. Flows: farside.co.uk/eth",
        "html": "$ETH spot ETFs took in <strong>$412M</strong> yesterday, the biggest day since August. Flows: <a href=\"https://farside.co.uk/eth\" target=\"_blank\" rel=\"noopener noreferrer nofollow ugc\">farside.co.uk/eth</a>",
        "language": "en",
        "class": "approved",
        "duplicate_of": null,
        "secondary_of": null,
        "in_aggregator": true,
        "aggregator_url": "https://t.me/c/2387701915/52310",
        "deleted": false,
        "deleted_at": null,
        "views": 12840,
        "forwards": 37,
        "sender_id": null,
        "cashtags": [
          "ETH"
        ],
        "hashtags": [],
        "addresses": [],
        "domains": [
          "farside.co.uk"
        ],
        "media": [
          {
            "type": "photo",
            "url": "https://hel1.your-objectstorage.com/hmonitors/telegram-aggregator/prod/media/AQADTr0xG2l9eUh-.jpg",
            "duration": null,
            "width": 1280,
            "height": 720,
            "preview": false
          }
        ],
        "link_preview": null,
        "album": {
          "id": "13871526540871123",
          "size": 2,
          "position": 1
        },
        "topic": "news_general"
      },
      "channels": 9,
      "posts": [
        2918034,
        2918102,
        2918377
      ],
      "first_post_id": 2918011,
      "first_at": "2026-10-21T08:52:31+00:00"
    }
  ]
}
```

Errors: `400` [invalid-request](#invalid-request), `401` [unauthorized](#unauthorized), `429` [rate-limited](#rate-limited), `500` [internal-error](#internal-error). An unchanged answer to `If-None-Match` is `304` without a body.

### GET /api/v1/mentions

Most named tokens of a period, with growth. Cashtags and addresses by the channels that named them.

| Parameter | In | Type | Default | Meaning |
|---|---|---|---|---|
| `period` | query | `hour`, `day`, `week` | `"day"` | The last `hour`, `day` or `week`, against the one before it |

```sh
curl -H "Authorization: Bearer $W3A_API_KEY" "https://api.web3aggregator.io/api/v1/mentions?period=day"
```

`200` — Mentions:

```json
{
  "items": [
    {
      "kind": "cashtag",
      "token": "ETH",
      "channels": 41,
      "posts": 118,
      "channels_before": 29,
      "growth": 0.41
    }
  ]
}
```

Errors: `400` [invalid-request](#invalid-request), `401` [unauthorized](#unauthorized), `429` [rate-limited](#rate-limited), `500` [internal-error](#internal-error). An unchanged answer to `If-None-Match` is `304` without a body.

## Stream

New posts as they come, over WebSocket: `wss://api.web3aggregator.io/api/v1/stream`.

- A program connects with the header `Authorization: Bearer $W3A_API_KEY`. A web page never holds the key: your server takes a ticket (`POST /api/v1/stream/ticket`, below) and the page opens `wss://api.web3aggregator.io/api/v1/stream?ticket=<ticket>` within 30 seconds; a ticket works once.
- Up to 3 streams at a time per key; opening one counts as a request.
- Without a key and a ticket the stream opens by your address: 1 at a time, opening one and each subscription with `resume_after` count as requests of the keyless tier, and a catch-up gives up to 100 posts.
- Messages are JSON text. Your first message is the subscription; send another to change it (up to 30 a minute):

```json
{
  "type": "subscribe",
  "scope": "aggregator",
  "cashtags": [
    "BTC",
    "ETH"
  ],
  "fields": [
    "text",
    "url",
    "class"
  ],
  "resume_after": null
}
```

| Field | Type | Meaning |
|---|---|---|
| `type` | string | `subscribe` |
| `scope` | string, optional | `aggregator`: posts as they are copied to the aggregator; `all`: every decision, with its class |
| `channels` | array of string, optional | Channel ids or usernames |
| `classes` | array of string, optional | Post classes, e.g. `approved` |
| `cashtags` | array of string, optional | Tokens, e.g. `BTC` |
| `addresses` | array of string, optional | Contract or wallet addresses |
| `domains` | array of string, optional | Link domains |
| `language` | string or null, optional | ISO 639-1 code, e.g. `en` |
| `fields` | array of string, optional | Only these fields of each post (and `id`); empty: all |
| `resume_after` | integer or null, optional | The last post id you got: the missed posts come first |
| `events` | array of `forwarded`, `decided`, `edited`, `deleted`, `removed` or null, optional | The events you want; none: `forwarded` (`scope=aggregator`) or `decided` (`scope=all`) as before. `edited`, `deleted` and `removed` come with the post's current state |

- The server sends:
  - `{"type": "subscribed", "scope": "aggregator"}` — the subscription took;
  - `{"type": "post", "event": "forwarded", "post": {...}}` — a post, the object of Posts (only `fields`, when given), as it is now. `event`: `forwarded` — copied to the aggregator (`scope=aggregator`); `decided` — a decision on it with its class (`scope=all`; a post may come again with a new class); `missed` — sent after `resume_after`; and, only when you list them in `events`: `edited` — its text, media or preview changed (or its aggregator copy came back after a failed removal: `in_aggregator` is `true` again); `deleted` — deleted from its channel (`deleted: true`; we keep it); `removed` — its copy was deleted from the aggregator (`in_aggregator: false`). With `scope=aggregator` these come for posts that were copied to the aggregator, with `scope=all` for posts with a class (not `unchecked`). One message per post and event; each album part comes on its own;
  - `{"type": "caught_up", "posts": 12}` — after the missed posts.
- Without `events` a subscription gets `forwarded` (`scope=aggregator`) or `decided` (`scope=all`) only, as before; with it, only the events listed, e.g. `"events": ["forwarded", "edited", "deleted", "removed"]` keeps a live copy of the aggregator in step. A `resume_after` catch-up sends missed posts only, never missed changes: after a drop, read again the posts you keep (`GET /api/v1/posts`).
- Posts without a decision (chats, history loads) come only through `/posts` and `/search`.
- After a drop, connect again and subscribe with `resume_after` = the last post `id` you got: up to 1000 missed posts of the last 30 days come first (without a key, 100).
- Close codes: `4401` a bad or revoked key or ticket; `4400` a bad message (the reason says what); `4429` too many streams, messages or requests without a key; `4408` you read too slowly (over 1000 posts waiting). Reconnect with a growing pause; after `4401` get a new key or ticket.

In Python (`pip install websockets`):

```python
import asyncio, json, os

import websockets

async def main():
    headers = {"Authorization": f"Bearer {os.environ['W3A_API_KEY']}"}
    async with websockets.connect("wss://api.web3aggregator.io/api/v1/stream", additional_headers=headers) as ws:
        await ws.send(
            json.dumps({"type": "subscribe", "cashtags": ["BTC"], "fields": ["text", "url"]})
        )
        async for message in ws:
            print(json.loads(message))

asyncio.run(main())
```

### POST /api/v1/stream/ticket

A one-time ticket for a browser's stream. Your server asks with its key and hands the ticket to the browser, which opens
`wss://…/api/v1/stream?ticket=…` within 30 seconds; the key never reaches the page.

```sh
curl -X POST -H "Authorization: Bearer $W3A_API_KEY" "https://api.web3aggregator.io/api/v1/stream/ticket"
```

`200` — Ticket:

```json
{
  "ticket": "Jt3qX0b1m8cV4yR2s9kP7wZ5nL6dH1fA",
  "expires_in": 30
}
```

Errors: `401` [unauthorized](#unauthorized), `429` [rate-limited](#rate-limited), `500` [internal-error](#internal-error).

## MCP

An MCP server for AI agents, on the same data, key and limits: `https://api.web3aggregator.io/mcp`.

- Protocol: MCP 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 (the older ones by `initialize`), Streamable HTTP. A client posts each message; a stream the client would open with `GET` is `405`, the server sends nothing of its own. Up to 5 sessions of the older revisions per key at once.
- The key goes in `Authorization: Bearer $W3A_API_KEY` on every request: none, a wrong or a revoked key is `401`; MCP switched off for the key on https://api.web3aggregator.io/keys is `403`, and the key's REST keeps working.
- Every tool only reads. A call is one request of the key's limits (a minute and a day); `search_posts` also one of its searches. Over a limit, the call's result is an error that ends with `retry in N s`: wait that long. Protocol messages (discovery, `initialize`, `tools/list`, pings) do not count, but more than 60 a minute get `429`; so do tool calls after 60 a minute the server refused before running (headers that do not match the message).
- Answers are compact JSON, as text and as `structuredContent`, at most 25000 characters. Lists cut a post's text to 500 characters (`text_truncated`) and its cashtags, addresses and domains to 10 (`cashtags_total` …); `get_post` gives the whole post. A post never carries its author's id. `next_cursor` goes back as `cursor`, as in the lists above; a cursor of a tool works in its route and back.
- Hot topics and tokens leave out excluded channels.

| Tool | What it gives | Items a call, at most |
|---|---|---|
| `search_posts` | Full-text search over every post of the base, rejected, old and deleted ones too, each with its class: judge them yourself | 25 |
| `get_post` | One post with its whole text, media and link preview, by our id or its t.me link | 1 |
| `list_posts` | Posts newest first: what the aggregator published, or every post with its class (`scope=all`); the last 30 days | 50 |
| `list_channels` | Our channels and groups by id, each with its score and risk flags (made nightly) | 50 |
| `get_channel` | A channel's card: its score with flags and reasons, and its last 30 days by class | 1 |
| `hot_topics` | Stories told by the most channels in the last hours, the most told first | 20 |
| `token_mentions` | The most named cashtags and addresses of a period by the channels that named them, against the period before | 20 |

Keep the key in the environment variable `W3A_API_KEY` (`read -rs W3A_API_KEY && export W3A_API_KEY`); the configs name the variable, never the key.

Claude Code:

```sh
claude mcp add --transport http --scope user w3a https://api.web3aggregator.io/mcp --header 'Authorization: Bearer ${W3A_API_KEY}'
```

or the project's `.mcp.json`:

```json
{
  "mcpServers": {
    "w3a": {
      "type": "http",
      "url": "https://api.web3aggregator.io/mcp",
      "headers": {
        "Authorization": "Bearer ${W3A_API_KEY}"
      }
    }
  }
}
```

Codex CLI:

```sh
codex mcp add w3a --url https://api.web3aggregator.io/mcp --bearer-token-env-var W3A_API_KEY
```

or `~/.codex/config.toml`:

```toml
[mcp_servers.w3a]
url = "https://api.web3aggregator.io/mcp"
bearer_token_env_var = "W3A_API_KEY"
tool_timeout_sec = 60
```

Check: `claude mcp list` or `codex mcp list` shows `w3a` connected; the key's «Last MCP call» on https://api.web3aggregator.io/keys changes within a minute of a call.
