Skip to content

API

TelegramOS HTTP API: authenticate with an API key and drive accounts, inbox, bots, analytics and workflows programmatically.

Everything the dashboard does, it does over this HTTP API. Every path, body and status code below was exercised against a live workspace before this page was published.

Base URL

https://telegramos.net

Requests and responses are JSON (Content-Type: application/json). Exports return a file (.xlsx / .csv) and media downloads return bytes.

Authenticating

Create a key under Settings → API keys, then send it as the X-API-Key header:

curl -H "X-API-Key: tgp_your_key_here" \
     https://telegramos.net/api/accounts

The key is shown once, at creation. It is stored as a SHA-256 hash, so nobody — including us — can recover it. Lose it and you create a new one.

PropertyValue
HeaderX-API-Key
Formattgp_ followed by 54 URL-safe characters
Default lifetime90 days (expires_days, 1–365)
Keys per workspace20
RevocationInstant, from Settings → API keys

Changing your password, resetting it, disabling 2FA or logging out everywhere revokes every key issued before that moment, permanently.

A browser session uses a cookie instead. The two paths never mix: an X-API-Key request is authenticated purely by that key, and key management (/api-keys) deliberately refuses keys — a key cannot mint or revoke another key (403).

Permissions

The permissions you tick at creation are enforced on every request.

Verbs — one is always required:

PermissionGrants
readGET, HEAD
writePOST, PUT, PATCH, DELETE

Areas — optional. A key that ticks no area reaches every area; a key that ticks one or more is confined to those:

PermissionCovers
accountsAccounts, proxies, warm-up, scraping, niche finder, mass DM, lead packages
inboxConversations, messages, groups, saved replies, auto-reply rules, notifications
botsBots, flows, campaigns, channel syncs, bot database
analyticsAnalytics and AI endpoints
tasksWorkflows and executions

read alone is a read-only key over the whole platform. read + write + inbox can do anything in the inbox only — it gets 403 on /api/accounts.

Request ids

Every response carries an X-Request-ID. Send your own and we use it; send nothing and we generate one:

curl -i -H "X-API-Key: $KEY" -H "X-Request-ID: my-job-42" \
     https://telegramos.net/api/accounts
...
x-request-id: my-job-42

Each request is logged on our side with that id, the path, the status, the duration, the key prefix (never the key) and the workspace:

api.request service=account-service request_id=my-job-42 method=GET
path=/api/accounts status=200 duration_ms=144 auth=key:tgp_t24zIO user=<workspace>

Log the id on your side. While the platform is in beta it is the fastest way for us to find your exact call — quote it in a support ticket and we can pull the whole request path without guessing.

Errors

StatusMeaning
400Malformed request, or a path id that isn't a UUID
401Missing, unknown, expired or revoked key
402Your plan doesn't include this feature, or the wallet can't cover the run
403Valid key, but its permissions don't cover this call
404No such resource, or it belongs to another workspace
409The account can't do this right now (spam-limited, still warming)
422Body failed validation — the response names the field
429Too many requests, or the account hit its hourly/daily send budget
503A dependency is down; retry

Errors carry detail:

{"detail": "This API key is scoped to inbox — not 'accounts'."}

Another workspace's object is 404, never 403 — the API never confirms that an id exists.

Conventions

  • Ids are UUIDs. A non-UUID path segment is 400 or 404, never a 500.
  • Sending is queued, not synchronous. Endpoints that make an account act return 202 with a task_id. The account then sends under the anti-ban pacing rules (see Limits & quotas). Poll the resource to see the result.
  • Everything is workspace-scoped. You never pass a workspace id; the key carries it.
  • List endpoints take limit/offset or page/page_size depending on the resource; both are optional.

Accounts

Area: accounts.

