Introduction

The Andishi API lets you read and write everything in a workspace — articles, comments, video, podcasts, categories, media and subscribers — over plain JSON. It's the same data your studio uses; nothing is a second-class citizen.

Product API base path is /v1/api(studio session routes use /v1). Example: https://api.example.com/v1/api. Every request and response body is JSON. API keys are workspace-scoped — no extra workspace header is required.

Setup & API keys

Before the first request you need one thing: a key. Which kind depends on where your code runs — the wrong choice is the most common way to leak a credential.

Base URL

Every request goes to https://api.andishi.nexuslabsstudio.com. There is nothing to install or deploy — your workspace is already there, and your API key identifies it. Paths below are relative to that host, so the articles endpoint in full is https://api.andishi.nexuslabsstudio.com/v1/api/articles.

The three surfaces

Requests land on one of three paths, and they authenticate differently. Most integrations only need the middle one.

PathAuthenticates withUse it for
/v1Session cookie or JWTThe studio UI itself. Not intended for integrations.
/v1/apiSecret key andishi_live_…Your backend: reading and writing workspace content.
/v1/publicPublishable key pk_…Browsers. Published site-builder content only.

Creating a secret key

In the studio, open DevelopersNew API Key. Give it a name you will recognise later, tick the scopes it needs, and create it. Only workspace owners and admins can mint keys.

POST /v1/workspaces/:id/api-keys — response
{
  "api_key": {
    "id": "9f1c…",
    "name": "Marketing site",
    "key_prefix": "andishi_live_51fA9",
    "scopes": [
      "articles:read",
      "media:read"
    ],
    "created_at": "2026-01-14T09:12:00Z"
  },
  "secret": "andishi_live_51fA9c…"
}

The secret field appears in this one response and nowhere else. Andishi stores only a hash of it and the visible prefix, so it cannot be shown to you again or recovered by support. Put it in your environment config immediately; if you lose it, revoke the key and mint another.

Keys are bound to the workspace that created them, which is why /v1/apineeds no workspace header — the key already says which workspace it speaks for.

Secret keys vs publishable keys

Secret keyPublishable key
Looks likeandishi_live_…pk_live_…
Created inDevelopers → New API KeyAutomatically, with each Site
Scope of accessEvery module its scopes allow, read and writePublished page content, one site, read only
Safe in a browser bundle?NoYes — it is designed for it
If it leaksRevoke it now; it can write to your workspaceRotate it; a reader only ever saw public content

Where content can be read from

This is the distinction that catches people out. A publishable key reads site-builder page content only — the fields you wrapped with <AE> from @andishi/react. Articles, videos, podcasts, categories, media and subscribers are available only on /v1/api, behind the secret key.

A secret key is a read and write credential for the whole workspace, so it must never reach a browser bundle. In practice that means a purely client-side site cannot list your articles directly — fetch them somewhere the key stays private and pass the result down:

If your site isFetch articles in
Next.js / Remix / SvelteKitA server component, loader, or route handler
Static (Astro, Hugo, 11ty)The build step
A client-only SPA (Vite, CRA)A small backend or serverless function that proxies to Andishi

Your first request

curl
curl "https://api.andishi.nexuslabsstudio.com/v1/api/articles?limit=5" \
  -H "Authorization: Bearer $ANDISHI_API_KEY"
node — server-side only
const res = await fetch(
  'https://api.andishi.nexuslabsstudio.com/v1/api/articles?limit=5',
  { headers: { Authorization: `Bearer ${process.env.ANDISHI_API_KEY}` } },
);
if (!res.ok) throw new Error(`andishi: ${res.status}`);
const { articles, pagination } = await res.json();

Rotating and revoking

Revoking a key in Developers takes effect on the next request — there is no cache to wait out. Keys cannot be edited, so changing scopes means creating a replacement and revoking the old one. Publishable keys are rotated from Sites → your site; the previous value stops resolving immediately, so redeploy the site with the new one.

Authentication

Every request carries an API key as a Bearer token. Keys are created under Developers in your workspace (admins only), shown exactly once, and bound to that workspace.

curl
curl https://your-app/v1/api/articles \
  -H "Authorization: Bearer andishi_live_51fA9c…" \
  -H "Content-Type: application/json"

Scopes

Each key carries a list of scopes, e.g. articles:read, articles:write, comments:write. A request fails with 403 missing_scope if the key lacks the scope the endpoint needs. Issue narrow keys per integration — a public website only ever needs read scopes.

Pagination & errors

Every endpoint follows the same two shapes, so you only need to learn them once.

List responses

GET /v1/api/articles?limit=2
{
  "articles": [
    {
      "id": "…",
      "title": "Elections 2027",
      "status": "published"
    },
    {
      "id": "…",
      "title": "Budget explainer",
      "status": "draft"
    }
  ],
  "pagination": {
    "total": 84,
    "limit": 2,
    "offset": 0
  }
}

Use limit (max 100) and offset query params to page through results. Collection keys match the resource (articles, comments, …).

Errors

400 example
{
  "error": "title is required",
  "code": "title_required"
}
FieldTypeNotes
401 invalid_api_key optionalstatusNo key, or the key is wrong / revoked.
403 missing_scope optionalstatusThe key is valid but lacks the needed scope.
404 not_found optionalstatusNo resource with that id in this workspace.
409 slug_taken optionalstatusThe slug collides with an existing item.
400 title_required / invalid_request optionalstatusThe request body is missing a required field.

Articles

Long-form written content — the writing module's core resource, including SEO fields and publish state.

GET/v1/api/articlesList articles
POST/v1/api/articlesCreate an article
GET/v1/api/articles/:idFetch one
PATCH/v1/api/articles/:idUpdate fields
DELETE/v1/api/articles/:idDelete

Create a published article

POST /v1/api/articles
{
  "title": "Elections 2027: what to watch",
  "body": "<p>The race begins…</p>",
  "status": "published",
  "tags": [
    "politics",
    "elections"
  ]
}

Body fields

FieldTypeNotes
title requiredstringFalls back to a slugified title if slug is omitted.
slug optionalstringURL segment; must be unique per language.
body optionalstring (HTML)Rich-text content, same format the editor produces.
subtitle optionalstringDeck / standfirst.
status optional"draft" | "in_review" | "scheduled" | "published" | "archived"Defaults to draft.
category_id optionaluuidOne of your Categories.
tags optionalstring[]Free-form tags.
locale optionalstringDefaults to the workspace’s default language.

Response

201 Created
{
  "article": {
    "id": "a1b2c3d4-…",
    "title": "Elections 2027: what to watch",
    "slug": "elections-2027-what-to-watch",
    "status": "published",
    "published_at": "2026-07-12T09:00:00Z",
    "read_time_minutes": 3,
    "comment_count": 0
  }
}

Comments

Reader comments, threaded, with a moderation status. Great for pulling in comments from your own front-end or an external form.

GET/v1/api/commentsList (filter by article, status)
POST/v1/api/commentsCreate a comment
PATCH/v1/api/comments/:idApprove / mark spam / edit
DELETE/v1/api/comments/:idDelete

Submit a comment (lands in the moderation queue)

POST /v1/api/comments
{
  "article_id": "a1b2c3d4-…",
  "author_name": "Amina",
  "content": "Great breakdown, thank you."
}
FieldTypeNotes
article_id requireduuidThe article being commented on.
author_name requiredstringDisplay name.
content requiredstringComment body.
parent_id optionaluuidReply to another comment.
status optional"pending" | "approved"Defaults to pending unless you pass approved.

Approve one

PATCH /v1/api/comments/:id
{
  "status": "approved"
}

Videos

Video titles, whether streamed through Bunny Stream or linked from an external source.

GET/v1/api/videosList videos
POST/v1/api/videosCreate a video record

Attach an already-uploaded Bunny video

POST /v1/api/videos
{
  "title": "Behind the Scenes: Episode 1",
  "bunny_video_id": "8f3e2c1a-…",
  "status": "published"
}
FieldTypeNotes
title requiredstring
bunny_video_id optionalstringGUID from the Bunny Stream upload flow.
source_url optionalstringExternal URL, for content not hosted on Bunny.
poster_url optionalstringThumbnail image URL.
status optional"draft" | "published" | "archived"Defaults to draft.

Podcast episodes

Episodes belong to a show created in the studio; the API publishes into an existing show.

GET/v1/api/podcast-episodesList episodes (filter by show)
POST/v1/api/podcast-episodesPublish an episode
POST /v1/api/podcast-episodes
{
  "show_id": "f4a1…",
  "title": "Ep. 12 — The Budget, Explained",
  "audio_url": "https://…/ep12.mp3",
  "episode_number": 12,
  "status": "published"
}

Categories

The built-in taxonomy. (Custom taxonomies you define in the studio aren't yet exposed over the API — ask us if you need them.)

GET/v1/api/categoriesList categories
POST/v1/api/categoriesCreate a category
POST /v1/api/categories
{
  "name": "Culture",
  "accent_color": "#8b5cf6"
}

Media

Read-only for now: list what's already in the media library (images, video, audio) to reference elsewhere.

GET/v1/api/mediaList media items (filter by type)

Subscribers

Newsletter subscribers — useful for wiring up a signup form on your own site.

GET/v1/api/subscribersList subscribers
POST/v1/api/subscribersAdd a subscriber (upsert by email)
POST /v1/api/subscribers
{
  "email": "reader@example.com",
  "name": "Amina O."
}

Webhooks

Subscribe to events instead of polling. Every payload is signed so you can verify it really came from Andishi.

FieldTypeNotes
article.published optionaleventFires when an article’s status becomes published.
article.updated optionaleventFires when a published article changes.
article.unpublished optionaleventFires when a published article is unpublished.
page.updated optionaleventFires when a visually-edited page section saves.

Verifying a payload

node
const crypto = require('crypto')

const expected = crypto
  .createHmac('sha256', WEBHOOK_SECRET)
  .update(rawRequestBody)
  .digest('hex')

if (expected !== req.headers['x-andishi-signature']) {
  throw new Error('Invalid signature')
}

SDKs & libraries

A small, dependency-free TypeScript client ships in the repo — copy it in, or call the REST endpoints directly from any language.

TypeScript
import { Andishi } from 'andishi'

const andishi = new Andishi(process.env.ANDISHI_KEY, {
  baseUrl: 'https://your-app', // calls {baseUrl}/v1/api/…
})

await andishi.articles.create({ title: 'Hi', status: 'published' })
await andishi.comments.moderate(commentId, 'approved')
await andishi.subscribers.add({ email: 'reader@example.com' })

No official library for your language yet? The API is plain REST + JSON — any HTTP client works.