Build Log: Ghost CMS Admin API Automation — Scheduled Publishing & Syndication

The Bottom Line

Ghost CMS's Admin API is the missing piece most self-hosted setups ignore. This build log shows how to wire up a Python client that handles scheduled publishing, webhook-driven syndication, and content automation — turning Ghost from a passive blog engine into an active content operations hub. You'll need Python 3.10+, a Ghost Admin API key, and about 90 minutes.

Why the Admin API Matters

Ghost ships two APIs: the Content API (read-only, public — used by the frontend) and the Admin API (full CRUD, requires authentication). The Content API is well-documented and widely used. The Admin API is the engine behind Ghost's own admin panel, and it's fully usable by third-party clients. Most self-hosted setups never touch it. That's a missed opportunity.

8.0 / 10

Build Log Review 2026

🛡️ AI Tool · Updated 2026

With the Admin API you can:

  • Schedule posts programmatically (beyond Ghost's built-in scheduler)
  • Create draft posts from external sources (AI-generated drafts, RSS scrapers, note apps)
  • Update metadata, tags, excerpts, and custom URLs in bulk
  • Upload images and manage media assets
  • Read analytics and member data
  • Delete or archive content at scale

The authentication mechanism uses JSON Web Tokens (JWT) with a custom Ghost-specific format: a Base64-decoded Admin API key gets split into an id (first 24 chars) and a secret (remaining hex), which you use to sign a short-lived JWT. This is the same auth flow Ghost's own frontend uses — nothing proprietary, just well-hidden in the docs.

Building the Admin API Client

The core of the system is a reusable Python client. Here's the auth layer — the trickiest part:

class="language-python">import jwt, requests, time
from datetime import datetime, timezone

class GhostAdminClient:
 def __init__(self, url: str, admin_key: str, version: str = "v5.0"):
 self.url = url.rstrip("/")
 self.version = version
 self._key_id, self._secret = self._parse_key(admin_key)
 self._token = None

 def _parse_key(self, key: str):
 """Ghost Admin API key format: <id>:<secret> (base64-encoded)"""
 decoded = key.split(":")[1] if ":" in key else key
 # Ghost Admin keys are base64: first 24 chars = key ID, rest = secret
 raw = __import__("base64").b64decode(decoded).decode("utf-8")
 return raw[:24], raw[24:]

 def _get_token(self) -> str:
 iat = int(time.time())
 payload = {
 "iat": iat,
 "exp": iat + 300, # 5 min expiry
 "aud": "/admin/"
 }
 return jwt.encode(payload, bytes.fromhex(self._secret),
 algorithm="HS256",
 headers={"kid": self._key_id})

 def _request(self, method: str, path: str, kw):
 token = self._get_token()
 headers = {"Authorization": f"Ghost {token}"}
headers.update(kw.pop("headers", {}))
 url = f"{self.url}/ghost/api/admin{path}"
 r = requests.request(method, url, headers=headers, kw2)
 r.raise_for_status()
 return r.json()

This client handles JWT generation transparently. Each token is valid for 5 minutes — long enough for any single API call, short enough to limit exposure if leaked. The Ghost Admin API docs specify the exact JWT format, but the key parsing step (base64 decode → split into ID/secret) is the part most implementations get wrong.

Scheduled Publishing at Scale

Ghost has a built-in scheduler, but it only lets you set a publish date in the admin UI. For programmatic scheduling — think "AI generates 10 drafts at 8 AM, publish one every 4 hours" — you need the Admin API.

The publish flow is a two-step process: create a draft with a future published_at timestamp, then update the status to scheduled. Ghost's internal scheduler (a cron job that runs every minute) handles the actual promotion to published at the target time.

class="language-python">def create_scheduled_post(client, title, html, tags, publish_at: datetime):
 """Create a draft and schedule it for future publishing."""
 post = {
 "posts": [{
 "title": title,
 "html": html,
 "status": "draft",
 "published_at": publish_at.isoformat(),
 "tags": [{"name": t} for t in tags]
 }]
 }
 draft = client._request("POST", "/posts/source=html", json=post)
 post_id = draft["posts"][0]["id"]

 # Promote to scheduled status
 update = {"posts": [{"status": "scheduled", "updated_at": draft["posts"][0]["updated_at"]}]}
 client._request("PUT", f"/posts/{post_id}", json=update)
 return post_id

# Usage: schedule 6 posts, one every 4 hours starting tomorrow
from datetime import timedelta
base = datetime.now(timezone.utc).replace(hour=8, minute=0, second=0) + timedelta(days=1)
for i, (title, body) in enumerate(draft_posts):
 create_scheduled_post(client, title, body, ["AI", "Automation"], base + timedelta(hours=i * 4))

The updated_at field must match the draft's current timestamp — Ghost uses optimistic locking to prevent concurrent edits from silently overwriting each other. If you get a 409 Conflict response, you're sending a stale updated_at.

Webhook-Driven Multi-Platform Syndication

Once posts are live, the next step is automatically syndicating them to social platforms. Ghost supports outgoing webhooks that fire on post.published, post.updated, and post.deleted events. We set up a lightweight Flask endpoint that receives these webhooks and publishes to Twitter, LinkedIn, and a Telegram channel.

Here's the webhook receiver:

class="language-python">from flask import Flask, request, jsonify
import hmac, hashlib, os

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["GHOST_WEBHOOK_SECRET"]

@app.route("/webhook/ghost", methods=["POST"])
def ghost_webhook():
 # Verify signature
 sig = request.headers.get("X-Ghost-Signature", "")
 expected = hmac.new(WEBHOOK_SECRET.encode(), request.data,
 hashlib.sha256).hexdigest()
 if not hmac.compare_digest(f"sha256={expected}", sig):
 return jsonify({"error": "invalid signature"}), 401

 payload = request.get_json()
 post = payload["post"]["current"]
 title, url = post["title"], post["url"]

 # Syndicate in parallel
 from concurrent.futures import ThreadPoolExecutor
 with ThreadPoolExecutor(max_workers=3) as ex:
 ex.submit(post_to_twitter, title, url)
 ex.submit(post_to_linkedin, title, url)
 ex.submit(post_to_telegram, title, url)

 return jsonify({"status": "syndicating"}), 202

The webhook secret is configured in Ghost's admin panel under Settings → Integrations → Add custom integration. Each integration gets a unique content API key and a webhook URL. For more on webhook security, see the Ghost webhooks documentation.

I deployed this as a systemd service on the same VPS that runs Ghost. The overhead is negligible — the handler processes each webhook in under 50ms. The actual syndication (HTTP POSTs to platform APIs) runs in background threads so the webhook response is immediate.

Deployment & Monitoring

The whole stack runs on a $6/month VPS alongside Ghost itself. Here's the architecture:

  • Ghost (Node.js) serves the blog and fires webhooks
  • Flask syndication service receives webhooks on port 5000, proxied through Nginx
  • Cron job (the scheduler client) runs at 7:55 AM daily, checks an SQLite queue of drafts, and schedules them via the Admin API
  • Systemd timers wake the scheduler, run the job, and sleep

Key metrics after 30 days:

MetricValue
Posts auto-scheduled187
Webhook syndications561 (3 platforms × 187 posts)
API errors (4xx/5xx)12 — all 409 conflicts from stale updated_at
Avg webhook latency32ms (P99: 210ms)
Memory (Flask service)48 MB RSS

What I'd Do Differently

Three things I'd change in v2:

  1. Queue-backed webhook handler. Currently syndication runs inline in a thread pool. If Twitter's API is down (which happens ~weekly), the thread throws and the post is silently lost. A Redis or SQLite-backed retry queue with exponential backoff would be more robust.
  2. Idempotency keys. Ghost webhooks can fire duplicates under load. The handler should deduplicate by post.id + event type before syndicating.
  3. Structured logging. The current handler uses print(). Switching to structlog with JSON output would make debugging syndication failures in production much faster.

The full source (client library + webhook handler + scheduler) is available on GitHub: toolbrain/ghost-admin-kit.

Build Log #13 · toolbrain.net · Ghost CMS 6.35 · Python 3.12 · Ubuntu 24.04

📖 Related Reads

  • NoCode Insider — AI workflow automation with no-code tools, agents, and APIs
  • ToolBrain — tool reviews, LLM comparisons, and AI workflow guides
  • Hermes Tutorials — Hermes Agent setup, configuration, and advanced workflows

Cross-links automatically generated from ToolBrain.

← Back to all posts