MethodPathDoes
GET/api/accountsList accounts
POST/api/accountsAdd an account
GET/api/accounts/{id}One account
PATCH/api/accounts/{id}Rename, re-tag, change settings
DELETE/api/accounts/{id}Remove an account
POST/api/accounts/importImport a session file
POST/api/accounts/batch-importImport many at once
POST/api/accounts/login/phone/send-codeStart a phone login
POST/api/accounts/login/phone/verify-codeSubmit the code
POST/api/accounts/login/phone/verify-2faSubmit the 2FA password
POST/api/accounts/login/qr/startStart a QR login
POST/api/accounts/login/qr/pollPoll it (long-poll, ~20s)
POST/api/accounts/login/cancelAbandon a login
POST/api/accounts/{id}/powerSleep or wake the account
POST/api/accounts/{id}/safetyLoosen or switch off the anti-ban limits (paid plans)
POST/api/accounts/bulk/safetyThe same, for every account at once
POST/api/accounts/{id}/profileSet name, bio, photo
POST/api/accounts/{id}/clone-profileCopy another account's profile
GET/api/accounts/{id}/privacyPrivacy settings
GET/api/accounts/{id}/photosProfile photos
GET/api/accounts/{id}/statsMessages sent/received today
GET/api/accounts/{id}/health-logHealth score history
GET/api/accounts/{id}/riskCurrent ban-risk read
GET/api/accounts/{id}/connectionsConnection audit log
GET/api/accounts/{id}/sessionsLogin prompts — rented accounts only
GET/api/accounts/{id}/vaultSession-vault state
GET POST/api/accounts/{id}/backfill-historyRead / start an old-chat pull (?discover_only=true = chat list only)
POST/api/accounts/{id}/backfill-history/cancelStop a running pull
POST/api/accounts/{id}/reset-statusMove an error or paused account back to active
GET/api/accounts/{id}/avatarProfile photo, as bytes
DELETE/api/accounts/{id}/photos/{photoId}Delete one profile photo
POST/api/accounts/{id}/username-checkIs a handle free, before you claim it
POST/api/accounts/{id}/contact-policyThe "one person, one account" rule — {"single_contact": false} lets this account message someone another of your accounts already talks to
POST/api/accounts/{id}/group-invitesAdd people to a group — see the note below
POST/api/accounts/{id}/skip-warmupBackdate the warm-up clock — owned accounts any time, rented accounts after 3 days
GET/api/accounts/{id}/session/exportDownload the session — owned accounts only
POST/api/accounts/{id}/sessions/{hash}/decisionApprove or reject a login prompt
POST/api/accounts/bulkActivate / deactivate / delete many at once
POST/api/accounts/bulk/listenerTurn the inbound listener on or off fleet-wide
POST/api/accounts/bulk/contact-policyThe same rule across every account

Every account row carries the account's own Telegram identity once the worker has connected: tg_user_id (numeric Telegram id), tg_username, tg_first_name, tg_last_name. tg_user_id is the value that shows up as peer_id when somebody else's thread points at this account.

The session vault lives under the same account: POST /api/accounts/{id}/vault/enable, /disable, /lock, /unlock, /rotate, with GET /api/accounts/{id}/vault for the current state.

Note /group-invites is the strictest endpoint in the product, because adding people is the fastest way to lose an account. It refuses with 409 unless the account has finished warm-up, holds a clean @SpamBot verdict less than 36 hours old, and is not reply-only.
Note /sessions is for rented accounts. On an account you own it answers 404 no live allocation for this account on your workspace — that is the correct answer, not a fault.

Proxies

MethodPathDoes
GET/api/proxiesList proxies
POST/api/proxiesAdd one
POST/api/proxies/bulkAdd many
PATCH/api/proxies/{id}Edit
POST/api/proxies/{id}/rotateRotate the exit IP
DELETE/api/proxies/{id}Delete
PATCH DELETE/api/proxies/{id}/assignAttach / detach an account
GET/api/proxies/{id}/usageTraffic used
POST/api/proxies/{id}/checkConnectivity test

Warm-up

MethodPathDoes
GET PUT/api/warmup/settingsRead / replace warm-up settings
GET/api/warmup/overviewFleet warm-up state
GET/api/warmup/planThe generated plan
POST/api/warmup/plan/applyApply it
DELETE/api/warmup/planDrop it
POST/api/warmup/dry-runPreview without applying
GET/api/warmup/pairsWhich accounts talk to which
GET/api/warmup/groupsGroups in the plan
GET/api/warmup/eventsWhat warm-up actually did
GET POST/api/warmup/channelsChannels the fleet joins
DELETE/api/warmup/channels/{id}Remove one
GET/api/warmup/accountsPer-account warm-up state
POST/api/warmup/accounts/{id}/socialToggle social warm-up

Anti-ban limits

Every throttle in this API — the cold-outreach and reply budgets, the warm-up ramp, the PeerFlood slow-down, the campaign daily cap, the same-group spacing and the human-like delays — is the platform's own protection, not Telegram's. On a paid plan you can change or switch it off per account with POST /api/accounts/{id}/safety, or for every account at once with POST /api/accounts/bulk/safety:

{
  "mode": "custom",
  "cold_hourly": 30, "cold_daily": 200,
  "reply_hourly": 120, "reply_daily": 600,
  "per_minute": 6, "min_gap_seconds": 3,
  "campaign_daily": 200, "group_spacing_minutes": 30,
  "content_filter": true, "spam_circuit": true,
  "warmup_ramp": false, "human_delay": true,
  "trial_cap": true, "spambot_throttle": true,
  "join_hourly": 40, "join_daily": 60,
  "forward_hourly": 20, "forward_daily": 200,
  "invite_hourly": 4, "invite_daily": 20,
  "report_hourly": 5, "report_daily": 20,
  "massdm_daily": 30, "massdm_delay_min": 45, "massdm_delay_max": 120,
  "massdm_warmup_ramp": true, "massdm_flood_hold": true, "massdm_circuit": true
}

