Skip to content

Publish from a script with the REST API

7 min read

In this tutorial you'll write a script that publishes a Markdown file from your computer to a Basehim site. Run it once and it creates the post, with its category and a cover image. Run it again after editing the file and it updates the same post. It's the core of a "write in your editor, publish from the terminal" workflow, or of a CI job that publishes release notes.

You'll learn to:

  • create a scoped API key;
  • send JSON and file uploads;
  • find or create a category;
  • tell create from update;
  • handle API errors.

You need: Node.js 18 or newer (for built-in fetch), and a Basehim site where you can create an API key. The script has no dependencies.

Step 1: Create an API key

In the admin, open API → Keys and create a key named "Publishing script" with these scopes:

ScopeUsed for
posts:writeCreating and updating posts (includes reading them)
media:writeUploading the cover image
taxonomies:read, taxonomies:writeFinding and creating the category

Copy the key, which starts with basehim_. It's shown only once. The key acts as your account, limited to these scopes, so the script can publish only if your role can.

A new API key shown once, and the key's scopes in the list below
1: the key is shown this one time only; copy it now. 2: the scopes you ticked appear next to the key in the list.

Keep the key out of the script and out of git. Pass it in the environment:

export BASEHIM_URL=https://your-site.com
export BASEHIM_KEY=basehim_…

Step 2: Write a post as Markdown

posts/release-2-4.md:

---
title: Release 2.4
excerpt: Faster search and a new export button.
category: Release notes
status: draft
cover: cover.png
cover_alt: The 2.4 release banner
---
## What changed

- Search is **twice as fast** on large sites.
- A new **Export** button on every report.

Thanks to everyone who sent feedback.

Put a cover.png next to it. The front matter between the --- lines becomes the post's fields. Always send an excerpt: it's what cards, feeds and search results show.

Step 3: A small API helper

Create publish.mjs and start with one function that every call goes through:

// Publish a Markdown file to Basehim.   node publish.mjs posts/release-2-4.md
// Needs Node 18+ and two environment variables: BASEHIM_URL and BASEHIM_KEY.
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';

const SITE = process.env.BASEHIM_URL;     // https://your-site.com
const KEY = process.env.BASEHIM_KEY;      // basehim_… with posts:write, media:write, taxonomies:read, taxonomies:write
const STATE = '.published.json';          // remembers which file became which post id

