TGAdsMCP
Sign in

The Telegram Ads API, and where automated buying stops

Telegram Ads has an official HTTP API: promoteapi.telegram.org, 45 methods, 49 object types, and a change history on its own documentation page that begins on 29 January 2024. The key is issued in your Telegram Ads account, in settings, and the documentation then promises that "any requests to the API can be executed". It sets no other condition. Every factual claim below is read off one snapshot of that documentation page, taken on 20 September 2026.

So a bid, a pause, an ad budget, a submission for review and a statistics pull all run without the interface. Topping the account up does not, and that single boundary is why buying stays partly manual. The documentation sits behind the sign-in, which is the mundane reason so much of what is written about this API is wrong: most people writing about Telegram Ads have never read it. We keep this page because our own MCP server works through the same methods.

TGAdsMCP is the MCP server for this API: your AI agent reads the statistics and runs the campaigns itself, with no account scraper. Plans and connecting.

The whole calling convention, and the refusal that arrives with 200 OK
curl -s https://promoteapi.telegram.org/getCurrentAccount \
  -H "Authorization: Bearer $TGADS_TOKEN"

{"ok":true,"result":{"account_id":"...","currency":"EUR"}}

curl -i https://promoteapi.telegram.org/getCurrentAccount

HTTP/1.1 200 OK
{"ok":false,"error":"ACCESS_TOKEN_REQUIRED"}

One trap is visible already: an authorization failure arrives with 200 OK, there is no 401 and no 403 anywhere in the responses, and the branch has to be on the error field in the body. Every response is a JSON object with a Boolean ok: when it is true the payload is in result, when it is false the reason is a string in error. The failure line is ours, measured against the live host on 20 and 23 September 2026; the success line is assembled from the documented shapes, because the documentation carries no response example. A method name cannot be probed this way either, since an invented one returns the same body.

This is not the Bot API. Different host, different key, different objects: the key comes from the advertising account rather than from BotFather, it is scoped to no single bot, and it is not in the path, so there is no /bot<token>/method segment. There are no chats, messages or updates. There is an Account, an Ad, an Audience, a Pixel, a TargetChannel and a TargetBot. The string api.telegram.org occurs twice in 2,458 lines and both times as the tail of promoteapi.telegram.org. Two further Telegram interfaces answer to the same search and are about something else: core.telegram.org/api/sponsored-messages is for someone building a messenger client that has to display sponsored messages, and core.telegram.org/api/revenue is a channel or bot owner's income. An advertiser wants the third, which is this one.

What the API runs instead of a person#

The documentation walks in domain blocks and this table keeps its order. The third column is what the area does not contain, and it is the column that decides where your hands stay.

Seven areas, 45 methods, and the limit of each
AreaMethodsWhat it cannot do
Accounts and their budgets10 Bring money in from outside. Transfers run between accounts that already hold some, and only the main account creates one.
Advertisements10 Pull the budget or delete an ad straight after stopping it: ten minutes of inactivity are required.
Statistics and reports3 An arbitrary window in the monthly report: getAccountReport takes a year and a month.
Targeting and lookups7 List a country's locations without a search string: query is required.
Pixel and conversions7 Change the type of an event once created, or touch events the platform made itself.
Audiences5 Delete an audience an ad is using. Phone numbers load 10000 per call.
Creative uploads3 Do without multipart/form-data, or use anything but JPEG, PNG and MP4.

The names, in the documentation's own order, so that any of them can be searched for. Accounts: createAccount, editAccountInfo, getAccountInfo, getCurrentAccount, getAccountsById, getRelatedAccountsList, increaseAccountBudget, decreaseAccountBudget, getTransactionStatus, getAccountTransactionsList. Statistics: getAccountStats, getAdStats, getAccountReport. Uploads: uploadAdPhoto, uploadAdVideo, uploadWebsitePhoto. Ads: createAd, editAd, editAdStatus, increaseAdBudget, decreaseAdBudget, submitAdForReview, deleteAd, getAdsById, getAdsList, getAdTransactionsList. Audiences: createAudience, editAudience, deleteAudience, getAudiencesById, getAudiencesList. Pixel: createPixel, getPixel, createPixelEvent, editPixelEvent, deletePixelEvent, getPixelEventsById, getPixelEventsList. Targeting: getTargetLanguagesList, getTargetTopicsList, getTargetCountriesList, getTargetLocationsById, getTargetLocationsList, getTargetChannel, getTargetBot.