mode is default (platform rules), custom (the numbers you send; anything you leave out keeps its platform value; 0 on per_minute, min_gap_seconds, campaign_daily or group_spacing_minutes means no limit) or off (no platform-side throttling at all). The join_*, forward_*, invite_* and report_* pairs are the per-action budgets; the massdm_* fields govern the mass-DM dispatcher (massdm_daily raises the per-account ceiling a job may ask for, massdm_circuit decides whether a job halts when its accounts come back flooded or blocked; a job stays protected as long as any of its accounts keeps that on). The current profile comes back as safety on the account object, and it takes effect on the next send — no reconnect. Telegram's own FloodWait and spam restrictions still apply whatever you set, and the ban risk of a loosened account is yours. The free plan answers 402. The "one person, one account" rule is separate from this profile and stays on under off; switch it per account with /api/accounts/{id}/contact-policy.

Scraping

MethodPathDoes
POST GET/api/scraping/jobsStart / list jobs
GET/api/scraping/jobs/{id}Status
POST/api/scraping/jobs/{id}/cancelCancel
GET/api/scraping/jobs/{id}/resultsResults as JSON
GET/api/scraping/jobs/{id}/exportResults as .xlsx
GET/api/scraping/jobs/{id}/export.csvResults as CSV
POST/api/scraping/jobs/{id}/webhookPush results to your URL
GET/api/scraping/usageQuota used this period

Scraping is a one-shot extraction, not a monitor: an account walks the target's message history once and pulls out phone numbers (or the patterns you give it). Broadcast channels work as targets, but their posts come from the channel itself, so a channel run rarely yields any numbers. To follow what a channel publishes, use Monitoring a channel instead. The free trial counts results, not messages scanned, so a run that finds nothing costs nothing.

POST /api/scraping/jobs
{
  "name": "leads",
  "group_links": ["https://t.me/somepublicgroup"],
  "limit_per_group": 300,
  "max_accounts": 5,
  "patterns": [],
  "keyword_filter": []
}

patterns empty means phone numbers; the other preset types are email, username, url and price, and a custom pattern takes your own regex. group_links accepts public groups and public channels alike.

A paid deep run is billed per message scanned and returns 402 if the wallet can't cover the worst case. The free trial is counted differently: it is 50 results, so a run that finds nothing costs you nothing. It is bounded on the other side too — free runs stop after 2,000 messages read in total, whatever they returned. GET /api/scraping/usage reports both counters (used / limit and scanned / scan_limit).

Niche finder

MethodPathDoes
POST/api/niche/suggestKeyword suggestions
POST GET/api/niche/jobsStart / list jobs
GET/api/niche/jobs/{id}Status
GET/api/niche/jobs/{id}/sourcesWhich groups it read
POST/api/niche/jobs/{id}/cancelCancel
GET/api/niche/jobs/{id}/resultsDiscovered audience
GET/api/niche/jobs/{id}/exportThe same, as .xlsx
POST /api/niche/jobs
{
  "name": "crypto traders",
  "keywords": ["trading", "signals"],
  "active_within_days": 30,
  "msg_scan_limit": 3000,
  "max_accounts": 5,
  "extract_members": false
}

extract_members dumps raw member lists; it is off by default because it is the ban-risky read.

Lead packages and watches

MethodPathDoes
GET/api/packagesPackages on sale
GET/api/packages/{id}One package
POST/api/packages/{id}/buyBuy it (charges the wallet)
GET/api/packages/{id}/downloadDownload a bought one
POST/api/packages/{id}/publishPut one of yours on sale
DELETE/api/packages/{id}Take it down
GET/api/watches/plansRecurring-scrape plans
GET POST/api/watchesList / create a watch
GET/api/watches/{id}One watch
POST/api/watches/{id}/pausePause
POST/api/watches/{id}/resumeResume
DELETE/api/watches/{id}Delete

Mass DM

MethodPathDoes
GET/api/mass-dm/accountsAccounts eligible to send
POST GET/api/mass-dm/jobsCreate / list campaigns
GET/api/mass-dm/jobs/{id}Progress
GET/api/mass-dm/jobs/{id}/recipientsPer-recipient state
GET/api/mass-dm/jobs/{id}/repliesWho answered
GET/api/mass-dm/jobs/{id}/exportEverything, as .xlsx
GET POST/api/mass-dm/suppressionDo-not-contact list
DELETE/api/mass-dm/suppression/{tgUserId}Remove one entry
POST/api/mass-dm/jobs/{id}/pausePause a running campaign
POST/api/mass-dm/jobs/{id}/resumeResume a paused one
POST/api/mass-dm/jobs/{id}/cancelStop it for good
GET/api/mass-dm/contacts/{tgUserId}What we know about a contact
POST /api/mass-dm/jobs
{
  "recipients": [{"tg_user_id": 123456789, "username": "someone"}],
  "message_text": "hi {first_name}",
  "account_ids": ["<account-uuid>"],
  "per_account_daily_cap": 20,
  "followups": []
}

