BlogCore
01

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.

Multi-tenant
Each connected app is a separate "platform" — completely isolated posts, users, media, and settings.
API key authentication
No JWT or OAuth complexity on the client. Two headers and you are authenticated.
AI-assisted writing
Generate posts, optimize SEO, rewrite content, and moderate text via Groq's LLMs.
Cloudinary media
Upload images and files once. BlogCore stores them on Cloudinary and returns ready-to-use URLs with auto-generated thumbnails.
Flexible workflows
Some platforms need editorial approval before publishing; others publish directly. Both are supported per-platform.
Built-in analytics
Track views, shares, and scroll depth, with pre-aggregated daily stats — no third-party tracker required.
Base URL All endpoints in this reference are relative to this server: 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.

02

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:

PartExampleUsage
Key Optional Sent in every request — identifies the platform.
Secret Optional Proves the caller is authorized. Never expose in browser JavaScript.
Keep secrets server-side only The API secret must only be used in server-side code (Next.js Route Handlers, Laravel controllers, Node.js backends). Never ship it in browser JavaScript or a mobile app bundle.

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.

03

Quick Start

Beginner friendly

Get your first list of posts in under five minutes.

1

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.

2

Add credentials to your environment

BLOGCORE_URL=https://blogcore.jamiuadewaleyusuf.com
BLOGCORE_API_KEY=pk_live_xxxxxxxxxxxx
BLOGCORE_API_SECRET=sk_xxxxxxxxxxxx
3

Fetch published posts

cURL
JavaScript
PHP
Python
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"]
4

The response looks like this

200 OK
{
  "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 }
}
04

Authentication — API Keys

Every platform API request requires two headers that identify and authorize your platform.

HeaderValueRequired
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.
The secret is shown only once When you create an API key, the secret is returned in the response exactly one time. BlogCore stores only a bcrypt hash and cannot recover the plaintext. Copy it immediately into environment variables or a secrets manager (AWS SSM, Doppler, Vercel env, etc.) — if you lose it, you must issue a new key.

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
JavaScript
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
05

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.

HeaderWhen to useExample
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.
Works with any auth system Clerk, Supabase Auth, Laravel Sanctum, NextAuth, Firebase Auth, a custom JWT — just extract the user's ID or email from your own session and pass it in the header. BlogCore never sees your users' passwords or tokens.

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.

GET /api/v1/me Effective role and permissions for the acting identity

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).

200 OK
{
  "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"]
  }
}
06

Admin — Platform Management

Requires the super-admin secret Every endpoint in this section and the next requires an 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.
POST /api/v1/admin/platforms Create a new platform / tenant

Request headers

HeaderValue
X-Super-Admin-Secret Optional Required on every request in this section.

Request body

FieldTypeRequiredDescription
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
JavaScript
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

201 Created
{
  "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."
}
GET /api/v1/admin/platforms List all platforms

Returns a paginated list of every platform with post and user counts. Query params: per_page (default 20), page.

PATCH /api/v1/admin/platforms/{id} Update platform settings

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}.

07

Admin — API Key Management

GET /api/v1/admin/platforms/{id}/keys List all keys for a platform

Returns every API key for the platform. The secret_hash is never returned — only the key string, name, permissions, and usage metadata.

POST /api/v1/admin/platforms/{id}/keys Create a new API key
for full access. See Permissions Reference.'], ['name' => 'allowed_ips', 'type' => 'string[]', 'description' => 'Restrict this key to specific source IPs.'], ['name' => 'expires_at', 'type' => 'date', 'description' => 'Optional expiry timestamp.'], ]"/>

The response includes the plaintext secret exactly once, the same way platform creation does.

PATCH /api/v1/admin/platforms/{id}/keys/{key}/revoke Revoke an API key

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.

08

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.

GET /api/v1/posts List posts

Query parameters

ParamTypeDescription
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.
This endpoint returns Laravel's standard pagination shape (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.
POST /api/v1/posts Create a post

Request body