Pause and resume live in editAd, behind the is_paused flag, and cpm and daily_budget_limit change in the same call. editAdStatus is held deprecated and its whole documented body is one line pointing back at that flag, so a ready-made example from a year-old article elsewhere is worth checking method name by method name. An ad's own state is a status on the Ad object with six values: stopped, ready_for_review, in_review, declined, active and on_hold. A declined ad carries a DeclineReason whose detail comes back as HTML, so whatever renders it has to treat it as untrusted markup.

Statistics come back as an AdStatItem series, for the account or for one ad_id, and run into one rule: the step is exactly 300 or 86400 seconds and the period may not exceed 1000 steps. At the daily step that is 1000 days, at the five-minute one about three and a half days, so a year at five-minute detail is assembled from roughly a hundred requests. The monthly report is the other shape: AccountReportItem, one calendar month, year between 2021 and 2100.

Uploads are the one place the JSON form does not reach. uploadAdPhoto takes JPEG or PNG up to 5 MB, at least 640 px wide, 16:9; uploadAdVideo takes MP4 up to 20 MB, the same width and ratio, 3 to 60 seconds; uploadWebsitePhoto takes JPEG or PNG up to 1 MB and at least 150x150 px; they return an AdPhoto, an AdVideo and a WebsitePhoto. All three take an InputFile and therefore multipart/form-data, and the identifiers they hand back belong to one account: the documentation says in its own words that they cannot be moved to another.

An audience holds up to 1,000,000 phone numbers, and add_phones takes 0-10000 of them per call, raw or as SHA256 hashes. Filling one to its documented ceiling is a hundred calls against an API that publishes no rate limit, which is where a careless import finds out what the undocumented limit is. Inside editAudience the order is fixed: removals run before additions, and reset_phones makes remove_phones ignored. Deleting an audience or an ad answers with True rather than an object.

createPixel returns a code_snippet that goes before the closing head tag on every page of the site. editPixelEvent renames and nothing more, since type is not one of its parameters, and events the platform created itself can be neither edited nor deleted. Pixel event identifiers are Strings, while ads and audiences use Integers.

Three targeting lookups take no parameters at all, which makes languages, topics and countries the natural things to cache: languages come back as IETF tags with names, countries as ISO 3166-1 alpha-2 codes with names, topics as ids with names. getTargetLocationsList is the awkward one, since both country_code and query are required, so there is no documented way to enumerate a country; getTargetLocationsById answers TargetLocation objects for a list of location_ids. Channels and bots resolve by numeric id or by @username, in getTargetChannel and getTargetBot and in the channel_ids and bot_ids fields of createAd and editAd; getTargetChannel takes an optional for_excluding flag and getTargetBot has none. Since 18 September 2026 a numeric identifier works only if the same account has already resolved it through its username, and otherwise the call answers CHANNEL_ID_UNKNOWN or BOT_ID_UNKNOWN, which is why the documentation now recommends usernames. That the earlier lookup is what makes the number usable is our reading and not a sentence in the document, so keep the usernames. channel_id and bot_id are also the exception to the rule that a signed 32-bit integer is safe everywhere: they carry up to 52 significant bits, so they belong in a signed 64-bit integer or a double.

Reading back is uneven. getAdsList takes a limit of 0-100 and defaults to 100, getAccountTransactionsList defaults to 100 too, and getAudiencesList and getPixelEventsList return everything. getAdsById returns targeting only with return_target, the by-id calls take a JSON-serialized list of identifiers such as ad_ids, getTransactionStatus resolves one transaction_id, getAccountInfo returns an AccountInfo and defaults to the current account, and getCurrentAccount takes no parameters, which makes it the cheapest liveness probe for a stored key. createAccount works only from the main account and what it creates is manageable only through it; getRelatedAccountsList lists those accounts, and editAccountInfo is where title, full_name, email, phone_number, country, city and legal_name change.

One call is worth a table of its own, because it is the call that fails. createAd marks five parameters required, title, promote_url, cpm, placement and target, and in practice text is a sixth while placement is not really one of the five. Both of those are documentation defects and both are in the next section.

createAd, the fields that decide whether the call succeeds
FieldLimitWhat it is
title1-128 bytesShown only in the Telegram Ads interface, never to users
text1-160 charactersWhat the audience reads. Required for every targeting type except search
promote_url1-256 charactersChannel, post, bot or website. The ad button opens it
cpmno stated minimumPrice per 1000 impressions in the account currency
placementone of fourchannel_post, bot_banner, search_result, video_banner
targetone of fourchannels, users, bots or search
website_name1-40 charactersRequired when the promoted link points outside Telegram
additional_info0-64 charactersVisible to users only when legal_name is set on the account

