Discord Webhooks for Script Automation: Send Notifications Without a Bot Library
Discord bots are powerful but heavyweight—they require a persistent connection, bot tokens, and the discord.py library. For simple notifications from scripts, cron jobs, or CI/CD pipelines, webhooks provide a lighter alternative. This post presents a two-part system: a bot that provisions webhooks automatically, and a standalone sender script that needs only curl or Python’s standard library.
The Problem
You have scripts running across multiple machines:
- ML training jobs that run for hours
- Benchmark suites that complete overnight
- Deployment pipelines that need status updates
- Monitoring scripts that detect anomalies
You want notifications in Discord without:
- Installing discord.py on every machine
- Managing bot tokens in multiple locations
- Maintaining persistent WebSocket connections
- Writing boilerplate Discord API code
Solution Architecture
1
2
3
4
5
6
7
8
9
10
11
┌─────────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Status Bot │────▶│ .discord_webhooks│────▶│ Any Script │
│ (provisions │ │ .json │ │ (curl/python│
│ webhooks) │ │ │ │ POST) │
└─────────────────┘ └──────────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Discord Server │
│ #general #ml-training #benchmarking #alerts #deploys │
└─────────────────────────────────────────────────────────────┘
The bot runs once to provision webhooks, then scripts use those webhooks independently.
Webhook Registry Format
The bot saves webhook URLs to a JSON file that maps channel names to URLs:
1
2
3
4
5
6
{
"general": "https://discord.com/api/webhooks/<id>/<token>",
"ml-training": "https://discord.com/api/webhooks/<id>/<token>",
"benchmarking": "https://discord.com/api/webhooks/<id>/<token>",
"alerts": "https://discord.com/api/webhooks/<id>/<token>"
}
Each webhook URL contains a unique ID and token generated by Discord when the webhook is created. The bot provisions these automatically—you never need to manually create webhooks in Discord’s UI.
This design enables:
- Channel targeting: Scripts specify which channel by name
- URL rotation: Update webhooks without changing scripts
- Multi-channel: Different notification types go to appropriate channels
- Portability: Copy the JSON file to any machine that needs to send notifications
Part 1: Webhook Provisioning Bot
The bot creates and manages webhooks for configured channels. It only needs to run when setting up new channels or refreshing URLs.
Configuration
1
2
3
4
# Channels to provision webhooks for
WEBHOOK_CHANNELS = ["general", "ml-training", "benchmarking", "alerts", "deploys"]
WEBHOOK_FILE = Path.home() / ".discord_webhooks.json"
WEBHOOK_BOT_NAME = "NotificationBot Webhook"
Webhook Provisioning Logic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
async def ensure_webhooks(bot_instance) -> dict[str, str]:
"""Ensure bot-owned webhooks exist for each configured channel.
Returns a dict of {channel_name: webhook_url}.
"""
webhook_urls: dict[str, str] = {}
for guild in bot_instance.guilds:
for channel in guild.text_channels:
if channel.name not in WEBHOOK_CHANNELS:
continue
try:
existing = await channel.webhooks()
except discord.Forbidden:
logger.warning(f"No permission to manage webhooks in #{channel.name}")
continue
# Look for a webhook we already own
bot_webhook = None
for wh in existing:
if wh.user == bot_instance.user and wh.name == WEBHOOK_BOT_NAME:
bot_webhook = wh
break
if bot_webhook is None:
try:
bot_webhook = await channel.create_webhook(name=WEBHOOK_BOT_NAME)
logger.info(f"Created webhook for #{channel.name}")
except discord.Forbidden:
logger.warning(f"No permission to create webhook in #{channel.name}")
continue
webhook_urls[channel.name] = bot_webhook.url
# Save to disk for external scripts
WEBHOOK_FILE.write_text(json.dumps(webhook_urls, indent=2))
logger.info(f"Saved {len(webhook_urls)} webhook URLs to {WEBHOOK_FILE}")
return webhook_urls
Key implementation details:
| Feature | Purpose |
|---|---|
| Check existing webhooks | Avoid creating duplicates |
| Match by bot user and name | Only manage our own webhooks |
| Save to JSON file | Enable standalone script access |
| Log permission errors | Diagnose missing bot permissions |
Bot Startup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class NotificationBot(commands.Bot):
def __init__(self):
intents = discord.Intents.default()
super().__init__(command_prefix="!", intents=intents)
async def setup_hook(self):
await self.tree.sync()
async def on_ready(self):
logger.info(f"Logged in as {self.user}")
# Provision webhooks on startup
urls = await ensure_webhooks(self)
for name in urls:
logger.info(f"Webhook ready: #{name}")
Manual Refresh Command
1
2
3
4
5
6
7
8
9
10
11
12
13
@bot.tree.command(name="setup-webhooks", description="Create/refresh webhooks")
async def setup_webhooks(interaction: discord.Interaction):
await interaction.response.defer()
urls = await ensure_webhooks(bot)
if not urls:
await interaction.followup.send("No webhooks created. Check bot permissions.")
return
lines = [f"**#{name}**: ready" for name in urls]
await interaction.followup.send(
f"Webhooks configured for {len(urls)} channel(s):\n" + "\n".join(lines)
)
Bot Permissions
The bot needs Manage Webhooks permission in Discord:
- Server Settings → Roles
- Select bot’s role
- Enable “Manage Webhooks”
- Save changes
Part 2: Standalone Sender Script
The sender script reads webhook URLs from the JSON file and POSTs messages. No Discord library required.
Full Implementation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
#!/usr/bin/env python3
"""Send messages to Discord channels via saved webhook URLs.
Examples:
python3 discord_webhook_send.py -c ml-training -m "Training complete"
echo "Results: 95.2%" | python3 discord_webhook_send.py -c benchmarking
python3 discord_webhook_send.py -c alerts -t "Alert" -m "Disk usage 90%"
"""
import argparse
import json
import sys
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
WEBHOOK_FILE = Path.home() / ".discord_webhooks.json"
def load_webhooks() -> dict[str, str]:
if not WEBHOOK_FILE.exists():
print(f"Error: {WEBHOOK_FILE} not found.", file=sys.stderr)
sys.exit(1)
return json.loads(WEBHOOK_FILE.read_text())
def send_webhook(url: str, content: str = None, title: str = None,
message: str = None) -> None:
"""POST a message to a Discord webhook URL."""
payload: dict = {}
if title:
# Send as embed
embed = {"title": title}
if message:
embed["description"] = message
payload["embeds"] = [embed]
elif content:
payload["content"] = content
else:
print("Error: nothing to send", file=sys.stderr)
sys.exit(1)
data = json.dumps(payload).encode()
req = Request(url, data=data, headers={
"Content-Type": "application/json",
"User-Agent": "DiscordWebhookSender/1.0", # Required by Cloudflare
}, method="POST")
try:
with urlopen(req) as resp:
if resp.status == 204:
print("Sent.")
else:
print(f"Sent (HTTP {resp.status}).")
except HTTPError as e:
print(f"Discord API error {e.code}: {e.read().decode()}", file=sys.stderr)
sys.exit(1)
except URLError as e:
print(f"Network error: {e.reason}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(
description="Send a message to a Discord channel via webhook"
)
parser.add_argument("--channel", "-c", required=True,
help="Channel name (e.g. ml-training)")
parser.add_argument("--message", "-m", help="Message text")
parser.add_argument("--title", "-t",
help="Embed title (sends as embed instead of plain text)")
args = parser.parse_args()
webhooks = load_webhooks()
if args.channel not in webhooks:
available = ", ".join(webhooks.keys()) or "(none)"
print(f"Error: no webhook for '{args.channel}'. "
f"Available: {available}", file=sys.stderr)
sys.exit(1)
# Read from stdin if no --message provided
message = args.message
if message is None and not sys.stdin.isatty():
message = sys.stdin.read().strip()
if not message and not args.title:
print("Error: provide --message, --title, or pipe input", file=sys.stderr)
sys.exit(1)
url = webhooks[args.channel]
if args.title:
send_webhook(url, title=args.title, message=message)
else:
send_webhook(url, content=message)
if __name__ == "__main__":
main()
User-Agent Requirement
Discord’s CDN (Cloudflare) blocks requests without a User-Agent header:
1
2
3
4
5
6
7
8
# This fails silently or returns 403
req = Request(url, data=data, headers={"Content-Type": "application/json"})
# This works
req = Request(url, data=data, headers={
"Content-Type": "application/json",
"User-Agent": "DiscordWebhookSender/1.0",
})
Always include a User-Agent when POSTing to Discord webhooks.
Usage Examples
Plain Text Messages
1
2
3
4
5
6
7
8
9
10
# Simple notification
python3 discord_webhook_send.py -c ml-training -m "Training complete"
# Pipe output from another command
nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader | \
python3 discord_webhook_send.py -c benchmarking -m "GPU: $(cat)"
# From a script
echo "Deployment finished at $(date)" | \
python3 discord_webhook_send.py -c deploys
Embeds with Titles
Embeds provide visual structure with a title and description:
1
2
3
4
5
6
7
8
9
# Training completion with metrics
python3 discord_webhook_send.py -c ml-training \
-t "Training Complete" \
-m "Epochs: 50000\nAccuracy: 98.2%\nLoss: 0.0234"
# Alert with details
python3 discord_webhook_send.py -c alerts \
-t "⚠️ Disk Space Warning" \
-m "Server: darknode\nUsage: 92%\nPath: /var/log"
Curl Alternative
For environments without Python:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Load webhook URL
URL=$(python3 -c "import json; print(json.load(open('$HOME/.discord_webhooks.json'))['ml-training'])")
# Plain text
curl -s -X POST "$URL" \
-H "Content-Type: application/json" \
-H "User-Agent: CurlWebhook/1.0" \
-d '{"content": "Training complete"}'
# Embed
curl -s -X POST "$URL" \
-H "Content-Type: application/json" \
-H "User-Agent: CurlWebhook/1.0" \
-d '{"embeds": [{"title": "Results", "description": "Accuracy: 95.2%"}]}'
Integration Patterns
Cron Job Notifications
1
2
3
# /etc/cron.d/backup-notify
0 3 * * * root /usr/local/bin/backup.sh && \
python3 /opt/discord_webhook_send.py -c alerts -m "Backup completed"
CI/CD Pipeline
1
2
3
4
5
6
7
8
# .gitlab-ci.yml
deploy:
script:
- ./deploy.sh
- |
python3 discord_webhook_send.py -c deploys \
-t "Deployment: $CI_PROJECT_NAME" \
-m "Branch: $CI_COMMIT_REF_NAME\nCommit: $CI_COMMIT_SHORT_SHA"
Training Script Integration
1
2
3
4
5
6
7
8
9
10
11
# At the end of train.py
import subprocess
def notify_discord(channel: str, message: str):
subprocess.run([
"python3", "discord_webhook_send.py",
"-c", channel, "-m", message
])
# After training completes
notify_discord("ml-training", f"Training complete\nEpoch: {epoch}\nAccuracy: {acc:.2%}")
Systemd Service Notifications
1
2
3
4
# /etc/systemd/system/myservice.service
[Service]
ExecStartPost=/usr/bin/python3 /opt/discord_webhook_send.py -c alerts -m "Service started"
ExecStopPost=/usr/bin/python3 /opt/discord_webhook_send.py -c alerts -m "Service stopped"
Multi-Machine Setup
To use webhooks from multiple machines:
- Generate webhooks on the machine running the bot
- Copy the JSON file to other machines:
1
scp ~/.discord_webhooks.json user@remote:~/.discord_webhooks.json
- Copy the sender script:
1
scp discord_webhook_send.py user@remote:~/bin/
Each machine can now send to any configured channel without needing bot tokens or Discord libraries.
Security Considerations
Webhook URL Protection
Webhook URLs are bearer tokens—anyone with the URL can post to your channel:
1
2
3
4
5
# Restrict file permissions
chmod 600 ~/.discord_webhooks.json
# Don't commit to version control
echo ".discord_webhooks.json" >> .gitignore
Rate Limiting
Discord rate limits webhook requests. For high-frequency notifications:
1
2
3
4
5
6
7
8
9
10
11
12
13
import time
def send_with_backoff(url: str, payload: dict, max_retries: int = 3):
for attempt in range(max_retries):
try:
# ... send request ...
return
except HTTPError as e:
if e.code == 429: # Rate limited
retry_after = int(e.headers.get("Retry-After", 5))
time.sleep(retry_after)
else:
raise
Webhook Rotation
If a webhook URL is compromised:
- Delete the webhook in Discord (Server Settings → Integrations → Webhooks)
- Run
/setup-webhooksin Discord - Redistribute the updated JSON file
File Organization
1
2
~/.discord_webhooks.json # Webhook URL registry (auto-generated)
~/bin/discord_webhook_send.py # Sender script
For the bot (if running locally):
1
2
3
4
~/discord-bot/
├── bot.py # Main bot with webhook provisioning
├── .env # DISCORD_BOT_TOKEN=...
└── requirements.txt # discord.py
Summary
This two-part system separates concerns:
| Component | Responsibility | Dependencies |
|---|---|---|
| Status Bot | Provision webhooks, save URLs | discord.py, bot token |
| Sender Script | POST messages via webhook | None (stdlib only) |
| JSON Registry | Map channel names to URLs | None |
The bot runs once to set up webhooks, then scripts across any machine can send notifications with zero Discord-specific dependencies. Channel targeting via the JSON registry keeps the sender script simple while supporting multiple notification streams.