Supply either recipients or niche_job_id, not both. Suppression takes a list:

POST /api/mass-dm/suppression
{"tg_user_ids": [777000777], "reason": "opted out"}

Inbox

Area: inbox.

MethodPathDoes
GET/api/conversationsList conversations
POST/api/conversationsStart a new chat (202)
GET/api/conversations/searchFull-text search (?q=)
GET/api/conversations/{id}/messagesMessages in a thread
POST/api/conversations/{id}/messagesSend a reply (202)
PATCH/api/conversations/{id}Assign, tag, resolve, mute
POST/api/conversations/{id}/mark-readMark read
DELETE/api/conversations/{id}Remove the thread
POST/api/conversations/mediaUpload an attachment, get a media_key
GET/api/conversations/{id}/messages/{msgId}/mediaDownload an attachment
POST/api/conversations/{id}/messages/{msgId}/fetch-mediaPull a placeholder's media
GET POST/api/conversations/{id}/notesInternal notes
DELETE/api/conversations/{id}/notes/{noteId}Delete a note
POST/api/conversations/{id}/blockBlock the peer
POST/api/conversations/{id}/leaveLeave the group
GET/api/conversations/sla-statsResponse-time stats
GET/api/conversations/account-statsPer-account volumes
GET/api/conversations/activity-seriesMessage volume over time
GET/api/conversations/reply-statsReply rate — needs ?source=

Filters on the list: account_id, peer_type (user / bot / group / channel), status, tags, unread_only, source (campaign / mass_dm), campaign_id, job_id, page, page_size.

reply-stats requires source=campaign&campaign_id=... or source=mass_dm&job_id=...; without them it answers 422/400 naming what's missing.

Monitoring a channel

A broadcast channel is a conversation like any other — peer_type is channel — but its posts are opt-in. A busy channel can push hundreds of megabytes a day through an account's proxy, so the listener drops posts from any channel you have not asked for. That is why a channel you joined shows nothing until you add it: it is not missing support, it is switched off.

Turning one on:

  1. Join the channel with one of your accounts (in Telegram itself).
  2. Add it to the monitored list: POST /api/groups with the body {"identifier": "@thechannel", "type": "channel", "via_account_id": "<uuid>"}.
  3. Read the posts: GET /api/conversations?peer_type=channel, then GET /api/conversations/{id}/messages?after_msg_id=<last seen>.

identifier takes a public @username, a t.me/name link, or a numeric peer id. type may be group, supergroup or channel; whatever you pass is corrected from Telegram on the first sync, along with members_count, posts_per_day and the marked peer id.

Private channels have no @username, and an invite link (t.me/+…) carries no peer id, so neither can identify one. Sync the account's chat list once and the channel appears with its numeric id, which you then add normally:

POST /api/accounts/{account_id}/backfill-history?discover_only=true
   → registers the account's chats, pulls no messages, costs almost no proxy data

GET /api/conversations?peer_type=channel
   → the private channel is now listed, with its peer_id

POST /api/groups
{"identifier": "-1001234567890", "type": "channel", "via_account_id": "<uuid>"}

In the dashboard the same flow is the channel picker in Groups → Track group.

Once tracked, new posts arrive like any other inbound message: on the inbox.new_message websocket event and on a webhook subscribed to it. A channel you have muted in Telegram still delivers both, with conversation.muted: true, so an integration never misses a post; only the dashboard stays quiet.

peer_type has four values: user, bot, group and channel. Supergroups are group — the distinction only matters for /api/groups, which reports supergroup after its first sync.

Once a channel is monitored, its posts arrive like any other message: in GET /api/conversations/{id}/messages, on the inbox.new_message websocket event, and through a webhook subscribed to inbox.new_message.

Two things that are *not* this feature: /api/channel-syncs mirrors posts from one channel into another one you control (it is a forwarder, not a reader), and /api/warmup/channels is the list of channels a new account joins while warming up. Neither returns channel content. POST /api/groups/{id}/sync refreshes member and post counts only — it does not fetch posts.

Following a thread

Inbound messages are only recorded for accounts whose message listener is on. An account with power_sender_only: true in GET /api/accounts sends but never receives, so its threads stay empty however often you poll — turn the listener on for it first.

GET /api/conversations/{id}/messages pages backwards by default (before, before_msg_id, limit up to 200) — that is what a UI wants when you scroll up.

A monitor wants the opposite. Pass after_msg_id with the telegram_msg_id of the newest message you already hold and you get only what arrived since, oldest first:

GET /api/conversations/{id}/messages?after_msg_id=19626585&limit=200

telegram_msg_id rises monotonically inside a chat, so the cursor never repeats a message and never skips one. Keep the highest id you have seen, poll with it, and there is nothing to de-duplicate. after_msg_id overrides before/before_msg_id if you send both.

Each message carries direction: in for messages the account received, out for messages sent through this platform. Messages typed by hand in a Telegram client on the same account are not recorded — Telegram delivers them as your own outgoing updates and the listener ignores them.

For push instead of polling, see Webhooks and Real-time events below.

Sending

Reply into an existing thread:

POST /api/conversations/{id}/messages
{"content": "Thanks — one moment.", "media_key": null}

Start a thread with someone not in the inbox yet:

POST /api/conversations
{
  "account_id": "<account-uuid>",
  "recipient_type": "username",
  "recipient": "someone",
  "content": "hello"
}

recipient_type is username, phone or user_id. Both endpoints answer 202:

{"task_id": "...", "conversation_id": null, "queued": true,
 "note": "Message queued. It will appear in the inbox once Telegram acknowledges the send."}

The message appears in GET /api/conversations/{id}/messages once Telegram acknowledges it — typically seconds, longer if the account is at its hourly pacing cap.

Anti-ban guards answer before anything is queued, so handle them:

  • A spam-limited account cold-starting a chat: Telegram has limited this account, so it can't start new conversations right now — it can still reply to people who messaged it.
  • A warming account cannot initiate, only reply.
  • The content filter rejects the text with 422 and a bracketed code — [REPEATED_CHARS], [EXCESSIVE_CAPS], [TOO_MANY_LINKS], [BLOCKED_LINK], [SPAM_PATTERN]. Fix the text and resend; retrying the same body always fails.
  • The rolling send budget answers 429 with [HOURLY_MESSAGE_LIMIT] or [DAILY_MESSAGE_LIMIT]. Cold outreach is capped at 10 messages per hour and 40 per day per account. Once the other person has written back, every further message in that thread counts as a reply and gets 60 per hour and 300 per day instead — whether you send it through POST /api/conversations/{id}/messages or keep calling POST /api/conversations with the same recipient. The hourly window rolls continuously, so back off rather than rewriting the message. An account whose health score has dropped gets a proportionally smaller budget. Paid plans can change or remove these budgets per account — see Anti-ban limits; a custom profile adds [MINUTE_MESSAGE_LIMIT] and [MESSAGE_GAP_LIMIT] to the possible 429 codes.

Only a 202 means the send is on its way. If it still fails later — the recipient blocked the account, the account dropped offline — the reason arrives as an inbox.message_failed webhook carrying the same task_id.

Groups, replies, rules, notifications

POST /api/groups/join accepts a public username, public or private t.me link, or numeric chat id. Every account id must belong to the workspace and be active; otherwise the whole request is rejected before any task is created. A 202 response contains one task_id per account. Telegram completes the joins asynchronously under the worker's existing join-rate limits; an existing membership is treated as success. Joining has no separate per-call API fee — normal plan limits and proxy traffic still apply.

MethodPathDoes
GET POST/api/groupsTracked groups and channels
POST/api/groups/joinJoin a group with 1–20 active accounts
PATCH DELETE/api/groups/{id}Edit / untrack
POST/api/groups/{id}/syncRefresh member and post counts
GET/api/groups/{id}/statsActivity
GET POST/api/saved-repliesCanned replies
PUT DELETE/api/saved-replies/{id}Edit / delete
GET POST/api/auto-reply-rulesAuto-reply rules
POST/api/auto-reply-rules/bulkCreate many
PATCH DELETE/api/auto-reply-rules/{id}Edit / delete
GET/api/notificationsNotification feed
GET/api/notifications/unread-countBadge count
PATCH/api/notifications/read-allMark all read
GET PUT/api/notifications/preferencesPer-category preferences
PATCH/api/notifications/{id}/readMark one read
DELETE/api/notifications/{id}Dismiss one
GET POST/api/api-connectorsExternal reply connectors
GET/api/api-connectors/kindsConnector types you can create
PATCH DELETE/api/api-connectors/{id}Edit / remove
POST/api/api-connectors/{id}/testTest one
GET/api/conversations/{id}/avatarChat photo, as bytes
GET/api/conversations/{id}/peer-avatarThe other side's photo
POST /api/groups
{"identifier": "https://t.me/somepublicgroup", "type": "group", "via_account_id": "<uuid>"}

POST /api/groups/join
{"identifier": "https://t.me/+private_invite_hash", "account_ids": ["<account-uuid>", "<another-account-uuid>"]}