Note the units in the first two rows. The title is measured in bytes and the text in characters, in one table, which for an ad written in Cyrillic is a factor of two on one field and not on the other.

The ceilings on a single ad are worth having in one place: up to 8 channel languages, up to 20 topics and up to 100 channels counting included and excluded together, up to 8 countries, or exactly 1 country when locations are used and then up to 20 locations inside it. Impressions to one user per day run 1 to 4 and default to 1. Scheduling reaches at most 365 days ahead, and InputAdSchedule wants a week_hours_mask of exactly seven integers, Monday first, each a 24-bit mask in which bit 0 is 00:00 to 01:00, so each number runs from 0 to 16777215; all seven at zero means no schedule. The timezone is not a free offset but one of forty permitted values in seconds from UTC, from -43200 to 50400, including the thirty-minute and forty-five-minute ones. Passing False instead of the object removes the schedule.

Money: what the API moves and what it never brings in#

Four methods move money inside the account structure: increaseAccountBudget and decreaseAccountBudget between the main account and a related one, increaseAdBudget and decreaseAdBudget between an account budget and an ad budget, plus initial_budget in createAd, which does the second at the moment of creation. Both directions require the source to hold the funds. No method among the 45 brings money in from outside. External money exists here only as a type of transaction peer, external, readable and nothing more, with an optional comment such as an invoice number. Topping up stays a manual operation, and that is where the automation of buying stops.

Account-to-account moves are asynchronous. They return a TransactionStatus of in_progress, failed or completed, with an optional error such as NOT_ENOUGH_BUDGET, and you learn which by asking getTransactionStatus again, because nothing calls you back.

An account is denominated in one of three currencies at creation and never changes: euros as EUR, Grams as TON, Telegram Stars as XTR. On the second the display name and the code disagree by design, so read the code and print whatever your users call it. Every monetary field is in that currency, and the currency comes back on the Account object and on every object that carries money. Each currency also has two precisions and they are not interchangeable: EUR and TON round cpm and budgets to 2 places and transaction amounts to 5, XTR to 0 and 3. Rounding a CPM at the amount precision is a real bug, and the documentation is explicit that values round to the cpm-and-budget one.

The price of a creative in impressions is a number the documentation gives itself, and it is worth knowing before the first bid. An ad with a photo needs on average 50-80% more CPM to be shown, one with a video 70-100% more, and the promoted channel's or bot's picture (show_userpic) 30%. A creative decision is a bid decision. photo_id and video_id are mutually exclusive, apply only to the channels and users targeting types, and passing either to editAd removes whatever was set before. The minimum CPM cannot be read out programmatically at all: no field, no method and no error text carries it, while cpm is required in createAd.

The key: where it comes from and what it opens#

The key is issued at ads.telegram.org/account/api, which opens once you are signed in to the advertising account. The documentation never names that address; it is our observation.

Every call is HTTPS to https://promoteapi.telegram.org/METHOD_NAME, method names are case-insensitive, every query is UTF-8, and both GET and POST work for every method. The key rides on each request either in the header Authorization: Bearer <token> or as an access_token parameter.

The documentation treats the two as equivalent and mentions access_token exactly once, in passing. The leak comes from one of the four forms a parameter can take, the URL query string, which lands in proxy logs, in shell history and in a Referer. The other three are request bodies and nothing is visible there. The reason to pick the header is that with it the choice of body form stops mattering at all.

The cost of a leak is higher than it looks. A leaked key is a leaked ad account: its statistics, its ads, its bids and its budget. It does not reach a neighbouring account - that one needs its own key, and getRelatedAccountsList answers MAIN_ACCOUNT_REQUIRED on an ordinary account. Revocation in your Telegram Ads settings is instant, the old key dies at once and a new one takes its place, and there is no interval in which both work. That is an observation from the interface; revocation is not in the documentation at all.

What connects with that key

Where the documentation is easy to read wrong#

Seven places, and each costs an afternoon if a validator meets it first. Of them, 2 are contradictions, where the document states two incompatible things about one field. The rest are places where it says the useful thing somewhere other than the column a reader checks.

