One API to dispatch your content anywhere. Twitter / X · Instagram · Telegram · Bluesky · LinkedIn · Threads · YouTube Shorts — self-host forever free · MIT license
Open-Dispatch is the infrastructure layer for content distribution. Like Stripe is to payments, this is to posting. One HTTP call, seven platforms, zero vendor lock-in. Integrate from any app, n8n workflow, cron job, or AI agent.
⭐ If self-hosting your social posting sounds good, star the repo to follow development — it's the fastest way to support an independent MIT project.
curl -X POST http://localhost:8000/dispatch \
-H "Content-Type: application/json" \
-d '{
"targets": ["twitter", "bluesky", "telegram", "threads"],
"formats": {
"twitter_thread": {"tweets": ["shipped v0.4", "thanks for reading"]},
"bluesky_post": {"text": "shipped v0.4"},
"telegram_message": {"text": "shipped v0.4"},
"threads_post": {"text": "shipped v0.4"}
}
}'Or use the built-in dark dashboard at http://localhost:8000/ — compose, schedule, retry, watch the queue live.
Hosted scheduling services charge $3–6 per connected account, gate analytics and inbox management behind paid tiers, and keep your OAuth tokens on their servers. Open-Dispatch runs on your own machine, stores nothing externally, and costs nothing beyond electricity.
| Hosted tools | Open-Dispatch | |
|---|---|---|
| Pricing | Per-account fees that stack up | Self-host free — $0 forever |
| Credentials | Live on their servers | Never leave your machine |
| Errors | Fail silently or stay vague | Visible + retryable in the dashboard |
| Inbox / analytics / X | Extra paid add-ons | All platforms included, no tiers |
| Your data | Lingers after you cancel | You own the queue end-to-end |
| Lock-in | Proprietary | MIT licensed — fork it any time |
Five ways to run Open-Dispatch. Pick what fits your stack.
| Method | Setup | Needs | Best for |
|---|---|---|---|
| 🍺 Homebrew | ~2 min | macOS + Homebrew | macOS devs, auto-start at login |
| ⬇️ install.sh | ~90s | bash + Python 3.11+ | Linux servers, scripts, CI |
| 🐳 Docker | ~60s | Docker | Self-hosters, Linux VMs, zero Python |
| 🐍 pip | instant | Python 3.11+ | Python devs, embedding in apps |
| 🍎 macOS App | 10s | macOS 13+ | Non-technical users, menubar control |
# Add the tap (formula lives in the main repo)
brew tap matthew-selvam/open-dispatch \
https://github.com/Matthew-Selvam/Open-Dispatch
brew install open-dispatch
# Set up credentials
$EDITOR ~/.open-dispatch/.env
# Start the server (foreground)
open-dispatch
# Or run as a background service — auto-starts on login
brew services start open-dispatchInstalls three commands: dispatch (CLI), open-dispatch (server), open-dispatch-worker (background worker).
brew services wires a launchd plist so the server starts at login automatically.
curl -fsSL \
https://raw.githubusercontent.com/Matthew-Selvam/Open-Dispatch/main/install.sh \
| bash
# Set up credentials
$EDITOR ~/.open-dispatch/.env
# Start the server
open-dispatch
# macOS: start at login via launchd
launchctl load ~/Library/LaunchAgents/dev.open-dispatch.plist
# Linux: start via systemd user session
systemctl --user enable --now open-dispatchThe script auto-detects your OS, finds Python 3.11+, creates an isolated virtualenv, and writes a launchd plist (macOS) or systemd user unit (Linux). No sudo required.
Advanced usage:
bash install.sh \
--prefix /opt/open-dispatch \ # install location (default: ~/.local)
--version v0.4.0 \ # specific tag (default: main)
--data-dir /var/open-dispatch \ # data / .env location
--no-service # skip auto-start setupgit clone https://github.com/Matthew-Selvam/Open-Dispatch
cd Open-Dispatch
cp .env.example .env # fill in your platform credentials
docker compose up -d
# Verify
curl http://localhost:8000/healthz # → {"status":"ok"}
# Open the dashboard
open http://localhost:8000
# Optional: add Redis for multi-worker throughput
docker compose --profile redis up -dMulti-arch image (amd64 + arm64). Non-root dispatch user. No compiler tools in the final layer.
# Install from GitHub (PyPI publish pending)
pip install git+https://github.com/Matthew-Selvam/Open-Dispatch.git
# Optional extras
pip install "open-dispatch[redis]" # Redis queue backend
pip install "open-dispatch[postgres]" # Postgres queue backend
# Start the API + UI
uvicorn api.app:app --reload
# Worker (separate terminal)
python -m scheduler.worker
# CLI
dispatch send --platforms bluesky --text "hello world"Best for Python developers who want to embed Open-Dispatch in an existing codebase.
- Download
Open-Dispatch-0.4.0.dmgfrom GitHub Releases - Drag
Open-Dispatch.appto/Applications - First launch: right-click → Open → click Open in the dialog (unsigned build — bypasses Gatekeeper once)
- A status icon appears in your menu bar
- Click → Edit .env → add your platform credentials
- Click → Start Server
- Click → Open Dashboard →
http://localhost:8000
Apple Silicon & Intel — this release is native arm64. Rosetta 2 runs it on Intel Macs without any extra steps.
SwiftUI menubar app (macOS 13+). Bundles the Python server — no separate Python install. Supports launch-at-login via SMAppService.
Build it yourself:
bash scripts/make-dmg.sh
# → dist/Open-Dispatch-0.4.0.dmg
# With code-signing + notarization:
bash scripts/make-dmg.sh \
--sign "Developer ID Application: Your Name (TEAMID)" \
--notarizeAll config lives in .env (or ~/.open-dispatch/.env for Homebrew / install.sh installs).
cp .env.example .env
$EDITOR .env# ── Twitter / X ──────────────────────────────────────────────────────────────
TWITTER_API_KEY=...
TWITTER_API_SECRET=...
TWITTER_ACCESS_TOKEN=...
TWITTER_ACCESS_SECRET=...
# ── Bluesky ──────────────────────────────────────────────────────────────────
BLUESKY_HANDLE=user.bsky.social
BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
# ── Telegram ─────────────────────────────────────────────────────────────────
TELEGRAM_BOT_TOKEN=123456:ABC-...
TELEGRAM_CHAT_ID=@channel_or_id
# ── Instagram / Threads / LinkedIn / YouTube: see .env.example for full list ─
# ── Queue backend (optional) ──────────────────────────────────────────────────
# REDIS_URL=redis://localhost:6379 # Redis backend
# DATABASE_URL=postgresql://... # Postgres backend
# ── AI caption adapter (optional) ────────────────────────────────────────────
# OPENROUTER_API_KEY=...
# OLLAMA_HOST=http://localhost:11434Only configure the platforms you actually use — missing vars are silently skipped.
http://localhost:8000/ # dashboard — queue, filters, live auto-refresh
http://localhost:8000/compose # post composer with AI caption adapt
http://localhost:8000/queue/<id> # row detail + retry
Dark terminal aesthetic. HTMX-driven (no JS build step). 51 KB vendored htmx.min.js. Runs on the same port as the API.
| Method | Path | Purpose |
|---|---|---|
| GET | /healthz |
Liveness probe — JSON for monitors, HTML dashboard for browsers |
| POST | /dispatch |
Enqueue a ContentUnit for one or many platforms |
| GET | /queue?status=… |
List rows (queued / publishing / published / dead) |
| GET | /queue/{id} |
One row — JSON or HTML (content-negotiated) |
| POST | /queue/{id}/retry |
Reset an errored / dead row to queued |
| DELETE | /queue/{id} |
Delete a single queue row permanently |
| POST | /_purge?status=… |
Bulk-delete rows by status (published or dead) |
| POST | /ai/adapt |
Rewrite a caption per target platform |
| POST | /media/transcode |
Resize image to a platform spec |
| GET | /media/specs |
List all 10 platform image specs |
{
"category": "general",
"targets": ["twitter:work", "bluesky", "threads"],
"scheduled_for": "2026-05-19T18:00:00+00:00",
"formats": {
"twitter_thread": { "tweets": ["t1", "t2"], "media_paths": [] },
"bluesky_post": { "text": "…", "images": [{"path": "…", "alt": "…"}] },
"telegram_message": { "text": "…", "photo_path": "…", "parse_mode": "HTML" },
"instagram_post": { "caption": "…", "image_url": "https://…" },
"linkedin_post": { "text": "…", "asset_urn": "urn:li:digitalmediaAsset:…" },
"threads_post": { "text": "…", "image_url": "https://…" }
},
"webhook_url": "https://example.com/dispatch-callback"
}Target syntax: platform[:account]. Per-account credentials: <PLATFORM>_<FIELD>_<ACCOUNT> (uppercase). E.g. telegram:broadcast resolves to TELEGRAM_CHAT_ID_BROADCAST.
When webhook_url is set the worker fires POST {url} after each publish or failure:
{"event":"published","id":"<row-id>","post_id":"<platform-id>","platform":"twitter:work"}
{"event":"failed", "id":"<row-id>","error":"…","platform":"…","dead":false}After any install, three commands are available:
# Send content now
dispatch send --platforms twitter,bluesky --text "hello world"
# Schedule for later
dispatch send --platforms telegram --text "scheduled post" \
--scheduled-for "2026-06-01T09:00:00Z"
# View queue
dispatch queue --status queued
dispatch queue --status failed
# Run the worker in-process
dispatch worker
# Quick connectivity test (Telegram ping)
dispatch quick-testEach adapters/<platform>.py exposes one function:
def publish(unit: ContentUnit, account: str | None) -> tuple[bool, str, str]:
"""Returns (ok, post_id, error_message)."""Add a platform in three steps:
- Write
adapters/myplatform.py— implementpublish - Add it to the
ADAPTERSdict inadapters/__init__.py - Document the format key in this README
That's the whole integration surface. The worker handles retry, backoff, and webhooks automatically.
POST /dispatch
│
▼
Queue (JSONL | Redis | Postgres)
│
▼
scheduler/worker.py
│
├─▶ adapters/twitter.py
├─▶ adapters/bluesky.py
├─▶ adapters/instagram.py
├─▶ adapters/telegram.py
├─▶ adapters/linkedin.py
├─▶ adapters/threads.py
└─▶ adapters/youtube.py
│
▼ on publish / fail
webhook_url
- Queue: JSONL on disk (default), Redis (
REDIS_URL), or Postgres (DATABASE_URL). All three implementQueueProtocol— swap with a single env var, no code changes. - Retry: exponential backoff (
WORKER_BACKOFF_BASE × 2^(attempts-1)+ jitter), gives up afterWORKER_MAX_ATTEMPTS. - State machine:
queued → publishing → published | failed | dead - No-cloud option: everything runs from a single
pythonprocess —python cli.py workeralongsideuvicorn.
Community node lives in n8n-node/. One node, five operations:
| Operation | Endpoint | Purpose |
|---|---|---|
| Dispatch | POST /dispatch |
Send content (now or scheduled) |
| Adapt Caption with AI | POST /ai/adapt |
Per-platform rewrite |
| Get Queue Row | GET /queue/{id} |
Fetch one row |
| Retry Queue Row | POST /queue/{id}/retry |
Re-queue a failed row |
| List Queue | GET /queue |
List rows by status |
cd n8n-node
npm install --ignore-scripts && npm run build
# In your n8n install:
npm link n8n-nodes-open-dispatchThe credential just needs your Open-Dispatch base URL (plus an optional bearer if you proxy with auth).
Per-platform image resize via Pillow — 10 platform specs out of the box. Honors EXIF orientation, flattens RGBA to JPEG, never upscales.
# Transcode a Twitter card from a phone photo
curl -X POST "http://localhost:8000/media/transcode?platform=twitter" \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpg --output photo.twitter.jpg
# List all specs
curl http://localhost:8000/media/specs | jq .twitterPython:
from media import transcode_image
transcode_image("photo.jpg", "instagram") # → photo.instagram.jpgOne source text → per-platform rewrites. Respects character limits and style conventions (Twitter punchy, LinkedIn formal, Instagram hashtag-heavy). Falls back to a heuristic when no LLM is configured — the endpoint never 500s on missing credentials.
curl -X POST http://localhost:8000/ai/adapt \
-H "Content-Type: application/json" \
-d '{
"text": "Open-Dispatch v0.4: 5 install methods, macOS app, Homebrew tap.",
"platforms": ["twitter", "bluesky", "linkedin", "instagram", "threads"]
}'Provider priority: Ollama (free, local) → OpenRouter (any cloud model) → heuristic (no LLM). Set OPENROUTER_API_KEY or OLLAMA_HOST in .env to enable AI rewrites.
pytest -q
# 135 tests — schema, queue, API, and adapter coverage — no network, no real credentials- Twitter / X, Instagram, Telegram, Bluesky, LinkedIn, Threads, YouTube Shorts adapters
- JSONL queue, exponential retry, webhooks
- Docker Compose self-host
- Web UI (HTMX + Jinja, dark theme — dashboard, composer, retry, row detail, live refresh)
- AI caption-adaptation per platform (OpenRouter / Ollama / heuristic fallback)
- YouTube Shorts adapter (Data API v3 resumable upload, OAuth2 refresh-token flow)
- Redis queue backend (multi-worker safe)
- Postgres queue backend (ACID + SKIP LOCKED for cross-region workers)
- Media transcoding (10 platform specs, REST + Python API)
- n8n community node (Dispatch / Adapt / Get Row / Retry / List Queue)
- Homebrew tap (
brew install open-dispatch) - Universal install.sh (macOS + Linux, launchd + systemd)
- macOS menubar app + DMG
- TikTok adapter (Content Posting API once approved)
- Video transcoding (ffmpeg-backed)
- PyPI publish
See CONTRIBUTING.md. The adapter contract is one function — adding a platform is ~80 LOC.
See SECURITY.md. Report vulnerabilities via GitHub Security Advisories (private disclosure).
Full documentation for all five install methods: INSTALL_METHODS.md.
Moving off Buffer, Hootsuite, or a similar paid tool? The step-by-step guide is in
docs/migrating-from-zernio.md — export your accounts,
map them to .env, and dispatch your first post in about ten minutes.
- 💬 Discussions — questions, ideas, show-and-tell
- 🐛 Issues — bugs and feature requests
- ⭐ Star the repo to follow development — every star helps an independent MIT project reach more self-hosters
MIT © Matthew Selvam. Fork it, ship it, never pay per-account fees again.