Media upload endpoint.'], ['name' => 'categories', 'type' => 'string[]', 'description' => 'Category IDs.'], ['name' => 'tags', 'type' => 'string[]', 'description' => 'Free-text tag names — created automatically if new.'], ['name' => 'visibility', 'type' => 'string', 'description' => 'public, unlisted, or private. Default public.'], ['name' => 'scheduled_at', 'type' => 'date', 'description' => 'A future timestamp. The post publishes itself automatically once this time arrives — see Core Concepts.'], ['name' => 'seo.title / seo.description / seo.keywords', 'type' => 'string / string / string[]', 'description' => 'Optional SEO metadata.'], ]"/>
X-User-Id or X-User-Email is required Post creation is a user-scoped action. Omitting both identity headers returns 401 USER_REQUIRED.
GET /api/v1/posts/{id} Get a single post by ID

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.

GET /api/v1/posts/slug/{slug} Get a single post by slug

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"
PUT /api/v1/posts/{id} Update a post

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.

DELETE /api/v1/posts/{id} Delete a post

Soft-deletes the post — it stops appearing in list/show responses immediately but isn't purged from the database.

09

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.

workflow_enabled: false
Anyone with posts.publish calls POST /posts/{id}/publish directly. There is no review step.
workflow_enabled: true
Authors submit for review; editors approve or reject; only an approved post can then be published.
POST /api/v1/posts/{id}/publish Publish directly, bypassing workflow

Requires posts.publish. Only works if the post is currently draft or approved — returns 422 WORKFLOW_VIOLATION otherwise.

POST /api/v1/posts/{id}/submit Submit a draft into the approval workflow

Requires posts.submit_review. Returns 422 WORKFLOW_DISABLED if the platform doesn't have workflow enabled — use the publish endpoint above instead.

POST /api/v1/posts/{id}/approve Approve the current review stage

Requires approvals.review. Optional body: {"comment": "..."}.

POST /api/v1/posts/{id}/reject Reject the post

Requires approvals.review. Body: {"comment": "..."} — the comment is required here so the author knows what to fix.

GET /api/v1/posts/{id}/stages List approval stages for a post

Returns each stage's order, name, status, and reviewer.

10

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.

GET /api/v1/posts/{id}/revisions List revision history

Returns each revision's title/content snapshot, the editor who made it, an optional change_summary, and when it was created — newest first.

POST /api/v1/posts/{id}/revisions/{revision}/restore Restore an old revision

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.

11

Categories

Categories support one level of nesting via parent_id. A post can belong to multiple categories.

GET /api/v1/categories List categories

Returns the full tree (top-level categories with their children eager-loaded), not paginated.

POST /api/v1/categories Create a category
FieldTypeRequiredDescription
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.
PUT /api/v1/categories/{id} Update a category

All fields optional — the same shape as create.

DELETE /api/v1/categories/{id} Delete a category

Posts already assigned to this category simply lose the association — they are not deleted.

12

Tags

Tags are flat (no nesting) and are usually created implicitly — passing a new tag name when creating or updating a post is enough; you rarely need to call these endpoints directly.

GET /api/v1/tags List tags

Query params: search (matches tag name), per_page (default 50). Ordered by number of posts using each tag, descending.

POST /api/v1/tags Create a tag

Body: {"name": "..."}, max 50 characters. Idempotent — creating a tag with a name that already exists on this platform returns the existing tag rather than erroring.

DELETE /api/v1/tags/{id} Delete a tag

Removes the tag from every post that had it.

13

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.

POST /api/v1/media Upload a file

Requires media.upload. Send as multipart/form-data, not JSON.

