Post

Discord Webhooks for Script Automation: Send Notifications Without a Bot Library

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:

FeaturePurpose
Check existing webhooksAvoid creating duplicates
Match by bot user and nameOnly manage our own webhooks
Save to JSON fileEnable standalone script access
Log permission errorsDiagnose 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:

  1. Server Settings → Roles
  2. Select bot’s role
  3. Enable “Manage Webhooks”
  4. 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:

  1. Generate webhooks on the machine running the bot
  2. Copy the JSON file to other machines:
    1
    
    scp ~/.discord_webhooks.json user@remote:~/.discord_webhooks.json
    
  3. 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:

  1. Delete the webhook in Discord (Server Settings → Integrations → Webhooks)
  2. Run /setup-webhooks in Discord
  3. 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:

ComponentResponsibilityDependencies
Status BotProvision webhooks, save URLsdiscord.py, bot token
Sender ScriptPOST messages via webhookNone (stdlib only)
JSON RegistryMap channel names to URLsNone

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.

This post is licensed under CC BY 4.0 by the author.