Twitter to Discord Webhook: Send Tweets to a Channel (2026)
A Twitter to Discord webhook relay in Node that checks Weir's signature, fits Discord's embed limits and waits out 429s, plus a cron poller as plan B.
Tarun Khatri
Published · Updated · 7 min read
A Twitter to Discord webhook takes new posts from an X account and sends each one to your channel through Discord's execute-webhook URL.
You get two routes in plain Node for putting an X (Twitter) account's posts in a channel, both tested against a fake Discord. We run Weir, the API they read from.
Once you have a Discord webhook URL, this posts a test message:
curl -H "Content-Type: application/json" -d '{"content": "Hello from my X feed"}' "$DISCORD_WEBHOOK_URL"
- Create a Discord webhookCopy its URL and test it.
- Shape posts into embedsTrim them to Discord's limits.
- Send and handle 429sWait retry_after seconds, then retry.
- Route 1: relay a monitorCheck Weir's signature, then post.
- Route 2: poll by idCron runs a search with sinceId.
Which Twitter to Discord webhook route fits you?
Here is what each option needs, from each vendor's own page on 22 October 2026:
| Route | You need | Price, as listed | The catch |
|---|---|---|---|
| IFTTT applet | IFTTT Pro | $2.99 a month, billed yearly | Checks every 5 minutes |
| twitter2discord on PyPI | X API keys | $0.005 per post read | Last release 2020 |
| Weir, this guide | A server or cron | 1 credit a call, 1 per post | Minutes or hours, not seconds |
| X Activity API | X developer credits | $0.005 per post | Real time, more setup |
Our pick: IFTTT if you won't code. Otherwise use a Weir monitor for daily or hourly posts, or the poller for minutes.
Step 1: Create a Discord webhook
In your server, open Server Settings, then Integrations, and create a webhook for the channel. Copy its URL into DISCORD_WEBHOOK_URL and run the curl above.
Discord's docs say webhooks need no bot or login, so keep the URL out of public code.
Step 2: Shape each tweet into a Discord embed
Discord refuses a message that breaks these limits with a 400:
This function trims each part, so five embeds always fit in one message:
export function toEmbed(post) {
return {
author: { name: `${post.author.name} (@${post.author.username})`.slice(0, 100) },
description: post.text.slice(0, 1000),
url: post.url,
timestamp: post.createdAt,
color: 0x1d9bf0,
footer: { text: `${post.likeCount ?? "?"} likes, ${post.retweetCount ?? "?"} reposts` },
};
}
We searched @discord's posts through Weir on 22 October 2026, with limit: 3, for 4 credits. Back came a repost, a reply with a GIF and one original post. Only the last belongs in a feed. Its embed:
{
"author": { "name": "Discord (@discord)" },
"description": "link your account and you can show off your ZZZ game status ...",
"url": "https://x.com/discord/status/2112946071077003446",
"timestamp": "2026-10-21T16:36:14.000Z",
"color": 1940464,
"footer": { "text": "2381 likes, 99 reposts" }
}
The repost had likeCount: 0, with its real counts under retweetedPost.
Step 3: Send tweets to Discord and handle 429s
Discord says not to hard-code rate limits. So after a 429, this waits the retry_after seconds Discord sends.
const sleep = (seconds) => new Promise((done) => setTimeout(done, seconds * 1000));
export async function send(body, url = process.env.DISCORD_WEBHOOK_URL) {
for (let attempt = 0; attempt < 5; attempt++) {
const init = { method: "POST", headers: { "Content-Type": "application/json" } };
const r = await fetch(url, { ...init, body: JSON.stringify(body) });
if (r.status === 429) {
await sleep((await r.json()).retry_after ?? 1); // seconds, per Discord
continue;
}
if (!r.ok) throw new Error(`Discord answered ${r.status}: ${await r.text()}`);
return;
}
throw new Error("Still rate limited after 5 tries");
}
relay drops reposts and replies, sends five posts per message, and blocks pings with allowed_mentions:
const PER_MESSAGE = 5; // 5 embeds of up to ~1,150 characters stay under 6,000
export async function relay(posts) {
const own = posts.filter((p) => !p.retweetedPost && !p.inReplyToId); // no reposts or replies
own.sort((a, b) => (BigInt(a.id) < BigInt(b.id) ? -1 : 1)); // oldest first
for (let i = 0; i < own.length; i += PER_MESSAGE) {
const embeds = own.slice(i, i + PER_MESSAGE).map(toEmbed);
await send({ username: "X feed", allowed_mentions: { parse: [] }, embeds });
}
}
Save the three pieces as discord.mjs. Node 18 or later needs no packages.
Step 4: Route 1, relay a Weir monitor's webhook
A Weir monitor checks an account on a schedule and sends only new posts, signed. Register your relay's public URL with POST /v1/webhooks, and save the secret it shows once.
Then create the monitor:
curl -s https://api.weirapp.io/v1/monitors -H "Authorization: Bearer $WEIR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "discord", "schedule": "daily", "target": {"kind": "x_user", "handle": "discord"}}'
Its first run reports recent posts. Run it once with POST /v1/monitors/{id}/run. When /runs shows it finished, attach the webhookId with PATCH, so the backlog skips your channel.
The relay checks the signature on the raw body, as Weir's webhook docs describe:
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { relay } from "./discord.mjs";
const SECRET = process.env.WEIR_WEBHOOK_SECRET;
const seen = new Set(); // event ids; use a database in production
function verify(raw, headers) {
const ts = headers["weir-timestamp"] ?? "";
const sig = headers["weir-signature"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const signed = `${ts}.${headers["weir-event-id"] ?? ""}.`;
const expected = createHmac("sha256", SECRET).update(signed).update(raw).digest("hex");
return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
It answers 200 before posting, because Weir waits only 10 seconds:
createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const raw = Buffer.concat(chunks); // the exact bytes Weir signed
if (req.method !== "POST" || !verify(raw, req.headers)) return res.writeHead(400).end();
res.writeHead(200).end(); // answer first, then do the work
const event = JSON.parse(raw);
if (seen.has(event.id)) return; // deliveries are at least once
seen.add(event.id);
relay(event.data.results ?? []).catch(console.error); // webhook.test has no results
});
}).listen(process.env.PORT ?? 8080);
Save it as relay.mjs. POST /v1/webhooks/{id}/test sends a signed test delivery, free.
Step 5: Route 2, poll X and keep the last seen id
No server? Cron can search every few minutes. sinceId returns only posts newer than the saved id, and -filter:nativeretweets leaves reposts out.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { relay } from "./discord.mjs";
const API = "https://api.weirapp.io/v1/x/search";
const since = existsSync("last_id.txt") ? readFileSync("last_id.txt", "utf8").trim() : undefined;
const body = { fromUsers: ["discord"], query: "-filter:nativeretweets", sinceId: since };
const init = { method: "POST", headers: { "Content-Type": "application/json" } };
init.headers.Authorization = `Bearer ${process.env.WEIR_API_KEY}`;
The loop stops on a null cursor or after three pages:
const posts = [];
for (let page = 0; page < 3; page++) {
const r = await fetch(API, { ...init, body: JSON.stringify(body) });
if (!r.ok) throw new Error(`Weir answered ${r.status}`); // 402: top up, don't retry
const { data, meta } = await r.json();
posts.push(...data);
if (!meta.nextCursor) break; // null is the only stop
body.cursor = meta.nextCursor;
}
if (since) await relay(posts); // the first run only saves an id
const newest = posts.map((p) => BigInt(p.id)).reduce((a, b) => (b > a ? b : a), 0n);
if (newest) writeFileSync("last_id.txt", String(newest));
Save both parts as poll.mjs and run it from cron every 15 minutes, with your keys set in the crontab.
What our local test showed
We ran both routes on 22 October 2026 against a fake Discord that enforced the limits above. Deliveries used Weir's documented shape and signature, with the real @discord posts inside.
- A signed delivery got
200in 84 ms. The original post arrived after one429and a retry. - A repeat, a wrong secret, an old timestamp or a re-serialized body posted nothing. Only the repeat got
200. - Twelve long posts became messages of 5, 5 and 2 embeds, the largest at 5,610 characters.
- The poller saved an id, sent one new post next run, and stopped at three pages on an endless cursor.
How much does a Twitter feed in Discord cost?
Each monitor run or search call costs 1 credit, plus 1 per new post. A poll that finds posts usually adds one empty call to reach the null cursor. For an account posting 10 times a day:
Weir's 5,000 free monthly credits cover the first three. Hourly monitors need any $5 pack. A monitor also bills the reposts it finds, even though the relay skips them.
If it doesn't work
- Discord answers
400: an embed broke a limit. Read the error body. - A
401or404from Discord: the webhook URL is wrong or deleted. Fix it rather than retry: Discord temporarily blocks IPs after 10,000 invalid requests in 10 minutes. - Your relay answers every delivery with
400: you're hashing parsed JSON. Hash the raw body.
When not to use Weir
- Seconds matter. X's Activity API pushes posts in real time.
- You want to post to X from Discord. Weir only reads public X data.
- Code isn't for you. IFTTT or our n8n guide fit better.
- Slack matters too. Our account monitor guide covers both, in Python.
FAQ
How do I send tweets to Discord without code?
Use an IFTTT applet. Its X triggers, like "New tweet by a specific user," need Pro: $2.99 a month billed yearly, as of October 2026. Pro checks every 5 minutes.
Is there a free X to Discord bot?
None we'd rely on. The open-source bots we checked need X API keys or were archived, like discord-twitter-webhooks. Weir's 5,000 free monthly credits cover a 15-minute poll.
Can I send the same tweets to a sheet or Telegram?
Yes, with the same search and saved id. See how to import tweets into Google Sheets or the Twitter to Telegram bot.
Sources
All checked on 22 October 2026:
- Discord developer docs, webhooks, embed limits and rate limits: https://docs.discord.com/developers/resources/webhook, https://docs.discord.com/developers/resources/message and https://docs.discord.com/developers/topics/rate-limits
- IFTTT X service, plans and Twitter Discord bot: https://ifttt.com/twitter, https://ifttt.com/plans and https://ifttt.com/explore/twitter-discord-bot
- twitter2discord on piwheels (0.1.6, December 2020): https://piwheels.org/project/twitter2discord/
- discord-twitter-webhooks (archived 7 July 2025): https://github.com/thelovinator1/discord-twitter-webhooks
- X API pricing and Activity API: https://docs.x.com/x-api/getting-started/pricing and https://docs.x.com/x-api/activity/introduction
- Weir monitors, billing and pagination: https://docs.weirapp.io/monitors, https://docs.weirapp.io/billing and https://docs.weirapp.io/pagination
- Weir call, 22 October 2026: search_x for @discord, limit 3, 4 credits