Skip to content

REST API guide

10 min read

Everything in Basehim's admin is also available over a REST API at /api/v1: posts, pages, media, taxonomies, comments, menus, users and settings. Use it to publish from scripts and other systems, to run Basehim as a headless backend, or to automate a site. This guide covers authentication, conventions and working examples. The full endpoint list is in the API reference.

Base URL

https://your-site.com/api/v1

In a subfolder install, include the folder: https://example.com/cms/api/v1.

Reading needs no key

Published posts, pages, categories, tags, menus, widget areas, public settings, search and comments are open to everyone:

curl https://your-site.com/api/v1/posts?per_page=5
curl https://your-site.com/api/v1/posts/hello-world
curl "https://your-site.com/api/v1/search?q=release"

Anything that writes, or that reads drafts, users or settings, needs authentication.

Authentication

MethodHowBest for
API keyAuthorization: Bearer basehim_…Scripts, servers, integrations
JWTPOST /auth/login, then Authorization: Bearer <access_token>Apps acting for a person who signs in
OAuth 2.1Automatic, through /mcpAI assistants. See MCP and AI agents

API keys

Create one under API → Keys in the admin. Name it after the integration that will use it, tick the scopes it needs, and copy the key. It's shown once. Then send it as a bearer token:

curl https://your-site.com/api/v1/me -H "Authorization: Bearer basehim_your_key"

A key acts as the user who created it, limited to its scopes. Revoke it on the same screen and it stops working at once.

Scopes

ScopeAllows
posts:read, posts:writePosts and pages, including drafts; create, update, trash
media:read, media:writeThe media library; uploads and deletes
taxonomies:read, taxonomies:writeCategories, tags and other terms
comments:read, comments:writeComments; moderation
users:read, users:writeUser accounts
settings:read, settings:writeSite settings, installed apps, the scheduler and the cache
menus:read, menus:writeNavigation menus

Apps can add scopes of their own. A key without the scope an endpoint needs gets 403 insufficient-scope, which names the scope it needs.

Scopes and roles both apply. A scope says which kind of thing a key may touch. The role of the key's owner decides what they may do with it. An author's posts:write key edits that author's own posts only, and a contributor's posts go to review instead of being published. Create keys from an account with the least power that does the job.

JWT (sign in as a person)

curl -X POST https://your-site.com/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"login": "ana", "password": "…"}'
{
  "user": {"id": 3, "username": "ana", "display_name": "Ana", "role": "editor", "…": "…"},
  "access_token": "eyJ…",
  "refresh_token": "…",
  "token_type": "Bearer",
  "expires_in": 900
}
  • login can be a username or an email.
  • The access token lasts 15 minutes. Exchange the refresh token for a new pair before then, with POST /auth/refresh and {"refresh_token": "…"}. Each refresh token works once, and lasts 14 days.
  • POST /auth/logout with the refresh token ends the session.
  • Accounts that use two-step sign-in get 401 two_factor_required first. A 6-digit code is emailed; repeat the login with "otp": "123456" added.

JWTs carry the user's full role. Scopes don't apply to them, so treat them like a password.

Requests and responses

Send JSON with Content-Type: application/json. Form-encoded bodies work too, and file uploads use multipart/form-data.

Successful responses wrap the result in data. Lists add meta:

{
  "data": [ { "id": 87, "slug": "release-notes-2-4", "title": "Release notes 2.4", "…": "…" } ],
  "meta": { "page": 1, "per_page": 10, "total": 57, "last_page": 6 }
}

A few endpoints answer differently: /auth/login and /auth/refresh return tokens at the top level, /me returns {"user": …}, and deletes return {"message": "Deleted"}.

Two rules that catch people out

  1. Read single items by slug, change them by id. GET /posts/hello-world, but PATCH /posts/12 and DELETE /posts/12.
  2. Updates are partial. PATCH /posts/12 with {"title": "New"} changes the title and nothing else. PUT behaves the same way.

Pagination and filters

List endpoints take page (from 1) and per_page (up to 100). /posts and /pages also take q (search) and author_id. The public lists show published content only.

curl "https://your-site.com/api/v1/posts?page=2&per_page=20&q=release"

Errors

Always check the HTTP status first. Authentication and scope errors use problem details (RFC 7807):

{"type": "https://basehim.io/errors/insufficient-scope", "title": "Insufficient scope", "status": 403,
 "detail": "This API key does not have the users:read scope.", "required_scope": "users:read"}

Other errors are a short object with an error message:

{"error": "Requires the publish_posts capability."}

So read detail, then error. A 5xx never includes the exception: details go to the site's log.

StatusMeaning
400 / 422The request was malformed or failed validation
401No credentials, or they are invalid or expired
403Missing scope or capability
404No such item, or it isn't published
429Too many attempts (sign-in)
500Server error; see the site's log

HTML in content

Post HTML from accounts without the unfiltered_html capability (anyone below editor) is filtered on save: scripts, event handlers, javascript: links and unknown iframes are removed. When that happens, the response carries a warnings array next to data. Markdown stays Markdown unless it contains HTML that can't be checked safely.

Examples

Each example below creates a draft, publishes it and reads it back. Set BASEHIM_URL and BASEHIM_KEY (a key with posts:read and posts:write) before running them.

curl

