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. 1

    Your Telegram channel

    You publish a post as usual: text, or a photo with a caption.

  2. 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. 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. 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

terminal
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.

telegram_to_x.py
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:

output
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 a 409 duplicate_content. The script logs it and moves on, so a channel that repeats the same alert text will see those repeats skipped.

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 appletDIY bridge on the X APIBot + OpenTweet
What you set upAn 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 typesPublic channels where you are an admin.Any channel your bot is in.Any channel your bot is in.
CostThe 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 postX 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.
PhotosA 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.

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.