When you’ve already read the Admin API docs
You’re not afraid of the Ghost Admin API. You’ve generated the JWT, you know it’s HS256 off the hex secret in your key with a five-minute expiry, and you’ve gotten a PUT /posts/{id}/ to come back 200. The instinct, once you’re that far, is the honest one: I’ll just script this myself. It’s a couple of fetch calls. And for a one-direction, one-time job, you should — that’s exactly the right call, and nothing here is meant to talk you out of it.
What this page is about is the gap between that first working request and a two-way sync you can actually trust, because that gap is wider than it looks from the docs, and most of it is invisible until you’re already relying on it.
The edges you find by hitting them
The first one shows up the moment you try to drive the API from anything browser-shaped — an Electron app, an Obsidian plugin, a quick local tool. CORS. The Admin API doesn’t want to be called from a browser context, and you end up proxying or reaching for a Node process anyway. So you write the JWT signing yourself: parse id:secret, hex-decode the secret, sign the header and payload, set aud to /admin/, refresh before the five minutes are up. Not hard. Just one more thing that has to be exactly right or every request 401s.
Then come the quieter ones. Every write has to be wrapped in { posts: [...] }, and Ghost rejects an update unless you send back the post’s current updated_at — its collision check — so you can’t blindly push, you have to read first. There’s the ?source=html flag, which feels like the answer to “I have HTML, I want it in the post,” and is the start of the real problem.
The part the docs don’t make loud
Ghost is Lexical-native. The lexical field is the source of truth for a post’s body; the html field is generated from it for rendering. When you send HTML in with ?source=html, Ghost converts it back into Lexical to store it — and that conversion is lossy. It is not a round-trip. Push HTML, pull it back, and you do not reliably get what you started with. Cards flatten, structure drifts, and the post you “edited” via the API is quietly not the post you meant.
This is the thing that turns a weekend script into a maintenance burden. A naive markdown-to-HTML-to-?source=html pipeline looks like it works on the first post and corrupts the tenth in a way you don’t notice until a reader does. Getting it right means treating Markdown as a projection of the post — a view you edit and reconcile back — not as the canonical body, and converting through the format Ghost actually stores. Specter pulls html, mobiledoc, and lexical together, treats Lexical as the source of truth, and is honest with you about where a Card can’t survive the trip. There’s a fuller account of that in how Specter handles Ghost Cards, and the conversion question specifically in why not just use the API directly.
What “two-way” actually costs to build
Say you get the fidelity right. You still don’t have a sync — you have a pusher. A real two-way sync has to answer: which side changed since last time, and what do I do when both did. That’s diffing against a stored state, it’s a file-watcher debounced so a save doesn’t fire mid-write, it’s a content hash so you don’t push a post whose only change was Ghost re-rendering its own HTML, and it’s a conflict policy for when the local file and the remote post have both moved. The honest version of that last one is a prompt, not a silent last-write-wins, because last-write-wins on someone’s published post is how you lose a day of their writing.
None of these are hard problems individually. Together they’re a small, stateful, long-running program that you now own — that breaks when Ghost ships a v6, that you debug at the worst possible moment, that lives in your head instead of in a repo someone maintains. That’s the trade Specter is offering to take off your hands: the maintained version of the sync you’d otherwise build and babysit — JWT, Lexical and mobiledoc conversion, diffing, conflict prompts, dry-run, file-watching — already done and kept working.
Keep the scripts you actually want
The thing you don’t want taken away is the scripting. That’s the whole point of being comfortable with the API in the first place. So the sync engine underneath the menu bar app is also a CLI, ghost-sync: pull, push, sync, watch, status, test. You can wire ghost-sync pull into a cron job, run a sync at the end of a build, or call it from whatever pipeline you already have, and let the part that’s genuinely fiddly — the formats and the conflicts — be handled by code that’s tested, while you write the orchestration that’s specific to you. And because every post is now a plain .md file on disk, the archive drops straight into git, which is its own kind of safety net — see version control for your Ghost posts.
The dry-run is the piece that earns trust here. Before anything writes, ghost-sync can tell you exactly which posts would be created, updated, or flagged as a conflict — the diff you’d otherwise be squinting at in API responses, made legible before a bulk pass goes live. When you’re driving real writes against a real blog, seeing the blast radius first is the difference between a tool you script confidently and one you babysit.
If you’d rather keep your own JWT signing close, Ghost Admin API keys covers how the key parses and what scope it carries. Everything here connects with that one key, the same one your own script would use — Specter just stops you from re-deriving the lossy parts the hard way.