# Semagram Agent API

Machine-to-machine API for Telegram channels, groups, and bots. Two endpoints: **search** finds entities you cannot name, **fetch** returns the profile details and latest posts of entities you can, read live from Telegram at the moment of the call. Payments are handled via the [x402 protocol](https://www.x402.org/) — no API keys or subscriptions required.

On both, the parameters go in the query string, not in a body. The price is
quoted from the URL — that is all the payment layer sees — so a body would be
priced for one request and answer another.

## Search

```
POST /api/agent/search?q=<query>&limit=<n>&lang=<code>&kind=<type>&min_users=<n>&max_users=<n>
```

| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `q`       | Yes      | —       | Search query (semantic). `@username` alone finds that entity and similar ones |
| `limit`   | No       | 100     | Max number of results (1-1000). A larger value is clamped to 1000, and priced at the clamped figure. |
| `lang`    | No       | —       | Language filter (ISO 639-1 code, e.g. "en", "ru"). Entities whose language could not be detected are kept |
| `kind`    | No       | —       | Entity type filter ("channel", "group", or "bot") |
| `min_users` | No     | —       | Minimum subscriber, member or monthly user count |
| `max_users` | No     | —       | Maximum subscriber, member or monthly user count |

## Fetch

```
POST /api/agent/fetch?usernames=<name1,name2,...>
```

| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `usernames` | Yes    | —       | Telegram usernames, comma-separated. Between 1 and 100; more is refused with 400 and charged nothing. `@name` and `t.me/name` are accepted and normalised. |

One entry comes back per distinct name, in the order asked, so an agent charged
per name can account for every one of them. A name Telegram holds nothing under
— a channel that does not exist, a personal account — comes back as
`{"type": "absent"}` and **is charged for**: the lookup is the product, and a
miss costs the same to answer as a hit.

Everything is read from Telegram when you call. If Telegram does not answer for
a name, that entry comes from our catalog instead and is marked `"stale": true`,
with `fetched_at` saying how old it is.

Posts exist for channels. A group and a bot come back with their profile details
and **no `messages` field at all**, because Telegram publishes no readable history
for either. A channel with no posts answers with an empty array.

## Authentication

This endpoint uses the **x402 payment protocol** (HTTP 402 Payment Required).

1. Send a request without payment headers.
2. The server responds with `402 Payment Required` and a `Payment-Required` header: base64 of a JSON document naming the recipient address, token, amount and networks accepted.
3. Create a payment authorization matching one of them and resend the request with a `Payment-Signature` header. The authorization is valid for 5 minutes.
4. On a successful response (2xx), the payment is settled on-chain. On errors (4xx/5xx), no payment is charged.

Use an x402-compatible HTTP client (e.g., [x402-fetch](https://www.npmjs.com/package/x402-fetch), [x402-reqwest](https://crates.io/crates/x402-reqwest)) to handle the payment flow automatically.

## Pricing

**Search** scales with the requested `limit`:

| Limit     | Price (USDC) |
|-----------|-------------|
| 1-100     | 0.01        |
| 101-200   | 0.02        |
| 201-300   | 0.03        |
| 301-400   | 0.04        |
| 401-500   | 0.05        |
| 501-600   | 0.06        |
| 601-700   | 0.07        |
| 701-800   | 0.08        |
| 801-900   | 0.09        |
| 901-1000  | 0.10        |

Formula: `ceil(limit / 100) * 0.01 USDC`

**Fetch** is linear in the number of names:

| Usernames | Price (USDC) |
|-----------|-------------|
| 1         | 0.01        |
| 2         | 0.02        |
| 10        | 0.10        |
| 100       | 1.00        |

Formula: `names * 0.01 USDC`. Duplicate names are collapsed before pricing, so
asking for the same channel twice is charged once.

## Search response

**Content-Type:** `application/json`

```json
[
  {
    "id": 123456,
    "kind": "channel",
    "username": "example_channel",
    "name": "Example Channel",
    "bio": "Short channel bio",
    "avatar_url": "https://semagram.io/api/avatar/example_channel.jpg",
    "user_count": 15000
  }
]
```

| Field        | Type     | Description |
|-------------|----------|-------------|
| `id`        | number   | Stable identifier |
| `kind`      | string   | `"channel"`, `"group"` or `"bot"` |
| `username`  | string   | Telegram username |
| `name`      | string?  | Display name |
| `bio`       | string?  | Short bio |
| `description` | string? | A bot's full description |
| `lang`      | string?  | Detected language (ISO 639-1), absent when it could not be detected |
| `avatar_url` | string? | Avatar image URL |
| `user_count` | number? | Subscribers, members, or a bot's monthly users |

## Fetch response

**Content-Type:** `application/json`

```json
[
  {
    "type": "channel",
    "username": "example_channel",
    "name": "Example Channel",
    "bio": "Short channel bio",
    "avatar_url": "https://semagram.io/api/avatar/example_channel.jpg",
    "user_count": 15000,
    "fetched_at": "2026-09-28T12:00:00Z",
    "messages": [
      {
        "id": 412,
        "text": "An example post",
        "posted_at": "2026-09-27T11:04:00Z",
        "forwarded_from": "telegram",
        "link": "https://t.me/example_channel/412",
        "view_count": 91024
      }
    ]
  },
  {
    "type": "absent",
    "username": "no_such_name"
  }
]
```

| Field        | Type     | Description |
|-------------|----------|-------------|
| `type`      | string   | `"channel"`, `"group"`, `"bot"`, `"absent"` when Telegram holds nothing under the name, or `"invalid"` when the name cannot be a Telegram username |
| `username`  | string   | The name asked for, lowercased |
| `canonical_username` | string? | Telegram's display spelling, when it differs from `username` |
| `name`      | string?  | Display name |
| `bio`       | string?  | Short bio |
| `description` | string? | Full description. Bots only |
| `lang`      | string?  | Detected language (ISO 639-1) |
| `avatar_url` | string? | Avatar image URL |
| `user_count` | number? | Subscribers for a channel, members for a group. Never present for a bot |
| `mau`       | number?  | Monthly active users. Bots only |
| `commands`  | object?  | A bot's commands, name to description |
| `fetched_at` | string? | When the entry was read from Telegram |
| `stale`     | boolean? | `true` when Telegram did not answer and the entry comes from our catalog |
| `messages`  | array?   | Latest posts, newest first. Present only for a channel, and then always an array |

### Messages

| Field | Type | Description |
|-------|------|-------------|
| `id`   | number | Telegram's post number |
| `text` | string? | The post's text, or the caption of a media post. Absent when the post carried neither |
| `posted_at` | string | When it was posted |
| `forwarded_from` | string \| true? | The origin's username when the origin is public, `true` when it chose to stay hidden, absent for an original post |
| `link` | string | `https://t.me/<username>/<id>` |
| `view_count` | number? | Views |

## Errors

| Status | Endpoint | Meaning |
|--------|----------|---------|
| 400    | search   | Empty or missing `q` parameter, a `kind` that is not `channel`, `group` or `bot`, or `min_users`/`max_users` that is not a non-negative integer |
| 400    | fetch    | No `usernames` given, or more than 100 of them |
| 402    | both     | Payment required (see Authentication) |
| 409    | both     | The payment is already in use: another request carrying it is being answered, or it has already bought an answer. Send a new payment |
| 429    | fetch    | More names in the last minute than one payer may fetch (300 by default). Retry after the `Retry-After` seconds; the same payment may be sent again |
| 502    | both     | Upstream service unavailable |

Error bodies are `{"status": 429, "error": "message"}`. Nothing is charged on a 4xx or a 5xx.

## MCP

The same search and fetch are available as MCP tools at `https://mcp.semagram.io/mcp` (OAuth).

## Example

```bash
# 1. Initial request returns 402 with payment requirements
curl -i -X POST 'https://semagram.io/api/agent/search?q=crypto+news'
curl -i -X POST 'https://semagram.io/api/agent/fetch?usernames=durov,telegram'

# 2. Use an x402 client library to handle payment automatically
```

Using `x402-fetch` (JavaScript):

```javascript
import { wrapFetch } from "x402-fetch";

const fetch402 = wrapFetch(fetch, wallet);

const found = await fetch402("https://semagram.io/api/agent/search?q=crypto+news", { method: "POST" });
const results = await found.json();

const names = results.map((r) => r.username).slice(0, 10).join(",");
const fetched = await fetch402(`https://semagram.io/api/agent/fetch?usernames=${names}`, { method: "POST" });
const entities = await fetched.json();
```

## Links

- [x402 Protocol](https://www.x402.org/)
- [x402 Documentation](https://docs.cdp.coinbase.com/x402/welcome)
