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 dashboardGrab 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" | jqResponse:
{
"data": [
{ "id": "post_abc", "title": "...", "status": "draft", "updatedAt": "..." }
],
"meta": { "page": 1, "limit": 10, "total": 42, "totalPages": 5 }
}Filter by status, category, search
curl -sS -H "Authorization: Bearer $JWT" \
"$GATEWAY/blog/admin/posts?status=published&category=engineering&search=react&limit=20" | jqGet one post by id
curl -sS -H "Authorization: Bearer $JWT" \
"$GATEWAY/blog/admin/posts/post_abc" | jqThe 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"]
}' | jqResponse: { "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" }' | jqWARNING
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" }' | jqPublish
curl -sS -X POST -H "Authorization: Bearer $JWT" \
"$GATEWAY/blog/admin/posts/post_xyz/publish" | jqResponse: { "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" | jqClears 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" }' | jqNOTE
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" }' | jqSlug 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" | jqThe 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" | jqGet one published post (HTML body included)
curl -sS -H "Authorization: Bearer $VLOZI_API_KEY" \
"$GATEWAY/blog/public/posts/hello-world" | jqcontent 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" | jqCounts 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))
doneIn 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-serviceCheck blog-service env directly (bypass gateway — local only)
curl -sS -H "x-gateway-key: $GATEWAY_SECRET" "http://127.0.0.1:8791/debug" | jqPlain health probe
curl -sS http://127.0.0.1:8791/health
# → BLOG SERVICE OK8. 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
contentfor 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, notidin the post body. Backend upserts. - Category
id, notnamein the post body. Resolve the id first viaGET /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.