Last updated: September 2026
Auto-post a Telegram channel to X and Bluesky
Add a bot to your channel as an admin, have it long-poll Telegram for channel_post updates, and send each post to POST https://opentweet.io/api/v1/posts with platforms and publish_now. OpenTweet publishes it to X, Bluesky or LinkedIn. You need an ot_ key, not an X developer account.
Below is the whole bot in about 60 lines of Python: photos included, long posts split into threads. Read the links section before you pick a plan, because most channels post links.
7-day free trial. Cancel anytime.
How it works
- 1
Your Telegram channel
You publish a post as usual: text, or a photo with a caption.
- 2
Your bot
Telegram delivers it to your bot as a channel_post update. The script reads the text or caption and the largest photo size.
- 3
OpenTweet API
The script uploads the photo to /api/v1/upload, then sends one POST /api/v1/posts with the text, the photo URL and an explicit platforms list.
- 4
X, Bluesky, LinkedIn
OpenTweet publishes to each network you listed through its official API and returns one result per network.
The bot runs anywhere Python runs: a small VPS, a Raspberry Pi, a container. It only makes outbound requests, so it needs no public URL.
Setup
1. Create the bot
Message @BotFather in Telegram, send /newbot, pick a name and username, and copy the token it gives you. Anyone with that token controls the bot, so keep it out of your code.
2. Add the bot to your channel as an admin
Open the channel, go to Administrators, and add the bot by its username. Telegram apps add bots to channels this way. The bot only reads posts, so you can turn off the rights it does not need.
3. Connect your accounts and get an ot_ key
Sign up, connect X (and Bluesky or LinkedIn if you want them) in Settings, then generate an API key in the API section. It starts with ot_.
4. Set the environment
pip install requests export TELEGRAM_BOT_TOKEN="123456:ABC-your-botfather-token" export TELEGRAM_CHANNEL_ID="" # leave empty on the first run export OPENTWEET_API_KEY="ot_your_key_here"
Run the script once with TELEGRAM_CHANNEL_ID empty and publish a test post in the channel. The script prints the channel id (a negative number) instead of forwarding. Put that value in the variable and restart.
The script
Long polling with getUpdates, filtered to channel_post and to your one channel. It saves the offset to a file so a restart does not repost anything.
import os, requests
BOT = os.environ["TELEGRAM_BOT_TOKEN"]
TG, TG_FILE = f"https://api.telegram.org/bot{BOT}", f"https://api.telegram.org/file/bot{BOT}"
CHANNEL_ID = os.environ.get("TELEGRAM_CHANNEL_ID", "")
OT = "https://opentweet.io/api/v1"
AUTH = {"Authorization": f"Bearer {os.environ['OPENTWEET_API_KEY']}"}
PLATFORMS = ["x", "bluesky"] # add "linkedin" if you want it there too
LIMIT = 260 # under X's 280, room for emoji (2) and links (23)
OFFSET_FILE = "telegram_offset.txt"
def split(text):
parts, cur = [], ""
for word in text.split(" "):
if cur and len(cur) + 1 + len(word) > LIMIT:
parts.append(cur)
cur = word
else:
cur = f"{cur} {word}" if cur else word
parts.append(cur)
return parts[:25] # OpenTweet caps a thread at 25 tweets
def upload_photo(sizes):
biggest = max(sizes, key=lambda p: p["width"])
info = requests.get(f"{TG}/getFile", params={"file_id": biggest["file_id"]}, timeout=30).json()
img = requests.get(f"{TG_FILE}/{info['result']['file_path']}", timeout=60).content
r = requests.post(f"{OT}/upload", headers=AUTH,
files={"file": ("photo.jpg", img, "image/jpeg")}, timeout=120)
r.raise_for_status()
return r.json()["url"]
def forward(msg):
text = (msg.get("text") or msg.get("caption") or "").strip()
if not text:
return # stickers, polls, uncaptioned album photos
parts = split(text)
body = {"text": parts[0], "platforms": PLATFORMS, "publish_now": True}
if len(parts) > 1:
body["is_thread"], body["thread_tweets"] = True, parts[1:]
if msg.get("photo"):
body["media_urls"] = [upload_photo(msg["photo"])]
r = requests.post(f"{OT}/posts", headers=AUTH, json=body, timeout=90)
print(r.status_code, r.text[:300])
offset = int(open(OFFSET_FILE).read()) if os.path.exists(OFFSET_FILE) else 0
while True:
r = requests.post(f"{TG}/getUpdates", json={
"offset": offset, "timeout": 50, "allowed_updates": ["channel_post"]}, timeout=60)
for update in r.json().get("result", []):
offset = update["update_id"] + 1
msg = update.get("channel_post")
if msg and not CHANNEL_ID:
print("channel id:", msg["chat"]["id"], msg["chat"].get("title"))
elif msg and str(msg["chat"]["id"]) == CHANNEL_ID:
try:
forward(msg)
except Exception as e:
print("skipped update", update["update_id"], e)
with open(OFFSET_FILE, "w") as f:
f.write(str(offset))Offset. Telegram treats an update as confirmed once you call getUpdates with an offset higher than its update_id, and it keeps unconfirmed updates for at most 24 hours. So if the bot is down for a day, older posts are gone; if it is down for an hour, it catches up.
allowed_updates. Sending ["channel_post"] means edits (edited_channel_post), group messages and everything else never reach the bot. Telegram remembers the setting between calls.
Text or caption. A text post carries text. A photo post carries photo (an array of sizes) and the words in caption. The script uses whichever is present.
Photos. getFile returns a file_path, and the file downloads from https://api.telegram.org/file/bot<token>/<file_path>. Bots can download files up to 20MB. OpenTweet's /api/v1/upload takes a multipart file field (JPG, PNG, GIF or WebP up to 5MB) and returns a url that goes in media_urls.
Platforms. Always pass the list. A post without platforms follows the account's auto cross-post setting, which may not be what you expect.
A successful publish prints something like this:
201 {"success":true,"count":1,"posts":[{"id":"...","status":"posted",
"url":"https://x.com/i/status/...","platforms":["x","bluesky"],
"results":[{"platform":"x","status":"published","url":"..."},
{"platform":"bluesky","status":"published","url":"..."}]}],
"message":"Post published successfully"}Why the script splits long posts
Telegram posts are often longer than a tweet. X counts 280 characters for accounts without Premium, and it counts them weighted: an emoji is 2 and every link is 23, whatever its real length. When OpenTweet knows your X account's tier, it checks this with X's own counting before it creates the post.
If x is in platforms and the text is over the limit, the whole request fails with 400 validation_failed and nothing publishes on any network. That is why the script never sends an over-limit single post. It splits on spaces into parts of up to 260 plain characters, which leaves room for the weighting, and sends them as a thread with is_thread and thread_tweets. The photo goes on the first tweet.
Prefer a single tweet? Replace the split with a cut at about 250 characters plus "…" and a link back to the Telegram post. That link counts toward your link allowance, below.
Duplicates
OpenTweet refuses a post whose content matches one you already posted or scheduled, with a409 duplicate_content. The script logs it and moves on, so a channel that repeats the same alert text will see those repeats skipped.Links: read this before choosing a plan
News and alert channels usually put a link in every post. OpenTweet meters posts that contain a URL separately from its daily post limit:
- Pro ($11.99): zero link posts a day. URLs are stripped from the text and the post publishes without them. The response carries a
urls_strippedwarning. - Advanced ($29): 10 link posts a day.
- Agency ($49): 20 link posts a day.
- Over the allowance: purchased URL credits cover the extra posts. With no credits left, URLs are stripped.
- Trial: 5 link posts, or 15 if you picked an automation or agent use at signup.
A bare domain like example.com counts as a link too. For a link-heavy news channel, start on Advanced. The daily post limits are separate: 20 a day on Pro, 100 on Advanced and 300 on Agency, with a thread counting as one post.
Hidden links are different. A Telegram hyperlink on a word is a text_link entity, not part of text, so the script drops it. Append entity["url"] from entities or caption_entities if you want it kept.
For comparison, going direct to the X API costs about $0.20 per post that contains a link under X's pay-per-use pricing. See is the X API free in 2026.
The no-code version with n8n
n8n's Telegram Trigger node has a Channel Post event and an option to download attached images. Connect it to an HTTP Request node that sends the same JSON body to https://opentweet.io/api/v1/posts with your ot_ key as a Bearer header, or use the n8n-nodes-opentweet community node.
The trigger works through a webhook, and Telegram's getUpdates does not work while a webhook is set. Use one approach per bot token, and call deleteWebhook before switching back to the script. More in post to X from n8n.
OpenTweet vs IFTTT vs a DIY X API bridge
As of September 2026. IFTTT details are from its Telegram and X service pages; X prices from X's pay-per-use API.
| IFTTT applet | DIY bridge on the X API | Bot + OpenTweet | |
|---|---|---|---|
| What you set up | An applet: "New post in your channel" trigger plus the X "Post a tweet" action. The @IFTTT bot must be an admin of the channel. | Your own bot plus your own X developer app, keys and OAuth tokens. | Your own bot (the script above) plus one ot_ key. No X developer account. |
| Channel types | Public channels where you are an admin. | Any channel your bot is in. | Any channel your bot is in. |
| Cost | The X actions are marked IFTTT Pro. | X pay-per-use: about $0.015 a post and about $0.20 a post with a link. | Flat plan: $11.99, $29 or $49 a month. No per-post fee. |
| Networks per post | X action only in the applet; other networks are separate applets. | X only, unless you write the Bluesky and LinkedIn clients yourself. | X, Bluesky and LinkedIn in one request. |
| Photos | A separate "New photo in your channel" trigger with the "Post a tweet with image" action. | Your code downloads the photo and runs X's media upload. | The script downloads it with getFile and uploads it to /api/v1/upload. |
Frequently asked questions
How do I auto-post a Telegram channel to Twitter (X)?
Create a bot with @BotFather, add it to your channel as an administrator, and run a small script that long-polls getUpdates with allowed_updates set to ["channel_post"]. For each new post, the script sends the text to POST https://opentweet.io/api/v1/posts with platforms ["x"] and publish_now true. You need an OpenTweet ot_ key, not an X developer account.
Does the bot have to be an admin of the channel?
Yes, in practice. Telegram apps add bots to a channel through the Administrators list, and Telegram's bot FAQ says bots receive all messages from channels where they are a member. The bot only reads posts, so you can switch off every admin right you do not need.
What happens to Telegram posts longer than 280 characters?
A Telegram message can be much longer than a tweet. The script splits the text into parts of up to 260 characters and sends them as a thread (is_thread and thread_tweets), capped at 25 tweets. Without that, a single over-limit post sent to X fails validation and nothing publishes on any network.
Will links in my Telegram posts show up on X?
It depends on the plan. Pro allows zero link posts a day and OpenTweet strips the URLs before publishing. Advanced ($29) allows 10 a day and Agency ($49) 20 a day, and purchased URL credits cover posts beyond that. A news channel that links in most posts should start on Advanced.
Can I forward Telegram to Bluesky too?
Yes. Put "bluesky" in the platforms list and the same request posts to both. Bluesky allows 300 characters, so the 260-character parts fit. If one network rejects a post, it is skipped with a reason and the others still publish.
Does it forward photos and albums?
It forwards one photo per post: the script picks the largest size, downloads it with getFile, and uploads it to /api/v1/upload (images up to 5MB). An album arrives as one message per photo and usually only one carries the caption, so the uncaptioned photos are skipped.
Can I use n8n instead of a script?
Yes. n8n's Telegram Trigger node has a Channel Post event. Connect it to an HTTP Request node that POSTs to /api/v1/posts, or to the n8n-nodes-opentweet community node. The trigger registers a webhook, so do not run the polling script on the same bot token at the same time.
Keep exploring
Other sources you can pipe into X, and the API calls underneath.
RSS or blog to X
The same pattern with a feed as the source instead of a channel.
Build an X alert bot
Price, uptime and on-chain alerts posted automatically.
Build an AI news account
Summaries of the day's news, written and posted by a script.
Post to X from Python
The API calls this bot uses, explained one at a time.
Cross-post to Bluesky
How posts land on Bluesky next to X, and what differs.
Your channel, on X and Bluesky
One bot, one ot_ key, one request per post. No X developer account and no per-post fee.
7-day free trial. Cancel anytime.