Schedule Discord Messages: Webhooks, Bots, Timing
Short answer: no, you cannot schedule a Discord message from the app itself. Not on desktop, not on mobile, not with Nitro. If you have been hunting for a hidden "send later" button, it does not exist. The real setup is simple: keep the message content in a file, keep the webhook URL in an environment variable, and let a scheduler or script make one HTTP request at the right time.
No, Discord can't schedule messages natively (and Scheduled Events aren't it)
Discord ships a feature literally called Scheduled Events, and a lot of people find it while searching for a message scheduler and assume it is the same thing. It is not. Scheduled Events are calendar entries. Members RSVP, get a reminder, and see a start time on an event card. There is no field for the text, embed, or attachment you actually want to post to a channel at that time. It is an RSVP system, not a publishing system.
Native message scheduling has been a standing feature request in Discord's own feedback channels for years, and it has not shipped. If you want a message to land in a channel at 9am on a Tuesday without you being awake to press send, you need one of the three methods below.
What you need
- A Discord server where you have permission to manage webhooks, or an account with Manage Webhooks on the target channel.
- For method 2, a Discord application registered in the Developer Portal, with a bot user added to your server.
- Whatever you are posting: plain text, an embed, an attachment, or a poll payload, ready ahead of the send time.
- A place to hold the scheduled time and fire the request when it arrives. That is either your own cron job, or a tool built for this.
Method 1: incoming webhooks (no bot, no OAuth)
This is the lightest path and the one most people should start with. A Discord webhook is a unique URL tied to a specific channel. Anyone (or anything) that has the URL can POST a message to that channel with no bot user, no OAuth flow, and no login required. Discord documents webhook execution here: Execute Webhook.
Create one from Channel Settings > Integrations > Webhooks, name it, and copy the URL. It looks like:
POST /webhooks/{webhook.id}/{webhook.token}
For a text-only send to an ordinary text channel, save this as /opt/discord-post/message.json:
{
"content": "Weekly changelog is up. Full notes in #updates."
}
This example does not handle embeds, attachments, or forum/media channels, which need a thread parameter. Discord documents the 2,000-character content limit, allowed_mentions, and the query parameter wait in Execute Webhook.
Save the following ES module as /opt/discord-post/send-discord.mjs. It requires Node.js 22 or newer and takes the content file's path as its argument. It sends only content and disables automatic mention parsing.
import { readFile } from "node:fs/promises";
let failure = "Invalid input: check the content file and webhook configuration.";
try {
const contentPath = process.argv[2];
if (!contentPath || process.argv.length !== 3) throw new Error();
const payload = JSON.parse(await readFile(contentPath, "utf8"));
if (
!payload || Array.isArray(payload) ||
typeof payload.content !== "string" ||
payload.content.trim().length === 0 || payload.content.length > 2000
) throw new Error();
const url = new URL(process.env.DISCORD_WEBHOOK_URL);
if (
url.protocol !== "https:" || url.hostname !== "discord.com" ||
url.username || url.password || url.port || url.hash || url.search ||
!/^\/api\/(?:v10\/)?webhooks\/\d+\/[A-Za-z0-9_-]+$/.test(url.pathname)
) throw new Error();
url.searchParams.set("wait", "true");
failure = "Discord request failed or timed out. Check the channel before resending.";
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
redirect: "error",
signal: AbortSignal.timeout(10_000),
body: JSON.stringify({
content: payload.content,
allowed_mentions: { parse: [] },
}),
});
if (!response.ok) {
failure = `Discord returned HTTP ${response.status}. No automatic retry was made.`;
throw new Error();
}
console.log("Discord message sent.");
} catch {
console.error(failure);
process.exitCode = 1;
}
The length check uses JavaScript UTF-16 code units, so it is deliberately conservative for emoji. Extra JSON fields are not forwarded. The script logs neither response bodies nor raw errors, and it never retries. A timeout can occur after Discord accepted a message; inspect the channel before deciding whether to resend.
Keep the webhook out of commands and logs
Use a private directory owned by the account that runs the scheduler. Save a trusted shell assignment in /opt/discord-post/webhook.env, replacing the example privately with your webhook URL:
DISCORD_WEBHOOK_URL='https://discord.com/api/webhooks/REPLACE_ID/REPLACE_TOKEN'
Do not commit this file. Save this wrapper as /opt/discord-post/send-discord.sh:
#!/bin/sh
set -eu
set -a
. /opt/discord-post/webhook.env
set +a
exec /usr/bin/node /opt/discord-post/send-discord.mjs "$@"
The wrapper sources a trusted file as shell code: only its owner should be able to change it. Verify your Node executable with command -v node and replace /usr/bin/node with that absolute path if necessary. Set permissions after creating the files, and run the scheduler as their owner:
chmod 700 /opt/discord-post
chmod 600 /opt/discord-post/webhook.env /opt/discord-post/message.json
chmod 700 /opt/discord-post/send-discord.sh
Never enable shell tracing (set -x) around the wrapper or environment file. Anyone who obtains the webhook URL can post as that webhook until you regenerate it.
Method 2: a bot token
Webhooks cover single-channel posting well, but they cannot read the server, react to messages, manage roles, or post to a channel that changes based on some condition in your app. For anything beyond "fire this message," you want a bot. Discord's create-message route is separate from the webhook route: Create Message. The bot endpoint is POST /channels/{channel.id}/messages, and that is not the same thing as a webhook URL.
Register an application in the Discord Developer Portal, add a bot user, and invite it to your server with the specific permissions it needs (at minimum, View Channel and Send Messages on the target channel). Your code authenticates with an Authorization: Bot ... header and sends messages to POST /channels/{channel.id}/messages. Incoming webhooks instead use their separate token-bearing webhook URL. This is more setup than a webhook: OAuth-style invite flow, permission scoping, keeping the bot token secret, and running something that stays connected or at least authenticates per request. It buys you flexibility a plain webhook URL does not have, at the cost of actually maintaining a bot.
For most people whose goal is "post this announcement to this channel at this time," a webhook does the job with far less surface area to secure.
Method 3: hold the message yourself (cron job, queue worker, or a scheduling tool)
Neither method above schedules anything by itself. A webhook or bot call fires the instant you make the request. Scheduling is a separate problem: something has to hold your message and the target time, then make that call when the clock hits it. That something is either your own cron job or queue worker calling the webhook URL directly, or a Discord-specific automation tool built for exactly this.
TimeToPost has implemented a Discord webhook connector, but it is not generally available yet. Check Settings โ Integrations for your workspace's access before relying on it for delivery.
In a workspace with access, create an incoming webhook in a regular text channel you manage, then connect its URL through the Discord connection form. Treat that URL as a credential. The connector schedules text and links up to 2,000 characters; it does not support attachments, forum channels or multi-message threads, and mentions do not notify members. Select the exact connected channel in the composer and check its delivery result after the scheduled time. Removing the TimeToPost connection does not delete the webhook in Discord; delete it there to revoke the credential.
Until access is available, use your own webhook script or Discord's supported tools for delivery. A planning tool alone does not send a Discord message.
One-off send
To send the content file once, immediately:
/opt/discord-post/send-discord.sh /opt/discord-post/message.json
For one delayed send, this at example schedules October 2, 2026 at 09:00 in the server's local timezone. Replace the date and time with your intended future time. The machine must have at installed and its scheduling service running:
printf '%s\n' '/opt/discord-post/send-discord.sh /opt/discord-post/message.json' | at -t 202610020900
The file is read when the job runs, so use a dedicated content file if you need to preserve a particular announcement.
Weekly send
Add this line to the scheduling user's crontab (crontab -e) for Tuesday at 09:00:
0 9 * * 2 /opt/discord-post/send-discord.sh /opt/discord-post/message.json >> /opt/discord-post/send.log 2>&1
Both paths are absolute, and the secret is loaded privately by the wrapper, not included in the cron entry. The private directory also protects the status log. Each weekly run reads the current content file; replace its content before the next run if the message should change.
Cron normally uses the server or daemon's configured timezone. A server configured for UTC sends at 09:00 UTC, which can shift relative to an audience's local time when daylight saving changes. Local-zone cron implementations can skip or repeat jobs around DST transitions. Check your cron implementation and server timezone; merely setting TZ inside the wrapper does not reliably change when cron triggers. Avoid ambiguous local times for one-off jobs too.
Neither example retries a failed send. A network failure does not prove the message was undelivered, so inspect the channel before any manual resend.
Ready to save hours on social media?
Schedule X posts from one dashboard, with more platforms landing.
Limits: 2,000 chars, embed math
Discord's message limits are strict and worth knowing before you build around them. The API can reject an overlong payload instead of truncating it for you, so validate length before send.
- Message content: keep API
contentat 2,000 characters for bot and webhook requests. Discord documents a 4,000-character allowance for messages sent by a Nitro subscriber, but that is an account entitlement, not a server boost that a webhook or bot can assume. See Discord's message resource documentation and webhook execution docs. - Embed title: 256 characters.
- Embed description: 4,096 characters.
- Embed fields: 25 fields per embed.
- Combined embed length: 6,000 characters across title, description, field names and values, footer, and author name, for all embeds in one message.
- Embeds per message: 10.
If you are posting a changelog or a long-form update, decide upfront whether it fits in a plain content message, needs an embed for structure, or needs to be split. Overlong payloads can be rejected by the API, so validate length before you send rather than after Discord rejects the call.
Timing in servers vs feeds
Posting into a Discord server is a different animal from posting to a public feed. A tweet or Instagram post competes for attention in an algorithmic timeline; a Discord message just appears in a channel your members already have open, or pings a role if you tag one. There is no engagement algorithm deciding whether more people see it later. Timing in a server is less about "when do most eyes scroll past" and more about when your specific, already-opted-in members are actually online, which channel notification settings they have, and whether pinging @everyone for a routine post will train people to mute you.
The practical version: reserve @role pings for genuinely time-sensitive posts, and use plain unpinged messages for routine updates. If you are also managing public-feed platforms where hour-of-day actually moves reach, that is a different question with actual data behind it. Our best time to post breakdown covers what is and is not backed by real study across platforms, which is worth reading before you assume the same clock applies inside a server.
FAQ
Are webhooks safe to share? No. A webhook URL is the entire credential. Anyone with it can post to your channel as that webhook, with no login and no second factor. Do not paste it into a public repo, a shared doc, or a client-side script. If one leaks, regenerate it immediately from the channel's Integrations settings.
Is there a Nitro-specific character limit?
The base message limit is 2,000 characters. Discord documents up to 4,000 characters for messages sent by a Nitro subscriber; do not treat that account allowance as a webhook or bot API limit. Keep automated content at 2,000 characters and split longer text or use an embed.
Can I schedule a recurring message, like a weekly reminder? Not natively, and not with a bare webhook either, since a webhook only fires once per call. Recurrence has to live in whatever is doing the scheduling: a cron job you run yourself, or a calendar tool that lets you set a cadence and have it fire the same webhook call on repeat.