Learning Goals
3 minBy the end of this lesson you can:
- Create a Discord webhook and post a message to a channel.
- Send a rich embed (title, description, colour, fields).
- Attach a file with a multipart POST.
- Design a provider-agnostic notifier that fans out to Slack and Discord.
Warm-Up · Same Shape, New URL
5 minDiscord webhooks work just like Slack's: in a server, open Channel Settings → Integrations → Webhooks → New Webhook, copy the URL. Then POST JSON to it.
Slack: {"text": "hello"}
Discord: {"content": "hello"} ← the only real difference: "content"Because the mechanics are nearly identical — POST JSON to a secret URL — you can hide the per-platform differences behind one send(message) interface. That's the real lesson: design a notifier where adding a new channel (Discord, Teams, SMS) is a small adapter, and the rest of your code never changes.
New Concept · Discord Messages & Abstraction
14 minThe simplest post
import os, requests from dotenv import load_dotenv load_dotenv() WEBHOOK = os.environ["DISCORD_WEBHOOK"] r = requests.post(WEBHOOK, json={"content": "Deploy finished ✅"}, timeout=10) r.raise_for_status() # Discord returns 204 No Content on success
Note Discord uses "content" (not Slack's "text") and returns 204 on success. You can also set "username" and "avatar_url" to customise how the bot appears.
Rich embeds
def discord_embed(title: str, description: str, colour: int = 0x2563EB) -> bool: payload = {"embeds": [{ "title": title, "description": description, "color": colour, # an integer, e.g. 0xRRGGBB "fields": [ {"name": "Orders", "value": "412", "inline": True}, {"name": "Total", "value": "84,210.50", "inline": True}, ], }]} return requests.post(os.environ["DISCORD_WEBHOOK"], json=payload, timeout=10).ok
Discord's embeds are the equivalent of Slack's blocks/attachments: a titled card with a colour bar and inline fields. The colour is an integer (use hex literal 0x2563EB), unlike Slack's "#RRGGBB" string — a small but typical per-platform difference.
Attaching a file
def discord_file(message: str, path: str) -> bool: with open(path, "rb") as f: return requests.post( os.environ["DISCORD_WEBHOOK"], data={"content": message}, # text goes in 'data' files={"file": (path, f)}, # file goes in 'files' timeout=30, ).ok
For attachments you switch from json= to a multipart POST: text in data=, the file in files=. requests sets the multipart headers for you.
A provider-agnostic notifier
import os, requests, logging log = logging.getLogger("notify") class Notifier: """Sends a message to every configured channel.""" def __init__(self): self.slack = os.getenv("SLACK_WEBHOOK") self.discord = os.getenv("DISCORD_WEBHOOK") def send(self, text: str) -> dict: results = {} if self.slack: results["slack"] = self._post(self.slack, {"text": text}) if self.discord: results["discord"] = self._post(self.discord, {"content": text}) if not results: log.warning("no notification channels configured") return results @staticmethod def _post(url: str, payload: dict) -> bool: try: return requests.post(url, json=payload, timeout=10).ok except requests.RequestException as e: log.error("notify failed: %s", e) return False Notifier().send("Backup completed ✅") # goes to whichever are configured
This is the key abstraction: Notifier.send(text) fans out to every channel whose webhook is set, translating the payload per platform (text vs content). Your automation calls send() and doesn't care how many channels exist. Adding Teams later = one more if + adapter.
Worked Example · A Unified Alert Layer
12 minGoal: a clean notification layer with severity levels that renders appropriately on each platform, so the rest of your code just calls alert("...", level).
import os, requests, logging from datetime import datetime from dotenv import load_dotenv load_dotenv() logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") log = logging.getLogger("alerts") # platform-specific colour encodings for the same logical levels SLACK_COLOUR = {"info": "#2563EB", "good": "#16A34A", "warning": "#F59E0B", "error": "#DC2626"} DISCORD_COLOUR = {"info": 0x2563EB, "good": 0x16A34A, "warning": 0xF59E0B, "error": 0xDC2626} EMOJI = {"info": "ℹ️", "good": "✅", "warning": "⚠️", "error": "❌"} def alert(text: str, level: str = "info") -> dict: when = datetime.now().strftime("%H:%M:%S") line = f"{EMOJI.get(level, '')} {text} _({when})_" results = {} slack = os.getenv("SLACK_WEBHOOK") if slack: results["slack"] = _post(slack, {"attachments": [ {"color": SLACK_COLOUR.get(level, "#999"), "text": line}]}) discord = os.getenv("DISCORD_WEBHOOK") if discord: results["discord"] = _post(discord, {"embeds": [ {"description": line, "color": DISCORD_COLOUR.get(level, 0x999999)}]}) if not results: log.info("[%s] %s (no channels configured)", level, text) return results def _post(url: str, payload: dict) -> bool: try: return requests.post(url, json=payload, timeout=10).ok except requests.RequestException as e: log.error("post failed: %s", e) return False alert("Pipeline started", "info") alert("Backup completed", "good") alert("Disk at 88%", "warning") alert("API unreachable", "error")
# Slack: coloured attachment bars; Discord: coloured embeds. # Console (if neither configured): INFO [info] Pipeline started (no channels configured) INFO [good] Backup completed (no channels configured) ...
Read the code
The logical levels (info/good/warning/error) are defined once; each platform's quirks — Slack's hex-string attachments vs Discord's integer-colour embeds — are translated inside alert. The caller writes alert("Disk at 88%", "warning") and never thinks about platforms, colours, or which channels are live. When all webhooks are unset it still logs locally, so it's safe in any environment. This unified layer is exactly what Lesson 34's multi-channel alert system orchestrates.
Try It Yourself
13 minCreate a Discord server (free) and a webhook on a channel; store the URL in .env.
Post a plain message, then customise the bot's display name via "username" in the payload.
Post a rich embed with a title, description, colour, and two inline fields. Confirm the card renders with the colour bar.
Hint
discord_embed("Daily Summary", "Today's automation results", colour=0x16A34A)
Build the Notifier class and configure both a Slack and a Discord webhook. Call send() once and confirm the message lands in both platforms.
Hint
n = Notifier() results = n.send("Hello to both channels!") print(results) # {'slack': True, 'discord': True}
Mini-Challenge · The Pluggable Notifier
8 minRefactor the notifier into a list of channel adapters, each a small object/function with a send(text, level) method. The Notifier just loops over enabled adapters. Adding a new platform should require only writing a new adapter — no changes to existing code (the open/closed principle in action).
Show a sample solution
import os, requests class SlackChannel: def __init__(self, url): self.url = url def send(self, text, level): return requests.post(self.url, json={"text": text}, timeout=10).ok class DiscordChannel: def __init__(self, url): self.url = url def send(self, text, level): return requests.post(self.url, json={"content": text}, timeout=10).ok class Notifier: def __init__(self, channels): self.channels = channels def send(self, text, level="info"): return {type(c).__name__: c.send(text, level) for c in self.channels} # wire up only what's configured: channels = [] if os.getenv("SLACK_WEBHOOK"): channels.append(SlackChannel(os.environ["SLACK_WEBHOOK"])) if os.getenv("DISCORD_WEBHOOK"): channels.append(DiscordChannel(os.environ["DISCORD_WEBHOOK"])) Notifier(channels).send("System healthy", "good")
Non-negotiables: each platform is a self-contained adapter, Notifier loops over them, adding one needs no edits elsewhere.
Recap
3 minDiscord webhooks mirror Slack: POST JSON to a secret URL, using "content" instead of "text" and expecting a 204. Rich embeds (integer colours) are the Discord analogue of Slack blocks/attachments; attach files with a multipart data=+files= POST. The real takeaway is abstraction: hide per-platform quirks behind one send(text, level) interface — even better, behind pluggable channel adapters so adding a platform is one new adapter and zero edits elsewhere. Keep every webhook URL in the environment, and degrade gracefully when none are set.
Vocabulary Card
- embed
- Discord's rich message card: title, description, colour, fields.
- multipart POST
- A request carrying form fields and file bytes together (for attachments).
- adapter
- A small object translating a common interface to one platform's API.
- provider-agnostic
- Code that works regardless of which notification service is behind it.
Homework
4 minFinish your pluggable notify.py module with at least Slack and Discord adapters and a logical level that each renders appropriately (colour/emoji). Wire it into one earlier automation so its outcome posts to whichever channels you've configured. Document how a teammate would add a third channel (the adapter they'd write).
Sample · adding a third channel
To add Microsoft Teams (or any platform):
1. Write one adapter class with a send(text, level) method
that POSTs the right JSON to that platform's webhook.
2. In the channel wiring, add:
if os.getenv("TEAMS_WEBHOOK"):
channels.append(TeamsChannel(os.environ["TEAMS_WEBHOOK"]))
3. Done — Notifier and every caller are unchanged.
That's the open/closed principle: open to extension (new
adapters), closed to modification (no edits to existing code).Non-negotiables: ≥2 adapters, level rendering per platform, wired into a real job, clear "add a channel" instructions.