An audience title is measured in two units. createAudience gives title as 1-64 characters and editAudience gives the same field as 1-64 bytes; the same pair holds for createPixelEvent and editPixelEvent. For Cyrillic those readings differ by a factor of two, since 64 Cyrillic characters take up to 128 bytes in UTF-8, so a name accepted at creation can fail on rename. Budget for the stricter reading. An ad title does not have this problem: it is bytes in both methods.

Four plus four is not ten. In InputAdTargetUsers, audience_ids is 0-4 entries and exclude_audience_ids is 0-4, and both rows then say the two together must not exceed 10. The prose above the table repeats the 10. Eight is the most the per-field caps allow, so the stated ceiling is unreachable and one of the two numbers is wrong. Which one, the document does not let us say.

text and placement are each marked the opposite of what they are. In createAd, text is Optional in the Required column while the description beside it requires it for every targeting type except search. placement is the reverse: Yes in the column, currently optional in the description, inferred from target.type when it is missing, and the changelog entry that introduced it warns that omitting it may cause errors in future versions. Send both explicitly.

Nineteen event types go in and twenty come out. createPixelEvent accepts type from 19 values and the PixelEvent object documents 20. The extra one is exactly landing_view, the event the platform creates itself. A validator built from the createPixelEvent list rejects a correct response; build it from PixelEvent.

Three ways to pass a parameter, or four. The section on passing parameters opens by saying three and then lists four, the fourth being multipart/form-data. The preamble to the method list repeats the sentence and this time really does give three, dropping multipart/form-data altogether, although InputFile requires it. There are four, and lists of identifiers are JSON-serialized even inside the query-string form.

Paging follows different rules from method to method, and the types disagree. Seven methods take an offset. On five it is an Integer; on getAdsList and getTargetLocationsList it is a String. Only four of the seven lists return a next_offset at all: AdList, AccountTransactionsList, AdTransactionsList and the locations list. The other three hand you nothing to continue with, which is presumably why they return every row by default. Then the sharp edge: next_offset is typed String in all four, and on getAccountTransactionsList and getAdTransactionsList the parameter it feeds back into is typed Integer. A strictly typed or generated client refuses the very value the API just handed it, on exactly the two methods a billing reconciliation walks. Keep the value opaque and send it back unchanged.

What cannot be changed afterwards is stated by omission. editAd documents 21 parameters and neither placement nor target is among them, which read plainly means an ad's placement and its whole targeting are fixed at creation, and changing either is creating a new ad. conversion_event_id and additional_info say in their own rows that they cannot be changed once set. The first of those is an argument from absence rather than a sentence in the document, so verify it against your own account before building a loop that rewrites targeting in place.

What the documentation leaves out, and what it costs you#

  • Rate limits, quotas and the code 429. The strings rate limit, quota, throttl and 429 occur zero times in 2,458 lines, and there is no Retry-After. Retries, backoff and a queue are yours to design and to find the real ceiling of by experiment. Anything that gives you a number for this is not quoting Telegram.
  • A catalogue of errors. 6 distinct error strings appear in the whole document: AD_TITLE_REQUIRED, NOT_ENOUGH_BUDGET, CHANNEL_ID_UNKNOWN, BOT_ID_UNKNOWN, IDEMPOTENT_PARAM_MISMATCH and IDEMPOTENT_REQUEST_IN_PROGRESS, plus the two key errors above, which are documented nowhere. There is no full list of error values, so every client needs a branch for a string it has never seen.
  • Anything to copy and paste. One request is shown, as a bare URL. There is no response example, an opening brace does not occur once in the whole capture, and there is no curl invocation, no SDK and no OpenAPI or Swagger file. The client is written by hand, from tables.
  • Callbacks and a test mode. Neither exists: state is learned by asking. A rehearsal is still possible, and this is our reading of two of the document's own sentences. initial_budget defaults to 0, and an ad goes for review by itself only while its budget is not empty, so an ad created with zero budget is created and read back without going anywhere. submitAdForReview covers the case where that automatic submission does not happen, and is_paused covers the rest.
  • Versioning. No version segment in the URL, no header, no deprecation policy. Backward compatibility is invoked three times and twice with a warning that behaviour may change, but there is no timetable and no definition of a breaking change: the 18 September 2026 entry shipped one with no transition window. The change history, 29 January 2024 to 18 September 2026, is the only signal of change on offer.