POST /api/saved-replies
{"title": "greeting", "content": "Hi {first_name}", "variables": ["first_name"]}

POST /api/auto-reply-rules
{
  "account_id": "<account-uuid>",
  "match_type": "contains",
  "match_value": "price",
  "reply_text": "Our pricing is on the site.",
  "is_active": true,
  "delay_min": 2,
  "delay_max": 8
}

match_type is one of any, exact, contains, regex.

Bots

Area: bots.

MethodPathDoes
GET POST/api/botsList / register bots
DELETE/api/bots/{id}Remove
POST/api/bots/{id}/activeEnable / disable
POST/api/bots/{id}/delivery-modePolling or webhook
POST/api/bots/{id}/broadcastBroadcast to its chats
GET/api/bots/{id}/chatsChats it can reach
GET POST/api/bots/{id}/flowsVisual flows
GET/api/bots/flows/templatesStarter templates
POST/api/bots/{id}/flows/templates/{templateId}/instantiateCreate a flow from one
GET PUT DELETE/api/bots/{id}/flows/{flowId}One flow
POST/api/bots/{id}/flows/{flowId}/testDry-run it
GET/api/bots/{id}/runsFlow run history
GET/api/bots/{id}/runs/{runId}/stepsPer-step detail

Campaigns

MethodPathDoes
GET POST/api/campaignsList / create (created in draft)
GET/api/campaigns/{id}One campaign
POST/api/campaigns/{id}/startStart
POST/api/campaigns/{id}/pausePause
POST/api/campaigns/{id}/stopStop
PATCH/api/campaigns/{id}/assignmentsRe-assign accounts and groups
POST/api/campaigns/{id}/import-chatlistBulk-add target groups
GET/api/campaigns/{id}/metricsSends, failures, reach
DELETE/api/campaigns/{id}Delete
POST /api/campaigns
{
  "name": "launch",
  "message_a": {"text": "..."},
  "message_b": {"link": "https://t.me/channel/123"},
  "interval_minutes": 30,
  "delay_min_seconds": 15,
  "delay_max_seconds": 90,
  "assignments": [{"account_id": "<uuid>", "group_chat_ids": [-1001234567890]}]
}

Creating does not start it — call /start.

Channel syncs and the bot database

MethodPathDoes
GET POST/api/channel-syncsChannel-to-channel mirrors — forwards posts, does not read them for you
GET PATCH DELETE/api/channel-syncs/{id}One mirror
POST/api/channel-syncs/{id}/armArm
POST/api/channel-syncs/{id}/disarmDisarm
POST/api/channel-syncs/{id}/runRun once now
GET POST/api/botdb/collectionsCollections
GET PUT DELETE/api/botdb/collections/{id}One collection
GET POST/api/botdb/collections/{id}/recordsRecords
POST/api/botdb/collections/{id}/queryFilter / sort / paginate
PUT DELETE/api/botdb/collections/{id}/records/{recordId}One record
GET POST/api/botdb/collections/{id}/viewsSaved views
POST /api/botdb/collections
{"name": "leads", "schema": {"fields": [{"key": "email", "type": "text"}]}}

POST /api/botdb/collections/{id}/records
{"data": {"email": "a@b.com"}}

Field definitions use key (not name), and a record whose data has an undeclared key is rejected with unknown field(s): ....

Analytics

Area: analytics.

MethodPathDoes
GET/api/analytics/summaryTotals for the period
GET/api/analytics/dailyDay-by-day series
GET/api/analytics/timelineEvent timeline
GET/api/analytics/accounts/{id}/timelineOne account's timeline
GET/api/analytics/velocitySend velocity
GET/api/analytics/anomaliesDetected anomalies
GET/api/analytics/anomalies/{accountId}Anomalies for one account
POST/api/analytics/ai/anomalies/detectRun detection now
GET/api/analytics/ai-insightsNarrative insights over the fleet
GET/api/analytics/reportFull report payload
GET/api/analytics/ai/summaryAI narrative summary
GET/api/analytics/ai/daily-digestDaily digest (seconds, not ms)
POST/api/analytics/ai/suggest-repliesReply suggestions
POST/api/analytics/ai/summarize-conversationSummarise a thread
POST/api/analytics/ai/sentimentSentiment of a thread
POST/api/analytics/ai/send-riskBan-risk score for a draft
POST/api/analytics/ai/compose-messageDraft a message
POST/api/analytics/ai/suggest-postsContent ideas
POST/api/analytics/ai/template-variationsSpin a template
GET/api/analytics/ai/quotaAI credits left

With the workspace analytics switch off these return empty series rather than an error. AI endpoints draw on the AI quota: check /api/analytics/ai/quota first, and expect 429 once it's spent.

