Snitch
A lightweight Python application that forwards GitHub webhook events to Discord channels as rich embeds — with automatic webhook management, a smee.io tunnel, and zero runtime configuration.
Overview
Snitch listens to your GitHub repository and forwards events — pushes, pull requests, releases, issues, and any custom event — as formatted Discord embeds to the channel of your choice. Just call Snitch() and everything is automatic.
Auto webhook lifecycle
Registers the GitHub webhook on startup and removes it cleanly on exit via atexit.
Built-in tunnelling
Creates a smee.io tunnel automatically — no manual ngrok setup or port forwarding needed.
Customisable embeds
Control title, description, and RGB color per event using {data[...]} placeholders.
Per-event routing
Each event gets its own channel_id. Route pushes, issues, and releases separately.
How It Works
Snitch orchestrates four independent classes in a fixed boot sequence inside Snitch.__init__.
Tunnel() fetches a fresh smee.io URL and starts smee-client in a background daemon thread to forward inbound requests to your local Flask server.Webhook(token, payload_url) registers a webhook on your repo via the GitHub API. On process exit, atexit fires the delete endpoint automatically — no manual cleanup needed.Client(token) starts a nextcord bot in a daemon thread. It reads events.json, builds Discord embeds from the payload data, and sends them to the configured channel.Server() starts Flask on port 3000. It receives POST requests from the tunnel, reads the X-Github-Event header, and dispatches them to Client.send().Installation
Requires Python 3.10+ and Node.js (for smee-client). Tested on Windows 10/11.
# 1. Enter the repo
cd /path/to/snitch
# 2. Create and activate a virtual environment
python -m venv .venv
.venv\Scripts\activate # Windows
source .venv/bin/activate # macOS / Linux
# 3. Install dependencies
pip install --upgrade pip
pip install -r requirements.txt
Node.js is installed automatically by Snitch on first run (npm install --global smee-client). You only need Node.js available on your PATH.
Quick Start
Fill in your config files, then create a main.py with a single line.
# main.py
from snitch import Snitch
Snitch()
On startup Snitch will print:
[SNITCH] Connected to tunnel: https://smee.io/xK9aQr...
[SNITCH] Created GitHub webhook with id: 489201847
[SNITCH] Initialized Discord bot
[SNITCH] Connected to Web-Server with port: 3000
Never commit tokens.env to version control. Add it to your .gitignore before your first commit.
Configuration
tokens.env
Stores your GitHub and Discord tokens. Loaded automatically at startup via python-dotenv. Located at snitch/config/tokens.env.
| Key | Required | Description |
|---|---|---|
| GITHUB_TOKEN | yes | Personal access token with admin:repo_hook scope to create and delete webhooks. |
| BOT_TOKEN | yes | Discord bot token. The bot must be in your server with Send Messages permission. |
GITHUB_TOKEN=ghp_your_personal_access_token
BOT_TOKEN=MTQ3...your_discord_bot_token
Do not wrap values in quotes — python-dotenv reads them literally and the quote characters will be included in the token string.
events.json
Defines embed templates and channel routing for every GitHub event you want to handle. The key must exactly match a GitHub webhook event name. Events absent from this file are silently ignored.
| Field | Type | Description |
|---|---|---|
| color.r / g / b | int 0–255 | Global embed accent color applied to all events. |
| <event>.channel_id | int | Discord channel ID where this event is posted. |
| <event>.title | string | Embed title. Supports {data[key]} placeholders. |
| <event>.desc | string | Embed description. Supports {data[key]} and \n. |
{
"color": { "r": 121, "g": 65, "b": 231 },
"push": {
"channel_id": 123456789012345678,
"title": "↗ Push from {data[pusher][name]}",
"desc": "`Commit: {data[head_commit][message]}`\n`Forced: {data[forced]}`"
},
"pull_request": {
"channel_id": 123456789012345678,
"title": "⤵ {data[pull_request][title]} was *{data[action]}* by {data[sender][login]}",
"desc": "`Number: {data[number]}`"
},
"release": {
"channel_id": 123456789012345678,
"title": "⬇ {data[release][name]} was *{data[action]}* by {data[release][author][login]}",
"desc": "`Pre Release: {data[release][prerelease]}`\n`Id: {data[release][id]}`"
},
"issues": {
"channel_id": 123456789012345678,
"title": "⚠ {data[issue][title]} was *{data[action]}* by {data[issue][user][login]}",
"desc": "`Id: {data[issue][id]}`"
}
}
webhooks.json
Stores the GitHub API URL and request payloads used to create and delete webhooks. This file is managed automatically — you only need to update the url field to point at your own repository.
Change url to: https://api.github.com/repos/YOUR_USER/YOUR_REPO/hooks
{
"url": "https://api.github.com/repos/your_user/your_repo/hooks",
"add": {
"headers": {
"Authorization": "token {github_token}",
"Accept": "application/vnd.github.v3+json"
},
"payload": {
"name": "web", "active": true, "events": ["*"],
"config": { "url": "{payload_url}", "content_type": "json" }
}
},
"remove": {
"headers": { "Authorization": "token {github_token}" }
}
}
API Reference
Creates and manages a smee.io tunnel between your local server and GitHub.
| Method | Returns | Description |
|---|---|---|
| __init__() | — | Fetches a new smee.io URL via HTTP redirect. Stores it as self.url. |
| run() | None | Installs smee-client via npm and starts forwarding in a daemon thread. Logs the URL on success. |
| get_url() | str | None | Returns the smee.io URL, or None if the tunnel failed to initialise. |
from snitch.api.tunnel import Tunnel
tunnel = Tunnel()
tunnel.run()
url = tunnel.get_url() # "https://smee.io/xK9aQr..."
Manages the GitHub webhook lifecycle — creation on startup, automatic deletion on exit.
| Method | Returns | Description |
|---|---|---|
| __init__(token, payload_url) | — | Reads webhooks.json, stores the token and payload URL. |
| run() | None | POSTs to the GitHub hooks API. On success, stores the webhook ID and registers a delete call via atexit. |
| Parameter | Type | Description |
|---|---|---|
| token | str | GitHub personal access token with admin:repo_hook scope. |
| payload_url | str | The smee.io URL the webhook will POST events to. |
from snitch.api.webhook import Webhook
webhook = Webhook(token="ghp_...", payload_url="https://smee.io/...")
webhook.run() # creates webhook, registers atexit cleanup
Wraps a nextcord Bot. Reads events.json at module level and builds Discord embeds from GitHub payloads.
| Method | Returns | Description |
|---|---|---|
| __init__(token) | — | Initialises the bot with all intents, registers on_ready and on_error handlers. |
| run() | None | Starts the bot in a daemon thread via run_thread. |
| send(header, payload) | None | Looks up the template for header, builds an embed, and sends it to the configured channel ID. |
| Parameter | Type | Description |
|---|---|---|
| token | str | Discord bot token. |
from snitch.api.client import Client
client = Client(token="MTQ3...")
client.run()
# called internally by Server on each valid event
client.send(header="push", payload={...})
A Flask application that receives POST requests from the smee.io tunnel and dispatches them to the Discord client.
| Method | Returns | Description |
|---|---|---|
| __init__() | — | Initialises Flask and registers the POST / route internally. |
| set_handler(predicate) | None | Sets the callable invoked on each valid webhook. Must accept header: str and payload: dict. |
| run() | None | Starts Flask on port 3000. This call blocks — it must be the last step in the boot sequence. |
from snitch.api.server import Server
server = Server()
server.set_handler(client.send) # wire up the Discord client
server.run() # blocks — call last
Customization
Adding Custom Events
Any GitHub webhook event can be handled by adding a matching key to events.json. The key must exactly match the value GitHub sends in the X-Github-Event header.
// events.json — add alongside existing events
"star": {
"channel_id": 123456789012345678,
"title": "⭐ {data[sender][login]} starred {data[repository][full_name]}",
"desc": "`Total stars: {data[repository][stargazers_count]}`"
},
"fork": {
"channel_id": 123456789012345678,
"title": "🍴 {data[sender][login]} forked {data[repository][full_name]}",
"desc": "`Forks: {data[repository][forks_count]}`"
}
Embed Templates
Title and description strings use Python's str.format() syntax. The full GitHub webhook payload is available as data.
| Syntax | Description |
|---|---|
| {data[key]} | Top-level payload field, e.g. {data[action]}. |
| {data[key][nested]} | Nested field, e.g. {data[pusher][name]}. |
| \n | Line break inside the embed description. |
| `text` | Discord inline code — wrapping values in backticks improves readability. |
See the GitHub webhook payload reference for available fields per event.