A repeated request does not create a second ad if you send a key: the header Idempotency-Key: <key>, up to 128 characters, generated by you, and the documentation asks for v4 UUIDs or other high-entropy strings. The same value goes as an idempotency_key parameter if you prefer. The first response body is saved and replayed for 24 hours, and a replay is marked Idempotent-Replayed: true. Nothing is saved unless the method actually started executing, so a request that failed validation or collided with a concurrent one is safe to repeat as it stands; reusing a key with different parameters answers IDEMPOTENT_PARAM_MISMATCH, and repeating while the first is still in flight answers IDEMPOTENT_REQUEST_IN_PROGRESS. 9 methods of the 45 accept the key: every create, which is createAccount, createAd, createAudience, createPixel and createPixelEvent, and all four budget moves. editAd is not among them, although it is what changes the bid and the daily limit, and neither is deleteAd nor submitAdForReview. A retry policy that assumes anything touching money can be replayed safely is false on the document's own text, and it is false about the calls that change what you pay per thousand impressions. Guard those with your own state.

These methods, from your AI agent#

TGAdsMCP is the Telegram Ads MCP server. It connects to your AI client with one address, and the agent then works through these same methods: create an ad, edit a bid, pause it, read the stats back over a period the API accepts. It runs on the documented API, so no account scraper is involved - and the traps above are handled inside rather than left for you to meet.

Two other ways exist, and they are worth naming. The first is an MCP server that drives the web interface with your login cookies - Free-cat/telegram_ads_mcp, zai-one/telegram-ads-mcp, NikitaZhidkov/tg-ads-mcp on GitHub. What that costs is stated in their own documentation rather than ours: the saved session file "is equivalent to your login credentials for ads.telegram.org - anyone with it can act as you, including spending your TON ad budget"; the cookies are "the equivalent of an unscoped login", to be treated "like passwords" and never pasted into a chat. We take an API key instead, because it is the opposite of unscoped: it opens the one ad account it was issued in, it carries no path to your messages or your Telegram login, and you revoke it yourself in one click.

The second is to build it yourself, and there is a good starting point: telead on PyPI covers every method in this documentation, with idempotency keys, retries and pagination already written. What it is not is a server. Your AI client connects to an address, not to a pip package, so the work that remains is the work that does not end - writing the MCP layer, hosting it, keeping it awake while you are not at the machine, and updating your own code each time the API moves. In exchange you keep the key on your own disk, which is a real difference and worth weighing: with us it is held on our side, and what that means is written out rather than implied.

You do not drive 45 methods one at a time. Resolving a username, checking a budget, creating, funding, submitting, then reading the stats back is one request in a conversation, not six calls in an order you have to remember.

Every change carries what it is about to change and the current values, so your AI client shows them before it runs. Creating an ad and moving budget go out so that a double press cannot create a second ad, and a daily spend ceiling is set on the ad - a new one has none until you set it. Accounts answer in one conversation: a GRAM account gives a key to anyone with access to it, while a euro account has no Ads API section and sits under an agency's key.

And one thing no client library of your own will cover: Telegram changes this API without warning, as the entry of 18 September 2026 already showed, with no transition window. We follow the changes; what stays in your AI client is the same one address.

Questions and answers#

Does Telegram Ads have an official API?#

Yes. It answers at promoteapi.telegram.org, it documents 45 methods and 49 object types, and the change history on its own page begins on 29 January 2024. The documentation sits behind the advertising account sign-in: by our measurement an anonymous request to it returns a redirect to the login.

What are the rate limits?#

The documentation names none: the strings rate limit, quota, throttl and 429 occur zero times in it. That does not mean there are none. You find them by experiment and set the pause between retries yourself.

How many methods does the Telegram Ads API have?#

45, on the 20 September 2026 capture of the documentation: 10 for accounts, 10 for ads, 7 for the pixel, 7 for targeting, 5 for audiences, 3 for creative uploads and 3 for statistics. One of them, editAdStatus, is marked deprecated.

How do I get a Telegram Ads API key?#

At ads.telegram.org/account/api, once you are signed in to Telegram Ads. The documentation never names that address - finding it there is our own observation - and it sets no other condition, which it says in as many words. Revocation is instant and there is no overlap window, so the old key stops the moment a new one is issued.

Can the API top up an advertising account?#

No. None of the 45 methods brings money in from outside. The API moves budget between accounts and ads inside one account structure, and topping up stays a manual operation.

One address connects it: you paste it into your AI client's settings and sign in with Telegram. There is nothing to install.

Start free