Workflows

Area: tasks.

MethodPathDoes
GET POST/api/workflowsList / create
GET PUT DELETE/api/workflows/{id}One workflow
POST/api/workflows/{id}/runRun it now
GET/api/workflows/{id}/statsRun counts and success rate
POST DELETE/api/workflows/{id}/scheduleAttach / remove a cron schedule
GET/api/executionsRun history
POST/api/executionsStart a run
GET/api/executions/{id}One run
GET/api/executions/{id}/stepsPer-node state
DELETE/api/executions/{id}Cancel a running execution
POST /api/workflows
{"name": "nightly", "definition": {"nodes": [], "edges": []}}

Support

No area — any key reaches these.

MethodPathDoes
GET POST/api/support/ticketsList / open a ticket
GET/api/support/tickets/{id}One ticket with its thread
POST/api/support/tickets/{id}/messagesReply
POST/api/support/tickets/{id}/closeClose it
POST/api/support/tickets/{id}/seenMark the thread read
GET/api/support/unread-countBadge count

Identity

MethodPathDoes
GET/auth/meWho this key belongs to, and the plan

Editing the profile (PATCH /auth/me), like key management, needs a browser session — a key answers 403.

Webhooks

A webhook is the durable way to receive events: we write the event to our database first, then POST it to your URL and retry on failure. A socket that was disconnected during your deploy loses the event; a webhook does not.

Figure 8. One webhook delivery: headers, signature, the retry ladder and de-duplication.

Manage them under Settings → API keys → Webhooks, or over the API (area inbox):

MethodPathDoes
GET/api/webhooksList your endpoints
POST/api/webhooksRegister one — the response carries the signing secret
PATCH/api/webhooks/{id}Change the URL, the event list, or turn it off
DELETE/api/webhooks/{id}Remove it
GET/api/webhooks/eventsThe event catalogue
GET/api/webhooks/{id}/secretRead the signing secret back
POST/api/webhooks/{id}/rotate-secretIssue a new secret
POST/api/webhooks/{id}/testQueue a webhook.test delivery (202)
GET/api/webhooks/{id}/deliveriesThe last deliveries, with status and error
POST /api/webhooks
{"url": "https://your-server.example/telegramos",
 "events": ["inbox.new_message"],
 "description": "prod monitor"}

An empty events list means every type. Up to ten endpoints per user; the URL must be a public http(s) address (private and loopback addresses are refused).

Events

TypeFires when
inbox.new_messageA message arrived in, or was sent from, a conversation
inbox.message_failedA queued send did not go through — carries error_code, a human-readable message, the task_id and the recipient
inbox.readA conversation was marked read
account.status_changeAn account changed status (active, paused, error, banned)
account.health_updateAn account's health score changed
account.trust_changedAn account's trust level changed
account.power_changedAn account was put to sleep or woken
account.presenceAn account's worker connected to or dropped off Telegram
notification.newA platform notification was raised
webhook.testOnly from the test button/endpoint

Closing the loop on a send. A send answers 202 with a task_id and nothing else — the account still has to get it past Telegram's pacing. Subscribe to both inbox.new_message and inbox.message_failed and you learn which happened: the success arrives as a message with direction: "out", the failure as inbox.message_failed carrying the same task_id, an error_code (blocked_by_user, privacy_restricted, account_offline, cannot_write, account_banned, …) and a sentence you can show a user.

The request we send

POST /your-path
Content-Type: application/json
X-TGP-Event: inbox.new_message
X-TGP-Delivery: 82a91c75-7446-4ebe-8f79-3013cceaa1d7
X-TGP-Timestamp: 1787308800
X-TGP-Signature: v1=<hex>

{"id": "<delivery id>", "type": "inbox.new_message",
 "created_at": "2026-08-20T10:27:04+00:00",
 "data": {"user_id": "...", "account_id": "...", "conversation_id": "...",
          "message": {"id": "...", "direction": "in", "sender_id": 123, "sender_username": "...",
                      "content": "...", "sent_at": "..."},
          "conversation": {"id": "...", "title": "...", "peer_id": 123,
                           "peer_username": "...", "peer_type": "channel", "muted": false}}}

conversation.id is stable for one account's thread with one peer, whichever way the message goes: an account's outbound message and the reply to it carry the same conversation.id, so that is the key to subscribe on. peer_id is always the other party's Telegram id, never the account's own, and message.direction says who wrote it: out is the account, in is the peer. message.sender_id / message.sender_username spell that out: the peer's Telegram id and handle on in, the account's own on out (null for a group or channel member, whose identity we do not carry). Two of your own accounts talking to each other produce two threads (one per account), each with its own id.

Verifying the signature. HMAC-SHA256 over {timestamp}.{raw body} with your secret, compared to the hex after v1=. Use the raw bytes — re-serializing the JSON changes them.

