I write this blog in [Obsidian](./Obsidian%20as%20a%20Writing%20and%20Publishing%20System.md), and for a long time publishing meant the same manual ritual: open the **Publish changes** modal, hunt through a list of changed files, tick the right boxes, and click Publish. My vault has thousands of notes but I only publish a curated handful, so that modal is a minefield — one stray "Select all" could push the entire vault live. I wanted to just say "publish this post" and have it happen.
## Why the obvious approaches don't work
**There is no official Obsidian Publish API or CLI.** So I tried the next-best things, and each failed for an instructive reason:
- **Editing `publish.json`'s `included` list.** Obsidian holds that config in memory and writes it back, so external edits get clobbered. It also doesn't scope the publish modal the way I assumed.
- **A keyboard macro (AppleScript).** It can *open* the modal reliably, but it can't tick specific checkboxes in a list of thousands — the order isn't stable and there's no keyboard path to a given row.
Both are fragile because they automate the *UI*. I wanted to automate the *protocol*.
## Reading the client's own code
The Obsidian desktop app is an Electron app, which means its source ships as a readable `app.js` inside an `.asar` archive. Extracting it and searching for the publish logic revealed exactly how the client talks to the server:
```
POST https://publish-01.obsidian.md/api/upload
headers:
obs-token : your account token
obs-id : the site id (from publish.json)
obs-path : URL-encoded, vault-relative file path
obs-hash : SHA-256 hex digest of the file's bytes
body: the raw file bytes
```
That's the whole upload protocol. Each call uploads or updates **exactly one file** — there's no "publish everything" surface area, so it can't accidentally touch the rest of the vault. Uploading never deletes anything.
A few details mattered:
- **The host is `publish-01.obsidian.md`**, read from `publish.json`'s `host` field — not the generic `publish.obsidian.md` domain I first guessed.
- **The hash is a plain SHA-256 hex digest** of the file bytes (the server uses it to detect changes).
- **The token lives in memory, not on disk.** It's stored in the app's `localStorage` under `obsidian-account`, so I grab it once from the DevTools console:
```js
copy(JSON.parse(localStorage.getItem('obsidian-account')).token)
```
## Listing and removing published files
Publishing was only half the protocol I eventually needed. Renaming a note in the vault does not rename its published page: uploading the new path creates a second page and leaves the old path live. The desktop client uses two separate backend endpoints to audit and remove those hosted files:
```
POST https://publish-01.obsidian.md/api/list
body: { "id": siteId, "token": token }
POST https://publish-01.obsidian.md/api/remove
body: { "path": "blog/old-post.md", "id": siteId, "token": token }
```
These endpoints authenticate differently from `/api/upload`: the site ID and token go in the JSON body, not in `obs-*` headers. There is also a nasty failure mode to guard against: bad authentication can still return HTTP 200 with an `AUTHFAIL` object in the response body, so the script checks the body before reporting success.
Removing a path here only unpublishes the hosted copy. The Markdown note and any assets remain in the local vault.
## The 403 that taught me about Cloudflare
My first requests came back `403 Forbidden` with `error code: 1010`. That's a Cloudflare signature: it blocks requests whose **User-Agent** doesn't look like a real Obsidian client. Adding an Obsidian user agent fixed it instantly:
```
User-Agent: obsidian/1.12.7
```
This is a good reminder that "undocumented API" often also means "protected by a bot wall" — and that the fix is to present yourself honestly as the same client the protocol was built for.
## The result
Now publishing a post is one command:
```bash
python3 publish_obsidian_api.py blog/my-post.md blog/my-post-assets/chart.png
```
The same script can show the authoritative live manifest and unpublish one or more paths:
```bash
python3 publish_obsidian_api.py --list
python3 publish_obsidian_api.py --remove blog/old-post.md blog/old-post-assets/chart.png
```
It hashes and uploads only the files named for publishing, and removes only the hosted paths named for unpublishing. Everything else stays untouched. (One gotcha worth automating away: a post's embedded images are separate files, so they have to be uploaded and removed separately from the Markdown.)
## Is this a good idea?
It's worth being honest about the tradeoffs. This is an **unofficial, reverse-engineered API**, so:
- It can break on any Obsidian update — the host, headers, or required user-agent string could change.
- It's against the spirit of "use the app the way it's shipped," even though it only touches my own site with my own credentials.
- The token is a real secret; it has to be handled like one.
For me the tradeoff is worth it: I publish from the same place I do everything else, and the consequential "which files go live" decision is now explicit in a command rather than a checklist I might fat-finger. But if Obsidian ever ships a real publish API, I'll switch to it immediately.
## Related
- [Obsidian as a Writing and Publishing System](./Obsidian%20as%20a%20Writing%20and%20Publishing%20System.md)
- [A Raspberry Pi Calendar for the Kitchen Wall](./A%20Raspberry%20Pi%20Calendar%20for%20the%20Kitchen%20Wall.md)