FieldTypeRequiredDescription
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"
201 Created
{
  "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.

GET /api/v1/media List uploaded media

Query params: type (image, video, or raw), per_page (default 24).

PATCH /api/v1/media/{id} Update alt text or caption

Requires media.upload. Only alt_text and caption are editable — the file itself is immutable; upload a new one to replace it.

DELETE /api/v1/media/{id} Delete a file

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.

14

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.

POST /api/v1/analytics/track Record a view/share/scroll event
FieldTypeRequiredDescription
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.

GET /api/v1/analytics/overview Platform-wide stats

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.

GET /api/v1/analytics/posts/{id} Per-post stats

Requires analytics.read. Same days param. Adds a breakdown by device type for that specific post.

15

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

EndpointWhat it doesInput
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

POST /api/v1/ai/generate Draft a full post from a topic
FieldTypeRequiredDescription
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.
200 OK
{
  "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

POST /api/v1/ai/rewrite Rewrite content to an instruction
FieldTypeRequiredDescription
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 }.

POST /api/v1/ai/summarize Summarize into an excerpt

Body: { content } or { post_id }. Returns { summary, usage }.

POST /api/v1/ai/suggest-titles Suggest alternative titles

Body: { content } or { post_id }. Returns { titles: string[], usage }.

POST /api/v1/ai/suggest-tags Suggest tags

Body: { content } or { post_id }. Returns { tags: string[], usage } — merge these with any tags the author already added rather than replacing them.

SEO Optimizer

POST /api/v1/ai/seo-optimize Suggest SEO title, description, and keywords
FieldTypeRequiredDescription
content string Required Or post_id.
title string Optional The post's current title, for context.
200 OK
{
  "success": true,
  "data": {
    "seo": {
      "seo_title": "...", "seo_description": "...",
      "seo_keywords": ["..."], "suggestions": ["Add a primary keyword to the first paragraph", "..."]
    },
    "usage": { "total_tokens": 210 }
  }
}

Moderation

POST /api/v1/ai/moderate Flag unsafe or policy-violating content

Body: { content } or { post_id }.

200 OK
{
  "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.

16

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.

Requires webhooks.manage All endpoints below require the 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.
GET /api/v1/webhooks List webhooks
POST /api/v1/webhooks Register a webhook
Events Reference.'], ]"/>

The response includes the plaintext secret exactly once — save it, you'll need it to verify delivered payloads.

PATCH /api/v1/webhooks/{id} Update a webhook

All fields optional: name, url, events, is_active.

DELETE /api/v1/webhooks/{id} Delete a webhook
POST /api/v1/webhooks/{id}/regenerate-secret Rotate the signing secret

The old secret stops working immediately. Update your receiver with the new one before rotating in production.

Delivery and retries BlogCore retries a failed delivery up to 3 times with backoff (1 minute, 10 minutes, 1 hour). After 10 consecutive failures a webhook is automatically disabled — check is_active if events stop arriving.
17

Webhooks — Verification &amp; 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.

Next.js
PHP / Laravel
Python / Flask
Node.js (Express)
// 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

Example: post.published payload
{
  "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

EventWhen it firesCommon 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.
18

Integration Guide — Next.js

Intermediate friendly

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"
}
Uploading files Cover images and inline media go through POST /media as 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.
19

Integration Guide — Vue / Nuxt 3

Intermediate friendly

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

// pages/blog/index.vue <script setup> const { data: posts } = usePosts(); </script> <template> <article v-for="post in posts?.data" :key="post.id"> <NuxtLink :to="`/blog/${post.slug}`">{{ post.title }}</NuxtLink> </article> </template>

Add blogcoreUrl, blogcoreApiKey, and blogcoreApiSecret to the runtimeConfig block in nuxt.config.ts, sourced from environment variables.

20

Integration Guide — PHP / Laravel

Beginner friendly

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'],
    ]);
}
21

Integration Guide — Python

Beginner friendly

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"},
)
22

Integration Guide — React Native

Advanced friendly
Never embed the API secret in a mobile app Anything bundled into a mobile app binary can be extracted. The 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.

23

Error Reference

Every error follows the same JSON shape, so your error handler never needs to special-case the structure:

4xx / 5xx
{
  "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."]
    }
  }
}
HTTPError codeMeaningFix
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;
}
24

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.

SlugGroupWhat it allowsDefault 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
Wildcard permission Use ["*"] 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.
25

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.