import hmac, hashlib

def verify(secret: str, headers, raw_body: bytes) -> bool:
    ts = headers["X-TGP-Timestamp"]
    expected = "v1=" + hmac.new(
        secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(headers["X-TGP-Signature"], expected)

The timestamp is inside the signed material, so reject anything older than a few minutes and a captured delivery can't be replayed at you later.

Delivery rules

  • Any 2xx counts as accepted. Answer fast and do the work afterwards — we give up on a request after 10 seconds and treat it as a failure.
  • Failures retry after 10 s, 1 min, 5 min, 30 min and 2 h — six attempts in all — then the delivery is marked failed.
  • Five consecutive events that exhaust every retry switch the endpoint off; the dashboard shows why. Turning it back on clears the counter.
  • Redirects are not followed. Point us at the final URL.
  • The delivery id is stable across retries — key your de-duplication on it.
  • Delivered records are kept 7 days, failed ones 30.

Real-time events

The dashboard streams live updates over Socket.IO:

wss://telegramos.net/ws

Authenticate with a session JWT (auth.token), the session cookie, or an API key — pass it as auth.apiKey in the handshake or as an X-API-Key header. A key needs read, and if it is area-scoped at all it must include inbox.

import { io } from 'socket.io-client'

const socket = io('https://telegramos.net', {
  path: '/ws',
  transports: ['websocket'],
  auth: { apiKey: process.env.TGP_API_KEY },
})
socket.on('inbox.new_message', (e) => console.log(e.conversation_id, e.message.content))

Events arrive as named Socket.IO messages: system.connected on connect, then inbox.new_message, notification.new, account.status_change, account.power_changed and friends; subscribe to one account with subscribe:account.

Two things to know before you build on it:

  • Browsers are restricted. The handshake checks Origin, and only our own domains are allowed. A server-side client sends no Origin and connects fine; a page on your own domain will be refused. Proxy through your backend.
  • A socket delivers once. Anything published while you are disconnected is gone. Use webhooks for delivery you can't afford to miss, or reconcile on reconnect with after_msg_id.

What the API does not cover

  • Teams, billing, wallet and the marketplace are session-only: /wallet, /payments/*, /subscriptions/*, /billing/*, /bundles/purchase, /account-resale/*, /referrals/* and /teams/* answer 401 to an API key however it is scoped. There is no key path to spend money, rent an account or change seats — do those in the dashboard. The two exceptions are the public catalogue reads GET /listings and GET /bundles/availability, which need no authentication at all.
  • No SDK. Plain HTTP; use whatever client you have.
  • No per-key rate limit. Requests are cheap, but the actions behind them are paced per account by the anti-ban limits in Limits & quotas, so a burst of sends queues rather than fires.

A worked example

Reply to the newest unread conversation and wait for the send to land:

KEY="tgp_your_key_here"
BASE="https://telegramos.net"

CONV=$(curl -s -H "X-API-Key: $KEY" \
  "$BASE/api/conversations?unread_only=true&page_size=1" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])")

curl -s -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
     -H "X-Request-ID: reply-$CONV" \
     -d '{"content":"Thanks for reaching out — one moment."}' \
     "$BASE/api/conversations/$CONV/messages"
# -> 202 {"task_id": "...", "queued": true, ...}

curl -s -H "X-API-Key: $KEY" "$BASE/api/conversations/$CONV/messages" | tail -c 400

Monitoring a conversation

Three moving parts, in the order you set them up.

1. Backfill once, if you want the history. POST /api/accounts/{id}/backfill-history queues a one-shot import of existing chats (owned accounts, no media, up to 1000 messages per chat; broadcast channels are included only once they are tracked via POST /api/groups). Poll GET /api/accounts/{id}/backfill-history until state is done — the states are queued, running, done, cancelled, and idle means no job is recorded (either it never started or the status expired after 6 hours). Run it once per account; it is heavy on proxy data and everything after it arrives on its own.

2. Take the live stream. Register a webhook for inbox.new_message. Nothing else is needed for messages to keep flowing into the inbox — the account's worker is already listening.

3. Reconcile. After any outage on your side, walk each conversation forward with after_msg_id from the last id you stored. That closes the gap without re-reading the thread.

KEY="tgp_your_key_here"
BASE="https://telegramos.net"

curl -s -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
     -d '{"url":"https://your-server.example/telegramos","events":["inbox.new_message"]}' \
     "$BASE/api/webhooks"
# -> 201 {"id": "...", "secret": "whsec_...", ...}   <- store the secret

curl -s -H "X-API-Key: $KEY" \
     "$BASE/api/conversations/$CONV/messages?after_msg_id=$LAST_SEEN&limit=200"
Report an issue with this page