# Create a draft (Markdown is rendered on the site)
curl -X POST "$BASEHIM_URL/api/v1/posts" \
  -H "Authorization: Bearer $BASEHIM_KEY" -H 'Content-Type: application/json' \
  -d '{"title":"Hello from curl","content":"Written by **a script**.","content_format":"markdown","status":"draft"}'

# Publish it (by id), then read it (by slug)
curl -X PATCH "$BASEHIM_URL/api/v1/posts/42" \
  -H "Authorization: Bearer $BASEHIM_KEY" -H 'Content-Type: application/json' \
  -d '{"status":"published"}'
curl "$BASEHIM_URL/api/v1/posts/hello-from-curl"

JavaScript (Node.js 18+)

const SITE = process.env.BASEHIM_URL;
const KEY = process.env.BASEHIM_KEY;     // basehim_…

async function api(method, path, body) {
  const res = await fetch(`${SITE}/api/v1${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${KEY}`,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(`${res.status}: ${json.detail ?? json.error}`);
  return json;
}

const { data: post } = await api('POST', '/posts', {
  title: 'Hello from JavaScript',
  content: '<p>Written by a script.</p>',
  status: 'draft',
});
await api('PATCH', `/posts/${post.id}`, { status: 'published' });
const { data: list, meta } = await api('GET', '/posts?per_page=5');
console.log(meta.total, 'published posts; newest:', list[0].title);

Never put an API key in JavaScript that runs in visitors' browsers: anyone could read it. Call the API from your server, or read public endpoints without a key. To call the API from another origin, list that origin in CORS_ALLOWED_ORIGINS (Configuration).

PHP

<?php
$site = getenv('BASEHIM_URL');
$key  = getenv('BASEHIM_KEY');

function api(string $method, string $path, ?array $body = null): array
{
    global $site, $key;
    $ch = curl_init($site . '/api/v1' . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 20,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . $key,
            'Content-Type: application/json',
            'Accept: application/json',
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $json   = json_decode((string) curl_exec($ch), true) ?? [];
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new RuntimeException($status . ': ' . ($json['detail'] ?? $json['error'] ?? 'request failed'));
    }
    return $json;
}

$post = api('POST', '/posts', ['title' => 'Hello from PHP', 'content' => '<p>Written by a script.</p>', 'status' => 'draft'])['data'];
api('PATCH', '/posts/' . $post['id'], ['status' => 'published']);
echo api('GET', '/posts/' . $post['slug'])['data']['status'], "\n";   // published

This is for code running outside Basehim. Inside an app, use $this->api() instead (The App API).

Python (standard library only)

import json, os, urllib.request

SITE = os.environ["BASEHIM_URL"]
KEY = os.environ["BASEHIM_KEY"]

def api(method, path, body=None):
    req = urllib.request.Request(
        SITE + "/api/v1" + path,
        method=method,
        data=json.dumps(body).encode() if body is not None else None,
        headers={"Authorization": "Bearer " + KEY,
                 "Content-Type": "application/json",
                 "Accept": "application/json"},
    )
    try:
        with urllib.request.urlopen(req, timeout=20) as res:
            return json.load(res)
    except urllib.error.HTTPError as e:
        problem = json.load(e)
        raise SystemExit(f"{e.code}: {problem.get('detail') or problem.get('error')}")

post = api("POST", "/posts", {"title": "Hello from Python", "content": "Written by **a script**.",
                               "content_format": "markdown", "status": "draft"})["data"]
api("PATCH", f"/posts/{post['id']}", {"status": "published"})
print(api("GET", f"/posts/{post['slug']}")["data"]["status"])   # published

Common tasks

curl -X POST "$BASEHIM_URL/api/v1/media" -H "Authorization: Bearer $BASEHIM_KEY" \
  -F "file=@cover.png" -F "alt_text=Blue banner"
# → {"data": {"id": 3, "url": "/uploads/2026/10/cover.png", …}}

curl -X PATCH "$BASEHIM_URL/api/v1/posts/42" -H "Authorization: Bearer $BASEHIM_KEY" \
  -H 'Content-Type: application/json' -d '{"featured_media_id": 3}'

Put a post in a category

# Find or create the category
curl "$BASEHIM_URL/api/v1/taxonomies/category/terms"
curl -X POST "$BASEHIM_URL/api/v1/taxonomies/category/terms" -H "Authorization: Bearer $BASEHIM_KEY" \
  -H 'Content-Type: application/json' -d '{"name": "Release notes"}'

# term_ids replaces all of the post's categories and tags
curl -X PATCH "$BASEHIM_URL/api/v1/posts/42" -H "Authorization: Bearer $BASEHIM_KEY" \
  -H 'Content-Type: application/json' -d '{"term_ids": [11]}'

Post fields you can send

FieldNotes
titleRequired on create unless content is given
contentHTML, Markdown or block JSON
content_formathtml (default), markdown or blocks
excerptPlain text summary
statusdraft, pending or published. Without publish_posts, published becomes pending
slugGenerated from the title if left out
comment_statusopen or closed
featured_media_idA media id, or null
term_idsCategory and tag ids; replaces the current set

A step-by-step version, with error handling and an image upload, is in the tutorial Publish from a script with the REST API.

Good practice

  • Give every integration its own key, with the fewest scopes, created by the least powerful account that works.
  • Keep keys out of browsers, repositories and screenshots. Revoke a leaked key at once.
  • Don't call your own site's API from inside an app. $this->api() does the same thing in-process.
  • Cache public reads. The API is fast, but a headless front end that fetches every post on every page view adds up.

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment