logicspike/docs

Blog Engine

Blog System — API Cookbook

Last Updated: 2026-05-06 Status: Active

Runnable curl and fetch recipes for the most common admin and public operations. Pair this with blog-service.md when you need the full schema for an endpoint.


1. Setup

All examples assume:

  • Local dev: gateway at http://127.0.0.1:8788, JWT for admin, pk_* API key for public
  • Production: gateway at https://api.vlozi.app
# Set once, reuse everywhere
export GATEWAY=http://127.0.0.1:8788
export JWT="eyJhbGciOiJSUzI1NiIs..."          # from a logged-in dashboard session
export VLOZI_API_KEY="pk_test_abc123..."      # from the API keys dashboard

Grab a JWT during local dev: log into the dashboard, open DevTools → Application → Cookies → copy the next-auth.session-token value, then call GET /api/auth/session and read accessToken.


2. Admin — Posts

List posts (most-recent first, drafts included)

curl -sS -H "Authorization: Bearer $JWT" \
  "$GATEWAY/blog/admin/posts?page=1&limit=10&sort=updatedAt&order=desc" | jq

Response:

{
  "data": [
    { "id": "post_abc", "title": "...", "status": "draft", "updatedAt": "..." }
  ],
  "meta": { "page": 1, "limit": 10, "total": 42, "totalPages": 5 }
}
curl -sS -H "Authorization: Bearer $JWT" \
  "$GATEWAY/blog/admin/posts?status=published&category=engineering&search=react&limit=20" | jq

Get one post by id

curl -sS -H "Authorization: Bearer $JWT" \
  "$GATEWAY/blog/admin/posts/post_abc" | jq

The admin GET returns raw TipTap JSON in content — that's what the editor consumes. Public GET returns rendered HTML instead.

Create a draft

curl -sS -X POST -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  "$GATEWAY/blog/admin/posts" \
  -d '{
    "title": "Hello world",
    "content": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "First post" }] }] },
    "excerpt": "An intro post",
    "tags": ["intro", "meta"]
  }' | jq

Response: { "id": "post_xyz", "status": "draft" }. The slug is auto-generated server-side — call GET /admin/posts/:id to read it back.

Update title only

curl -sS -X PUT -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  "$GATEWAY/blog/admin/posts/post_xyz" \
  -d '{ "title": "Better title" }' | jq

WARNING

Updating title regenerates the slug. The old slug is gone — there is no redirect handling. See database.md §2.

Set a custom slug (preserve URL on title change)

curl -sS -X PUT -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  "$GATEWAY/blog/admin/posts/post_xyz" \
  -d '{ "title": "New title", "slug": "stable-slug" }' | jq

Publish

curl -sS -X POST -H "Authorization: Bearer $JWT" \
  "$GATEWAY/blog/admin/posts/post_xyz/publish" | jq

Response: { "status": "published" }. Sets published_at = NOW().

Unpublish (back to draft)

curl -sS -X POST -H "Authorization: Bearer $JWT" \
  "$GATEWAY/blog/admin/posts/post_xyz/unpublish" | jq

Clears published_at to null. Public API immediately stops returning the post.

Schedule for future publish

curl -sS -X PUT -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  "$GATEWAY/blog/admin/posts/post_xyz" \
  -d '{ "scheduledFor": "2026-06-01T10:00:00Z" }' | jq

NOTE

The auto-publish cron isn't implemented yet — status becomes scheduled but won't flip to published automatically. Tracked in blog-service.md §12.

Delete

curl -sS -X DELETE -H "Authorization: Bearer $JWT" \
  "$GATEWAY/blog/admin/posts/post_xyz"

Hard delete — no soft-delete column exists. Cascades to junction rows in blog_post_tags.


3. Admin — Categories & Tags

List categories (with post counts)

curl -sS -H "Authorization: Bearer $JWT" \
  "$GATEWAY/blog/admin/categories" | jq
[
  { "id": "cat_eng", "name": "Engineering", "slug": "engineering", "postCount": 12, "createdAt": "..." }
]

Create a category

curl -sS -X POST -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  "$GATEWAY/blog/admin/categories" \
  -d '{ "name": "Engineering" }' | jq

Slug is auto-generated. Updating the name regenerates the slug.

Delete a category

curl -sS -X DELETE -H "Authorization: Bearer $JWT" \
  "$GATEWAY/blog/admin/categories/cat_eng"

Affected posts get categoryId = NULL (FK is onDelete: SET NULL). No posts are deleted.

Tags work identically — /admin/tags

Replace categories with tags in any of the calls above. Same shape, same rules. Deleting a tag cascades to junction rows in blog_post_tags (no posts deleted).


4. Public — Read Posts (SDK consumers)

List published posts

curl -sS -H "Authorization: Bearer $VLOZI_API_KEY" \
  "$GATEWAY/blog/public/posts?page=1&limit=10" | jq

The list endpoint excludes content to keep payloads small. Use the single-post endpoint when you need the body.

Filter by category and tag

curl -sS -H "Authorization: Bearer $VLOZI_API_KEY" \
  "$GATEWAY/blog/public/posts?category=engineering&tag=react&limit=5" | jq

Get one published post (HTML body included)

curl -sS -H "Authorization: Bearer $VLOZI_API_KEY" \
  "$GATEWAY/blog/public/posts/hello-world" | jq

content is server-rendered HTML, already sanitized. The SDK runs an additional defense-in-depth pass before injecting. See sdk-security-model.md.

List categories with published-post counts

curl -sS -H "Authorization: Bearer $VLOZI_API_KEY" \
  "$GATEWAY/blog/public/categories" | jq

Counts only include posts with published_at IS NOT NULL. A category with 5 drafts and 0 published returns postCount: 0.


5. From Code — fetch Snippets

Server-side (Next.js Route Handler)

// app/api/internal/posts/route.ts
import { cookies } from "next/headers"
import { getToken } from "next-auth/jwt"
 
export async function GET() {
    const token = await getToken({ req: { cookies: cookies() } as never })
    const res = await fetch(`${process.env.NEXT_PUBLIC_GATEWAY_URL}/blog/admin/posts`, {
        headers: { Authorization: `Bearer ${token?.access_token}` },
    })
    return Response.json(await res.json())
}

Server-side (any Node — public endpoint)

const res = await fetch(`${process.env.VLOZI_BASE_URL}/blog/public/posts?limit=10`, {
    headers: { Authorization: `Bearer ${process.env.VLOZI_API_KEY}` },
})
const { data, meta } = await res.json()

Browser (React, public endpoint)

Don't roll your own — use the SDK:

import { VloziClient } from "@vlozi/blog"
const client = new VloziClient({
    apiKey: process.env.NEXT_PUBLIC_VLOZI_API_KEY!,
    baseUrl: process.env.NEXT_PUBLIC_VLOZI_BASE_URL!,
})
const posts = await client.blog.list({ limit: 10 })

The SDK handles caching, retries, error typing, and SWR. See sdk-reference.md §2.


6. Common Multi-Step Recipes

Create + publish in one flow

# 1. Create draft, capture id
POST_ID=$(curl -sS -X POST -H "Authorization: Bearer $JWT" \
    -H "Content-Type: application/json" \
    "$GATEWAY/blog/admin/posts" \
    -d '{ "title": "Quick post", "content": {} }' | jq -r .id)
 
# 2. Publish
curl -sS -X POST -H "Authorization: Bearer $JWT" \
    "$GATEWAY/blog/admin/posts/$POST_ID/publish"

Move a post between categories

# Get current state
curl -sS -H "Authorization: Bearer $JWT" \
    "$GATEWAY/blog/admin/posts/post_xyz" | jq '.categoryId'
 
# Reassign (or null)
curl -sS -X PUT -H "Authorization: Bearer $JWT" \
    -H "Content-Type: application/json" \
    "$GATEWAY/blog/admin/posts/post_xyz" \
    -d '{ "categoryId": "cat_other" }'

To uncategorize: -d '{ "categoryId": null }'.

Replace all tags on a post

curl -sS -X PUT -H "Authorization: Bearer $JWT" \
    -H "Content-Type: application/json" \
    "$GATEWAY/blog/admin/posts/post_xyz" \
    -d '{ "tags": ["react", "performance", "tutorial"] }'

The server does delete-all-then-insert inside a transaction. Tags by name — backend upserts by tenant.

Walk every published post (paginate to end)

PAGE=1
while :; do
    RES=$(curl -sS -H "Authorization: Bearer $VLOZI_API_KEY" \
        "$GATEWAY/blog/public/posts?page=$PAGE&limit=100")
    echo "$RES" | jq -r '.data[].slug'
    LAST=$(echo "$RES" | jq -r '.meta.totalPages')
    [ "$PAGE" -ge "$LAST" ] && break
    PAGE=$((PAGE+1))
done

In code, the SDK has client.blog.archive({ maxPosts: 5000 }) which does this and groups by year/month. See sdk-reference.md §2.


7. Health & Debug

Check blog-service is alive (gateway-routed)

curl -sS -H "Authorization: Bearer $JWT" "$GATEWAY/blog/debug" | jq
# DEBUG=true required on both gateway and blog-service

Check blog-service env directly (bypass gateway — local only)

curl -sS -H "x-gateway-key: $GATEWAY_SECRET" "http://127.0.0.1:8791/debug" | jq

Plain health probe

curl -sS http://127.0.0.1:8791/health
# → BLOG SERVICE OK

8. Common Errors

Status Body excerpt What to check
401 Missing token / Invalid token JWT expired, JWT_PUBLIC_KEY mismatch on gateway. See blog-workflow.md §6.
403 Direct access forbidden. Use the gateway. GATEWAY_SECRET mismatch. See deployment.md §6.
403 Missing required permission: blog:posts.publish User's JWT doesn't grant that scope. See permissions.md.
400 Tenant ID is required requireTenant rejected — JWT didn't have a tenant claim.
400 Zod issue list Body failed validation. The error response includes the field path.
404 Not Found Either missing or — for admin routes — belongs to a different tenant. The 404 is intentional (information disclosure prevention).
409 Conflict on slug Slug auto-resolution exhausted 100 attempts. Pass an explicit slug to override.

9. Tips

  • Use jq -r '.id' to chain create → publish without copy-paste.
  • The list endpoint excludes content for performance. Always use the single-post endpoint to read the body.
  • Admin returns TipTap JSON, public returns HTML. Don't try to render TipTap JSON on a public page — it's not safe and not the contract.
  • Tag name, not id in the post body. Backend upserts.
  • Category id, not name in the post body. Resolve the id first via GET /admin/categories.
  • Tenant isolation is at the query layer — there is no admin route to read another tenant's data even if you have all permissions on your own.
Blog Engine