async function api(method, route, body) {
  const isForm = body instanceof FormData;
  const res = await fetch(`${SITE}/api/v1${route}`, {
    method,
    headers: {
      Authorization: `Bearer ${KEY}`,
      Accept: 'application/json',
      ...(body && !isForm ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body === undefined ? undefined : (isForm ? body : JSON.stringify(body)),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`${method} ${route} → ${res.status}: ${json.detail ?? json.error ?? 'request failed'}`);
  return json;
}
  • JSON bodies get Content-Type: application/json. For uploads, fetch sets the multipart content type itself when the body is a FormData.
  • Errors: authentication and scope problems come back as problem details with a detail field. Other errors have an error field. The helper reads either and throws, so one failed call stops the script with a clear message.

Step 4: Parse the file

// "---\nkey: value\n---\nbody" → { meta, body }
function parse(text) {
  const m = text.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  if (!m) throw new Error('The file needs front matter between --- lines.');
  const meta = Object.fromEntries(m[1].split('\n')
    .map((line) => line.match(/^(\w+):\s*(.*)$/)).filter(Boolean).map(([, k, v]) => [k, v.trim()]));
  return { meta, body: m[2].trim() + '\n' };
}

This simple parser handles flat key: value lines, which is all this script needs.

Step 5: Category and cover image

async function categoryId(name) {
  const { data } = await api('GET', '/taxonomies/category/terms');
  const found = data.find((t) => t.name.toLowerCase() === name.toLowerCase());
  if (found) return found.id;
  return (await api('POST', '/taxonomies/category/terms', { name })).data.id;
}

async function uploadImage(file, alt) {
  const form = new FormData();
  form.append('file', new Blob([await readFile(file)]), path.basename(file));
  form.append('alt_text', alt ?? '');
  return (await api('POST', '/media', form)).data.id;
}

Basehim makes the thumbnail, medium and large sizes of the upload automatically. Always send alt_text: it's used for accessibility and for the image's social-card description.

Step 6: Create or update

The API reads single posts by slug but changes them by id. Drafts aren't readable by slug, so the script keeps a small .published.json file that maps each Markdown file to its post id:

const file = process.argv[2];
if (!SITE || !KEY || !file) {
  console.error('Usage: BASEHIM_URL=… BASEHIM_KEY=… node publish.mjs path/to/post.md');
  process.exit(1);
}

const { meta, body } = parse(await readFile(file, 'utf8'));
const state = JSON.parse(await readFile(STATE, 'utf8').catch(() => '{}'));

const payload = {
  title: meta.title,
  content: body,
  content_format: 'markdown',
  excerpt: meta.excerpt ?? '',
  status: meta.status ?? 'draft',
};
if (meta.category) payload.term_ids = [await categoryId(meta.category)];

let post;
if (state[file]) {
  post = (await api('PATCH', `/posts/${state[file]}`, payload)).data;          // update by id
  console.log(`Updated #${post.id} (${post.status})`);
} else {
  if (meta.cover) payload.featured_media_id = await uploadImage(path.join(path.dirname(file), meta.cover), meta.cover_alt);
  post = (await api('POST', '/posts', payload)).data;
  state[file] = post.id;
  await writeFile(STATE, JSON.stringify(state, null, 2));
  console.log(`Created #${post.id} "${post.title}" (${post.status})`);
}
  • content_format: 'markdown' stores the Markdown as it is. The site renders it, and the editor opens it as blocks if someone edits it in the admin.
  • term_ids replaces the post's categories and tags with exactly this list.
  • PATCH changes only the fields you send, so nothing else on the post is touched.
  • The cover is uploaded only when the post is created, so re-running doesn't fill the media library with copies.

Step 7: Run it

node publish.mjs posts/release-2-4.md
# Created #115 "Release 2.4" (draft)

Open the admin. The draft is there, with its category and featured image.

The published Release 2.4 post in the editor, with its status, featured image and Release notes category
1: the post was sent as Markdown, so the editor offers to keep it as Markdown. 2: status, set by the script. 3: the featured image from media:write. 4: the Release notes category the script found or created.

Now change status: draft to status: published in the file and run it again:

node publish.mjs posts/release-2-4.md
# Updated #115 (published)

curl -s "$BASEHIM_URL/api/v1/posts/release-2-4"
# {"data": {"id": 115, "status": "published", "excerpt": "Faster search and a new export button.", "terms": [{"name": "Release notes", …}], …}}

Try a wrong key to see the error handling:

BASEHIM_KEY=basehim_wrong node publish.mjs posts/release-2-4.md
# Error: PATCH /posts/115 → 401: Authentication required.
Terminal output of three runs of publish.mjs: created as a draft, updated to published, and a 401 error with a wrong key
1: the first run creates the post as a draft. 2: the second run updates the same post and publishes it. 3: a wrong key stops with a clear 401 message.

What can go wrong

MessageCause
401: Authentication required.Wrong or revoked key, or the server strips the Authorization header (see Installation)
403: This API key does not have the media:write scope.Add the scope to the key, or create a new key
The status comes back pending instead of publishedYour role can't publish, so the post waits for an editor's review. Updating a post that is already published then fails with 403: Requires the publish_posts capability.
A warnings array in the responseYour role lacks unfiltered_html, and raw HTML in the Markdown was filtered
404 on PATCHThe post was deleted. Remove its line from .published.json

Taking it further

  • Publish a whole folder: loop over posts/*.md.
  • Run it in CI on every push to a content/ folder, with the key stored as a CI secret.
  • Schedule posts for later by keeping them as drafts and changing status when it's time.

The same calls in curl, PHP and Python are in the REST API guide, and every endpoint is in the API reference.

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment