BlogCore API Reference
A multi-tenant Blog-as-a-Service backend. Power any number of frontend apps — React, Vue, mobile, or plain HTML — from one centralized blog API.
Introduction
BlogCore is a centralized blog API backend you deploy once and connect to any number of client projects. Instead of rebuilding a blog system from scratch for every product, you integrate via API key. One backend, unlimited frontends — a marketing site, a SaaS dashboard, a mobile app, and a partner's storefront can all publish through the same BlogCore instance while staying completely isolated from one another.
https://blogcore.jamiuadewaleyusuf.com/api/v1. Every example on this page uses that exact URL — copy it directly into your own environment configuration.
Who this page is for. If you're evaluating BlogCore for a project, the Introduction and Core Concepts sections below explain the model in plain terms. If you're implementing the integration, jump to Quick Start, then the endpoint reference for the resource you need.
Core Concepts
Five ideas to understand before touching an endpoint.
1. Platforms (tenants)
A Platform represents one connected project — your client's marketing site, your SaaS blog, your personal site. Every piece of data (posts, users, media, analytics) belongs to exactly one platform. Platforms cannot see or affect each other's data.
2. API keys
Each platform has one or more API keys. A key has two parts:
| Part | Example | Usage | |
|---|---|---|---|
Key |
Optional | Sent in every request — identifies the platform. | |
Secret |
Optional | Proves the caller is authorized. Never expose in browser JavaScript. |
3. Platform users
BlogCore does not replace your authentication system. For user-scoped operations, you pass your own user's identity via request headers. BlogCore creates a lightweight PlatformUser record the first time it sees a new identity, and tracks that user's posts, role, and activity from then on.
4. Roles & permissions
Every platform user has a role: owner, editor, author, or viewer. Roles determine what API actions that user can perform — publishing, approving posts, deleting media, managing webhooks, and so on. See the full Permissions Reference.
5. Post status state machine
[draft] ▸ [pending_review] ▸ [in_review] ▸ [approved] ▸ [published]
│ │
│ (workflow_enabled = false: skip straight to approved/published) │
└──────────────────────────────────────────────▸──▸
[rejected] ◂─── (from review)
[archived] ◂─── (from any state)A post can also be scheduled: set scheduled_at to a future timestamp while the post is draft or approved. A background job checks every five minutes and publishes it automatically once that time arrives — you don't need to poll or trigger anything yourself.
Quick Start
Get your first list of posts in under five minutes.
Get your API credentials
Create a platform via the admin endpoint (see Admin — Platforms). You'll receive a key and a secret. The secret is shown exactly once — save it immediately.
Add credentials to your environment
BLOGCORE_URL=https://blogcore.jamiuadewaleyusuf.com BLOGCORE_API_KEY=pk_live_xxxxxxxxxxxx BLOGCORE_API_SECRET=sk_xxxxxxxxxxxx
Fetch published posts
curl "https://blogcore.jamiuadewaleyusuf.com/api/v1/posts?status=published" \ -H "X-API-Key: pk_live_xxxxxxxxxxxx" \ -H "X-API-Secret: sk_xxxxxxxxxxxx"
const res = await fetch(`${process.env.BLOGCORE_URL}/api/v1/posts?status=published`, { headers: { 'X-API-Key': process.env.BLOGCORE_API_KEY, 'X-API-Secret': process.env.BLOGCORE_API_SECRET, } }); const { data, meta } = await res.json(); // data = array of posts, meta = { current_page, per_page, total }
$response = Http::withHeaders([
'X-API-Key' => 'pk_live_xxxx',
'X-API-Secret' => 'sk_xxxx',
])->get('https://blogcore.jamiuadewaleyusuf.com/api/v1/posts', [
'status' => 'published',
])->json();import requests
r = requests.get(
"https://blogcore.jamiuadewaleyusuf.com/api/v1/posts",
params={"status": "published"},
headers={"X-API-Key": "pk_live_xxxx", "X-API-Secret": "sk_xxxx"},
)
posts = r.json()["data"]The response looks like this
{
"success": true,
"data": [{
"id": "01JXXXXXXXXXXXXXXX",
"title": "My First Blog Post",
"slug": "my-first-blog-post",
"excerpt": "A short summary...",
"content": "<p>Full HTML content...</p>",
"content_format": "html",
"status": "published",
"reading_time": 4,
"published_at": "2026-01-15T10:30:00Z",
"author": { "id": "...", "name": "Adewale", "email": "..." },
"featured_image": { "url": "https://res.cloudinary.com/...", "alt_text": "..." },
"categories": [{ "id": "...", "name": "Laravel", "slug": "laravel" }],
"tags": [{ "name": "php", "slug": "php" }],
"seo": { "title": "...", "description": "...", "keywords": [] }
}],
"meta": { "current_page": 1, "per_page": 15, "total": 42, "last_page": 3 }
}
Authentication — API Keys
Every platform API request requires two headers that identify and authorize your platform.
| Header | Value | Required | |
|---|---|---|---|
X-API-Key |
Required | Your platform's public key (pk_live_...). |
|
X-API-Secret |
Required | Your platform's secret (sk_...). |
|
Content-Type |
Optional | application/json — required for POST/PUT/PATCH. |
Key permission scoping
You can create multiple keys per platform, each with a different permission scope. For example, a restricted read-only key that's safe to use in a public-facing frontend proxy:
curl -X POST https://blogcore.jamiuadewaleyusuf.com/api/v1/admin/platforms/{id}/keys \
-H "X-Super-Admin-Secret: <your admin secret>" \
-H "Content-Type: application/json" \
-d '{
"name": "Public Frontend (read-only)",
"permissions": ["posts.read", "analytics.read"]
}'const { data, secret } = await fetch(`${BASE}/api/v1/admin/platforms/${platformId}/keys`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Super-Admin-Secret': process.env.BLOGCORE_SUPER_ADMIN_SECRET, }, body: JSON.stringify({ name: "Public Frontend (read-only)", permissions: ["posts.read"], }), }).then(r => r.json()); // SAVE secret NOW — it is only shown once
User Identity
BlogCore does not replace your authentication system. For user-scoped operations (creating posts, AI generation, approvals), pass your user's identity via request headers. BlogCore will auto-create a platform user record the first time it encounters a new identity.
| Header | When to use | Example | |
|---|---|---|---|
X-User-Id |
Optional | When your system has a stable user ID. | |
X-User-Email |
Optional | Alternative to ID — email as identifier. | |
X-User-Name |
Optional | Optional display name used for auto-registration. |
Example: Next.js with NextAuth
// app/api/blog/posts/route.ts import { getServerSession } from "next-auth" export async function POST(req: Request) { const session = await getServerSession(); const body = await req.json(); const res = await fetch(`${process.env.BLOGCORE_URL}/api/v1/posts`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.BLOGCORE_API_KEY!, 'X-API-Secret': process.env.BLOGCORE_API_SECRET!, 'X-User-Id': session.user.id, // your user's ID 'X-User-Email': session.user.email, 'X-User-Name': session.user.name, }, body: JSON.stringify(body), }); return Response.json(await res.json()); }
Checking what the current identity can do
Before rendering write actions in your own UI (a Publish button, an Approve/Reject panel), ask BlogCore what the acting identity is actually allowed to do rather than assuming. This is the same check BlogCore itself enforces server-side, so your UI and your permissions never drift out of sync.
Requires the standard X-API-Key / X-API-Secret headers, plus X-User-Id or X-User-Email to identify the acting user. Returns the platform, the resolved user, and their effective permission list (the intersection of what the API key allows and what the user's role allows).
{
"success": true,
"data": {
"platform": { "id": "01JXXXXX", "name": "Acme Corp Blog", "workflow_enabled": true },
"user": { "id": "01JXXXXX", "name": "Adewale", "email": "adewale@example.com", "role": "editor" },
"permissions": ["posts.create", "posts.read", "posts.update", "posts.publish", "media.upload", "analytics.read"]
}
}
Admin — Platform Management
X-Super-Admin-Secret header matching the SUPER_ADMIN_SECRET value configured on this server. These endpoints provision tenants and issue live credentials — they are for whoever operates this BlogCore instance, not for individual platform integrations. Keep the secret out of any client-side code and out of source control.
Request headers
| Header | Value | ||
|---|---|---|---|
X-Super-Admin-Secret |
Optional | Required on every request in this section. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Required | Display name for the platform. |
domain |
string | Optional | Origin domain, for reference only. |
workflow_enabled |
boolean | Optional | Enable the post approval workflow. Default: false. |
owner_email |
string | Optional | If given (with owner_external_id and/or owner_name), immediately registers this identity as the platform's owner — skips the lazy auto-registration that would otherwise default a new user to the limited author role. |
owner_external_id |
string | Optional | The owner's ID in your own system, matched against X-User-Id on later requests. |
owner_name |
string | Optional | Display name for the owner. |
curl -X POST https://blogcore.jamiuadewaleyusuf.com/api/v1/admin/platforms \
-H "X-Super-Admin-Secret: <your admin secret>" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Corp Blog",
"workflow_enabled": true,
"owner_email": "you@acme.com",
"owner_external_id": "user_123",
"owner_name": "Adewale"
}'const res = await fetch(`${BASE}/api/v1/admin/platforms`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Super-Admin-Secret': process.env.BLOGCORE_SUPER_ADMIN_SECRET, }, body: JSON.stringify({ name: "Acme Corp Blog", workflow_enabled: true }), }); const { data, api_key } = await res.json(); // api_key.key = "pk_live_xxxx" — store in env // api_key.secret = "sk_xxxx" — store in env now, never shown again
Response
{
"success": true,
"data": { "id": "01JXXXXX", "name": "Acme Corp Blog", "slug": "acme-corp-blog", "status": "active" },
"api_key": {
"key": "pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"secret": "sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"note": "Store the secret now — it cannot be retrieved again."
}
Returns a paginated list of every platform with post and user counts. Query params: per_page (default 20), page.
All fields optional (partial update). To suspend a platform: {"status": "suspended"} — every API call from that platform immediately returns PLATFORM_SUSPENDED. To toggle workflow: {"workflow_enabled": true}.
Admin — API Key Management
Returns every API key for the platform. The secret_hash is never returned — only the key string, name, permissions, and usage metadata.
The response includes the plaintext secret exactly once, the same way platform creation does.
Immediately deactivates the key. Any request using it afterward returns INVALID_API_KEY. Revoking is permanent — issue a new key if the integration needs to keep working.
Posts
The core resource. All post endpoints require the standard X-API-Key / X-API-Secret headers; write actions also require X-User-Id or X-User-Email.
Query parameters
| Param | Type | Description | |
|---|---|---|---|
status |
string | Optional | Filter by status: draft, pending_review, in_review, approved, published, rejected, archived. |
visibility |
string | Optional | public, unlisted, or private. |
category |
string | Optional | Category slug. |
tag |
string | Optional | Tag slug. |
search |
string | Optional | Matches against title and excerpt. |
author_id |
string | Optional | Filter by author's platform user ID. |
sort_field |
string | Optional | created_at, published_at, title, or view_count. Default created_at. |
sort_dir |
string | Optional | asc or desc. Default desc. |
per_page |
integer | Optional | Default 15. |
data, links, meta) directly — it is the one endpoint on this API that is not wrapped in the usual {success, data} envelope. Every other endpoint on this page is.
Request body
401 USER_REQUIRED.
Returns the post regardless of its status — use this for admin/editor views where you need to see drafts. For public-facing pages, prefer the slug lookup below and check status === "published" yourself.
The endpoint a public blog page should call — clean, permanent URLs don't need to know the post's internal ID. Returns 404 NOT_FOUND if no post with that slug exists on this platform.
curl https://blogcore.jamiuadewaleyusuf.com/api/v1/posts/slug/my-first-blog-post \ -H "X-API-Key: pk_live_xxxx" \ -H "X-API-Secret: sk_xxxx"
Same body shape as create, all fields optional. Every update automatically saves the previous title and content as a revision before applying the change, so nothing is ever lost. Add a change_summary string to label that revision for your own audit trail.
Soft-deletes the post — it stops appearing in list/show responses immediately but isn't purged from the database.
Approval Workflow
Which endpoint to use?
Every platform has workflow_enabled set to true or false. This decides which of the two publishing paths below applies to it.
posts.publish calls POST /posts/{id}/publish directly. There is no review step.Requires posts.publish. Only works if the post is currently draft or approved — returns 422 WORKFLOW_VIOLATION otherwise.
Requires posts.submit_review. Returns 422 WORKFLOW_DISABLED if the platform doesn't have workflow enabled — use the publish endpoint above instead.
Requires approvals.review. Optional body: {"comment": "..."}.
Requires approvals.review. Body: {"comment": "..."} — the comment is required here so the author knows what to fix.
Returns each stage's order, name, status, and reviewer.
Post Revisions
Every update to a post's title or content is automatically snapshotted before the change is applied — there is no separate "enable versioning" step.
Returns each revision's title/content snapshot, the editor who made it, an optional change_summary, and when it was created — newest first.
Requires posts.update. The post's current title/content is saved as a new revision first, so restoring is itself non-destructive — you can always step back through history in either direction.
Categories
Categories support one level of nesting via parent_id. A post can belong to multiple categories.
Returns the full tree (top-level categories with their children eager-loaded), not paginated.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Required | Max 100 characters. Slug is generated automatically. |
description |
string | Optional | Optional. |
parent_id |
string | Optional | Nest under an existing category. |
sort_order |
integer | Optional | Controls display order. |
All fields optional — the same shape as create.
Posts already assigned to this category simply lose the association — they are not deleted.
Media — Cloudinary
Uploads are stored on Cloudinary under a per-platform folder. BlogCore returns the secure URL plus three auto-generated size variants (thumbnail, medium, large) so you rarely need to do your own image resizing.
Requires media.upload. Send as multipart/form-data, not JSON.
| Field | Type | Required | Description |
|---|---|---|---|
file |
file | Required | jpg, jpeg, png, gif, webp, pdf, mp4, or webm. Max size is configurable server-side (default 10 MB). |
alt_text |
string | Optional | Max 255 characters. |
caption |
string | Optional | Max 500 characters. |
curl -X POST https://blogcore.jamiuadewaleyusuf.com/api/v1/media \ -H "X-API-Key: pk_live_xxxx" -H "X-API-Secret: sk_xxxx" \ -H "X-User-Id: user_123" \ -F "file=@cover.jpg" \ -F "alt_text=A sunset over the harbor"
{
"success": true,
"data": {
"id": "01JXXXXX",
"secure_url": "https://res.cloudinary.com/.../cover.jpg",
"resource_type": "image",
"width": 1600, "height": 900,
"variants": {
"thumbnail": { "url": "...", "width": 150, "height": 150 },
"medium": { "url": "...", "width": 600, "height": 400 },
"large": { "url": "...", "width": 1200, "height": 800 }
}
}
}
Pass the returned id as featured_image_id when creating or updating a post.
Query params: type (image, video, or raw), per_page (default 24).
Requires media.upload. Only alt_text and caption are editable — the file itself is immutable; upload a new one to replace it.
Requires media.delete. Removes the asset from Cloudinary and the database. Any post still referencing this image as its featured image will simply show no image.
Analytics
Lightweight, first-party view tracking — no third-party script on your pages, no cookies required. Send an event when a reader views a post; BlogCore aggregates it into daily summaries automatically.
| Field | Type | Required | Description |
|---|---|---|---|
event |
string | Required | view, unique_view, share, scroll_50, or scroll_100. |
post_id |
string | Optional | The post this event relates to. |
session_id |
string | Optional | Your own session/visitor identifier, for de-duplicating unique_view. |
referrer |
string | Optional | Where the reader came from. |
device_type |
string | Optional | desktop, mobile, tablet, or unknown. |
Processed asynchronously by default — this endpoint returns 202 Accepted immediately and never blocks page rendering. It's safe to fire this from a client-side effect on your blog post page; it only needs the platform's API key, not a user identity.
Requires analytics.read. Query param days (default 30). Returns total views/unique views/shares for the period, the top 10 posts by views, and a daily breakdown.
Requires analytics.read. Same days param. Adds a breakdown by device type for that specific post.
AI Features — powered by Groq
Every AI endpoint requires ai.use and, since generated content is attributed to a real author, a user identity header. Each platform has a monthly token quota (default 500,000, configurable per-platform) — once it's reached, requests return 429 AI_LIMIT_EXCEEDED until the quota resets on the 1st of the month.
AI endpoints at a glance
| Endpoint | What it does | Input | |
|---|---|---|---|
POST /ai/generate |
Optional | Writes a full draft post from a topic. | |
POST /ai/rewrite |
Optional | Rewrites existing content to a given instruction. | |
POST /ai/seo-optimize |
Optional | Suggests an SEO title, description, and keywords. | |
POST /ai/summarize |
Optional | Condenses content into a 2–3 sentence excerpt. | |
POST /ai/suggest-titles |
Optional | Returns alternative title options. | |
POST /ai/suggest-tags |
Optional | Returns suggested tag names. | |
POST /ai/moderate |
Optional | Flags unsafe or policy-violating content before publish. |
Generate a Post
| Field | Type | Required | Description |
|---|---|---|---|
topic |
string | Required | What the post should be about. |
tone |
string | Optional | professional, casual, educational, persuasive, or humorous. |
length |
string | Optional | short (~400–600 words), medium (~800–1200), or long (~1800–2500). |
keywords |
string[] | Optional | Target keywords to weave in. |
save_as_draft |
boolean | Optional | If true, immediately saves the result as a draft post instead of just returning it. |
{
"success": true,
"data": {
"post": {
"title": "...", "content": "<h2>...</h2><p>...</p>",
"excerpt": "...", "seo_title": "...", "seo_description": "...",
"seo_keywords": ["..."], "suggested_tags": ["..."]
},
"usage": { "prompt_tokens": 80, "completion_tokens": 620, "total_tokens": 700 },
"saved_post_id": null
}
}
Content is generated as HTML. Review it before publishing — treat the AI as a fast first draft, not a final copy.
Writing Tools
| Field | Type | Required | Description |
|---|---|---|---|
content |
string | Required | Or use post_id to rewrite an existing post's content instead. |
instruction |
string | Required | e.g. "make this more concise and conversational". |
Returns { content, usage }.
Body: { content } or { post_id }. Returns { summary, usage }.
Body: { content } or { post_id }. Returns { titles: string[], usage }.
Body: { content } or { post_id }. Returns { tags: string[], usage } — merge these with any tags the author already added rather than replacing them.
SEO Optimizer
| Field | Type | Required | Description |
|---|---|---|---|
content |
string | Required | Or post_id. |
title |
string | Optional | The post's current title, for context. |
{
"success": true,
"data": {
"seo": {
"seo_title": "...", "seo_description": "...",
"seo_keywords": ["..."], "suggestions": ["Add a primary keyword to the first paragraph", "..."]
},
"usage": { "total_tokens": 210 }
}
}
Moderation
Body: { content } or { post_id }.
{
"success": true,
"data": {
"moderation": {
"is_safe": true, "confidence": 0.97,
"flags": { "hate_speech": false, "violence": false, "spam": false, "adult_content": false, "misinformation": false }
},
"usage": { "total_tokens": 140 }
}
}
Run this before publishing user-submitted or AI-generated content you haven't personally reviewed.
Webhooks
Register a URL to receive a POST request whenever something happens on your platform — a post is published, a file is uploaded, and so on — instead of polling the API.
webhooks.manage permission, held by the owner and editor roles by default. Managing webhooks is treated as an elevated action, the same as deleting media or approving posts.
The response includes the plaintext secret exactly once — save it, you'll need it to verify delivered payloads.
All fields optional: name, url, events, is_active.
The old secret stops working immediately. Update your receiver with the new one before rotating in production.
is_active if events stop arriving.
Webhooks — Verification & Events
Verifying the signature
Every webhook delivery includes an X-BlogCore-Signature header in the format sha256=<hex> — an HMAC-SHA256 of the raw JSON body, signed with the webhook's own secret. Always verify this before processing, to reject spoofed requests.
// app/api/blogcore-events/route.ts import crypto from 'crypto' export async function POST(req: Request) { const rawBody = await req.text(); const signature = req.headers.get('x-blogcore-signature') ?? ''; const expected = 'sha256=' + crypto .createHmac('sha256', process.env.BLOGCORE_WEBHOOK_SECRET!) .update(rawBody) .digest('hex'); // Use timingSafeEqual to prevent timing attacks const valid = crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); if (!valid) return Response.json({ error: 'Bad signature' }, { status: 401 }); const payload = JSON.parse(rawBody); switch (payload.event) { case 'post.published': await revalidateBlogPath(`/blog/${payload.data.slug}`); break; case 'post.rejected': await notifyAuthorByEmail(payload.data.author.email, 'Your post was rejected'); break; } return Response.json({ ok: true }); // must return 2xx within 10s }
// routes/api.php Route::post('/blogcore-events', function(Request $request) { $signature = $request->header('X-BlogCore-Signature', ''); $expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), config('services.blogcore.webhook_secret')); if (!hash_equals($expected, $signature)) { return response()->json(['error' => 'Bad signature'], 401); } $payload = $request->json()->all(); match ($payload['event']) { 'post.published' => dispatch(new ClearBlogCache($payload['data']['slug'])), 'post.rejected' => dispatch(new NotifyAuthorOfRejection($payload['data'])), default => null, }; return response()->json(['ok' => true]); })->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
import hmac, hashlib from flask import Flask, request, abort, jsonify app = Flask(__name__) WEBHOOK_SECRET = b"your_webhook_secret_here" @app.route("/blogcore-events", methods=["POST"]) def handle_webhook(): sig = request.headers.get("X-BlogCore-Signature", "") expected = "sha256=" + hmac.new(WEBHOOK_SECRET, request.data, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): abort(401, "Bad signature") payload = request.get_json() event = payload["event"] if event == "post.published": clear_cache(payload["data"]["slug"]) elif event == "post.rejected": send_rejection_email(payload["data"]) return jsonify({"ok": True})
const express = require('express'); const crypto = require('crypto'); const app = express(); // Must use the raw body for HMAC — parse before any JSON middleware app.post('/blogcore-events', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.headers['x-blogcore-signature'] ?? ''; const expected = 'sha256=' + crypto .createHmac('sha256', process.env.BLOGCORE_WEBHOOK_SECRET) .update(req.body) .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return res.status(401).json({ error: 'Bad signature' }); } const payload = JSON.parse(req.body.toString()); // handle payload.event ... res.json({ ok: true }); });
Webhook payload structure
{
"event": "post.published",
"platform_id": "01JXXXXXXXXXXXXXXX",
"timestamp": "2026-01-15T10:30:00Z",
"data": {
"id": "01JXXXXXXXXXXXXXXX",
"title": "Getting Started with Laravel Queues",
"slug": "getting-started-with-laravel-queues",
"status": "published",
"published_at": "2026-01-15T10:30:00Z",
"author": { "id": "...", "name": "Adewale", "email": "..." }
}
}
All events reference
| Event | When it fires | Common use case | |
|---|---|---|---|
post.created |
Optional | A new post draft is saved — notify editors of new content in the queue. | |
post.updated |
Optional | A post's content or metadata changes — invalidate a cache entry. | |
post.submitted |
Optional | Author submits for approval — email editors that a post awaits review. | |
post.approved |
Optional | All approval stages pass — notify the author their post is ready. | |
post.rejected |
Optional | Post rejected at any stage — email the author with the rejection comment. | |
post.published |
Optional | Post goes live — trigger ISR revalidation, post to social media. | |
post.deleted |
Optional | Post is soft-deleted — remove from search index, clear cache. | |
media.uploaded |
Optional | File uploaded to Cloudinary — sync a headless CMS media library UI. |
Integration Guide — Next.js
App Router, Server Components and Server Actions. Because the API secret must never reach the browser, every call goes through server-side code — never a client component.
1. A typed client wrapper
// lib/blogcore/client.ts — server-only import "server-only"; export class BlogcoreError extends Error { constructor(message: string, public readonly status: number, public readonly code?: string) { super(message); } } export async function blogcoreFetch<T>(path: string, options: RequestInit & { actingUser?: { id: string; email: string; name: string } } = {}): Promise<T> { const { actingUser, headers, ...rest } = options; const res = await fetch(`${process.env.BLOGCORE_URL}/api/v1${path}`, { ...rest, headers: { Accept: "application/json", "X-API-Key": process.env.BLOGCORE_API_KEY!, "X-API-Secret": process.env.BLOGCORE_API_SECRET!, ...(actingUser ? { "X-User-Id": actingUser.id, "X-User-Email": actingUser.email, "X-User-Name": actingUser.name, } : {}), ...headers, }, }); const body = await res.json().catch(() => null); if (!res.ok) throw new BlogcoreError(body?.error?.message ?? "Request failed", res.status, body?.error?.code); // The posts list endpoint returns {data, links, meta} directly; everything // else is wrapped as {success, data} — normalize both to just the payload. return (body && typeof body === "object" && "success" in body) ? body.data : body; }
2. Reading posts in a Server Component
// app/blog/page.tsx import { blogcoreFetch } from "@/lib/blogcore/client"; export default async function BlogPage() { const { data: posts } = await blogcoreFetch<{ data: any[] }>( "/posts?status=published&sort_field=published_at&sort_dir=desc", { next: { revalidate: 60, tags: ["posts"] } } // ISR — revalidate every 60s ); return ( <main> {posts.map((post) => ( <a key={post.id} href={`/blog/${post.slug}`}>{post.title}</a> ))} </main> ); }
3. Publishing a post from a Server Action
// app/admin/actions.ts "use server"; import { revalidateTag } from "next/cache"; import { blogcoreFetch } from "@/lib/blogcore/client"; export async function publishPostAction(formData: FormData) { const id = String(formData.get("id")); await blogcoreFetch(`/posts/${id}/publish`, { method: "POST", actingUser: { id: "admin", email: "you@example.com", name: "Admin" }, }); revalidateTag("posts"); // invalidates every cached fetch tagged "posts" }
multipart/form-data. In a Server Action, accept the file straight from a FormData field and forward it — Next.js's File objects work directly with the Fetch API's FormData, no extra conversion needed.
Integration Guide — Vue / Nuxt 3
In Nuxt, keep the API secret in a server route or a Nitro server util — never in a plain composable that runs in the browser.
// server/utils/blogcore.ts — runs only on the Nitro server export async function blogcoreFetch<T>(path: string, opts: RequestInit = {}): Promise<T> { const config = useRuntimeConfig(); const res = await $fetch<any>(`${config.blogcoreUrl}/api/v1${path}`, { ...opts, headers: { "X-API-Key": config.blogcoreApiKey, "X-API-Secret": config.blogcoreApiSecret, ...opts.headers, }, }); return ("success" in res) ? res.data : res; }
// server/api/posts.get.ts export default defineEventHandler(async () => { return blogcoreFetch("/posts?status=published"); });
// composables/usePosts.ts — calls YOUR OWN /api/posts route, never BlogCore directly export function usePosts() { return useFetch("/api/posts"); }
Usage in a page
Add blogcoreUrl, blogcoreApiKey, and blogcoreApiSecret to the runtimeConfig block in nuxt.config.ts, sourced from environment variables.
Integration Guide — PHP / Laravel
Uses Laravel's built-in HTTP client — no extra package required.
// app/Services/BlogcoreClient.php namespace App\Services; use Illuminate\Support\Facades\Http; class BlogcoreClient { public function __construct( private readonly string $baseUrl = null, ) { $this->baseUrl = config('services.blogcore.url'); } private function client(?array $user = null) { return Http::withHeaders(array_filter([ 'X-API-Key' => config('services.blogcore.key'), 'X-API-Secret' => config('services.blogcore.secret'), 'X-User-Id' => $user['id'] ?? null, 'X-User-Email' => $user['email'] ?? null, ]))->baseUrl("{$this->baseUrl}/api/v1"); } public function posts(array $query = []): array { return $this->client()->get('/posts', $query)->json(); } public function createPost(array $data, array $user): array { return $this->client($user)->post('/posts', $data)->throw()->json('data'); } }
Register the config in config/services.php:
'blogcore' => [
'url' => env('BLOGCORE_URL'),
'key' => env('BLOGCORE_API_KEY'),
'secret' => env('BLOGCORE_API_SECRET'),
],Then inject it anywhere via Laravel's container:
public function index(BlogcoreClient $blogcore) { return view('blog.index', [ 'posts' => $blogcore->posts(['status' => 'published'])['data'], ]); }
Integration Guide — Python
Works with any framework — Flask, Django, FastAPI — since it's a plain requests-based client with no framework dependency.
# blogcore.py import os import requests class BlogcoreError(Exception): def __init__(self, message, status, code=None): super().__init__(message) self.status = status self.code = code class BlogcoreClient: def __init__(self): self.base_url = os.environ["BLOGCORE_URL"] self.headers = { "X-API-Key": os.environ["BLOGCORE_API_KEY"], "X-API-Secret": os.environ["BLOGCORE_API_SECRET"], } def _request(self, method, path, user=None, **kwargs): headers = {**self.headers} if user: headers.update({ "X-User-Id": user.get("id", ""), "X-User-Email": user.get("email", ""), "X-User-Name": user.get("name", ""), }) res = requests.request(method, f"{self.base_url}/api/v1{path}", headers=headers, **kwargs) body = res.json() if not body.get("success", True) and not res.ok: err = body.get("error", {}) raise BlogcoreError(err.get("message", "Request failed"), res.status_code, err.get("code")) return body.get("data", body) def get_posts(self, **params): return self._request("GET", "/posts", params=params) def create_post(self, data, user): return self._request("POST", "/posts", user=user, json=data)
Usage
client = BlogcoreClient()
posts = client.get_posts(status="published")
post = client.create_post(
{"title": "Hello, BlogCore", "content": "<p>First post</p>"},
user={"id": "user_123", "email": "you@example.com", "name": "Adewale"},
)Integration Guide — React Native
X-API-Secret must never ship inside a React Native (or any mobile) app — even obfuscated. Instead, proxy every BlogCore call through your own backend, which is the only place that holds the secret.
Recommended pattern: server proxy
Mobile App ──▶ Your backend (holds the secret) ──▶ BlogCore
The mobile app authenticates to your backend using whatever
you already use (Firebase Auth, a JWT, session cookies). Your
backend then forwards the request to BlogCore, attaching the
API key/secret server-side, and returns BlogCore's response.// mobile app — calls your own backend, never BlogCore directly async function getPosts() { const res = await fetch("https://your-backend.com/api/blog/posts", { headers: { Authorization: `Bearer ${await getUserToken()}` }, }); return res.json(); }
// your backend (e.g. an Express route) — the only place with BlogCore credentials app.get("/api/blog/posts", authenticateUser, async (req, res) => { const upstream = await fetch(`${process.env.BLOGCORE_URL}/api/v1/posts?status=published`, { headers: { "X-API-Key": process.env.BLOGCORE_API_KEY, "X-API-Secret": process.env.BLOGCORE_API_SECRET, }, }); res.json(await upstream.json()); });
This is the same shape used by the Next.js and Vue guides above — the browser (or app) only ever talks to a server you control, and that server is the only thing that ever sees the BlogCore secret.
Error Reference
Every error follows the same JSON shape, so your error handler never needs to special-case the structure:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "The given data was invalid.",
"details": {
"title": ["The title field is required."],
"seo.description": ["The seo.description may not be greater than 160 characters."]
}
}
}
| HTTP | Error code | Meaning | Fix |
|---|---|---|---|
401 |
Optional | INVALID_API_KEY — key not found or secret mismatch. Check both headers are correct and the key is active. |
|
401 |
Optional | KEY_EXPIRED — API key has passed its expiry date. Issue a new key and update your environment. |
|
401 |
Optional | USER_REQUIRED — user identity missing for a user-scoped action. Add X-User-Id or X-User-Email. |
|
401 |
Optional | UNAUTHORIZED — missing or incorrect X-Super-Admin-Secret on an /admin/* route. |
|
403 |
Optional | PLATFORM_SUSPENDED — the platform account is suspended. Contact whoever administers this BlogCore instance. |
|
403 |
Optional | INSUFFICIENT_PERMISSION — the API key or the user's role lacks the required permission. See Permissions Reference. |
|
403 |
Optional | IP_NOT_ALLOWED — request IP isn't in the key's allow-list. Add it to allowed_ips or remove the restriction. |
|
404 |
Optional | NOT_FOUND — the resource may belong to a different platform, be soft-deleted, or the ID is wrong. |
|
405 |
Optional | METHOD_NOT_ALLOWED — wrong HTTP method for this URL. |
|
422 |
Optional | VALIDATION_ERROR — check the details field for field-level error messages. |
|
422 |
Optional | WORKFLOW_VIOLATION — invalid post state transition, e.g. publishing a rejected post without first editing it back to draft. |
|
422 |
Optional | WORKFLOW_DISABLED — tried to submit for review but workflow is off for this platform. |
|
429 |
Optional | AI_LIMIT_EXCEEDED — monthly AI token quota reached. Increase the limit, or wait for next month. |
|
503 |
Optional | AI_SERVICE_ERROR — Groq is unreachable. Retry with backoff; BlogCore already retries twice on 5xx before surfacing this. |
Error handling example
async function blogcoreRequest(path, options = {}) { const res = await fetch(`${BASE}/api/v1${path}`, options); const json = await res.json(); if (!json.success) { const { code, message, details } = json.error; switch (code) { case 'INVALID_API_KEY': throw new Error('BlogCore credentials invalid — check environment variables'); case 'VALIDATION_ERROR': { const fieldErrors = Object.entries(details).map(([f, m]) => `${f}: ${m[0]}`).join('; '); throw new Error(`Validation failed — ${fieldErrors}`); } case 'AI_LIMIT_EXCEEDED': throw new Error('Monthly AI quota reached — try again next month'); default: throw new Error(message ?? 'An unexpected error occurred'); } } return json.data; }
Permissions Reference
Permissions are checked at two levels: the API key (what the integration is allowed to do at all) and the acting user's role (what that specific person is allowed to do). Both must allow an action for it to succeed.
| Slug | Group | What it allows | Default roles |
|---|---|---|---|
posts.create |
Optional | Create new post drafts. — author, editor, owner | |
posts.read |
Optional | Read posts in any status, including draft. — all roles | |
posts.update |
Optional | Edit post content and metadata. — author, editor, owner | |
posts.delete |
Optional | Soft-delete posts. — editor, owner | |
posts.publish |
Optional | Publish directly, bypassing workflow. — editor, owner | |
posts.submit_review |
Optional | Submit a draft into the approval workflow. — author, editor, owner | |
approvals.review |
Optional | Approve or reject posts in the workflow. — editor, owner | |
approvals.manage_workflow |
Optional | Create and configure approval workflows. — owner | |
media.upload |
Optional | Upload files to Cloudinary. — author, editor, owner | |
media.delete |
Optional | Delete files from Cloudinary and the database. — editor, owner | |
users.manage |
Optional | Change user roles, suspend/activate accounts. — editor, owner | |
analytics.read |
Optional | Access analytics overview and per-post stats. — viewer, editor, owner | |
ai.use |
Optional | Call any AI feature endpoint. — author, editor, owner | |
webhooks.manage |
Optional | Create, update, delete, and rotate secrets for webhooks. — editor, owner |
["*"] in an API key's permissions array to grant every permission the key can hold. Appropriate for trusted server-to-server integrations. Restrict to specific slugs for any key that reaches a browser or a public frontend proxy.
Frequently Asked Questions
Can I use BlogCore without a workflow?
Yes. Leave workflow_enabled as false (the default). Anyone with posts.publish can publish directly with POST /posts/{id}/publish. The multi-stage review only activates once you enable it, and you can toggle it at any time without affecting existing posts.
Do I need to pre-register users before they can create posts?
No. BlogCore auto-registers users on first encounter (governed by BLOGCORE_AUTO_REGISTER_USERS, default on). Just pass X-User-Id or X-User-Email and BlogCore creates a platform user with the default author role. You can change their role afterward.
How do scheduled posts actually get published?
Set scheduled_at to a future timestamp on a draft or approved post. A scheduled job checks every five minutes for posts whose time has arrived and publishes them the same way POST /posts/{id}/publish would. You don't need to call anything yourself once it's scheduled — just make sure this server's task scheduler (php artisan schedule:run via cron) is actually running in your deployment.
Are media files served via BlogCore or directly from Cloudinary?
Directly from Cloudinary. BlogCore only stores the metadata and URLs — images are served from Cloudinary's CDN, so your readers never route through this server for assets.
What happens if the Groq API is down?
AI endpoints retry twice on a 5xx from Groq before giving up and returning 503 AI_SERVICE_ERROR. Everything else on the platform — posts, media, analytics — is unaffected; AI is an optional add-on, not a dependency of the core publishing flow.
Can I run multiple platforms from one BlogCore instance?
Yes — that's the entire point. Every table is scoped by platform_id and enforced by a query-level scope, so platforms can't see or affect each other's data even though they share one database and one deployment.
How do webhooks retry on failure?
Up to 3 attempts with backoff: 1 minute, 10 minutes, then 1 hour. After 10 consecutive failures, the webhook is automatically deactivated (is_active: false) — check that field if deliveries seem to have stopped.
Is post content stored as HTML or Markdown?
Either — set content_format to html or markdown per post. Store whichever your editor produces; BlogCore doesn't convert between them, so render each post according to its own content_format value on the way out.
Can I search posts by content, not just title/excerpt?
The built-in search query parameter matches title and excerpt only, not full post body. For full-text search across content, sync published posts into a dedicated search index (Meilisearch, Algolia, Postgres full-text) using the post.published / post.updated webhooks to keep it current.
How is the API secret stored?
As a bcrypt hash — the same way BlogCore would store a password. The plaintext is shown to you exactly once, at creation time, and is never recoverable afterward. If you lose it, revoke the key and issue a new one.
How do I test webhooks locally?
Expose your local dev server with a tunnel (ngrok, Cloudflare Tunnel, or similar) and register that public URL as the webhook target. Trigger a real event (publish a test post) and inspect the delivery in your tunnel's request log.