Base Path /api/blogs
GET

List Blogs

â–ļ
GET /api/blogs

Returns a paginated list of blog posts. Defaults to status=published. Supports full-text search across title, excerpt, content, and focusKeyword. Tags and types are matched via LIKE (substring match).

Query Parameters
ParamTypeDefaultDescription
pagenumber1Page number
limitnumber20Results per page (max 100)
statusstringpublisheddraft | published | archived
categorystring—Exact category match
tagstring—Substring match against JSON tags array
typestring—general | video | lawyer | real-estate | marketing | healthcare
featuredstring—"true" or "false"
searchstring—Full-text search (max 200 chars) across title, excerpt, content, focusKeyword
sortBystringpublishedAtcreatedAt | publishedAt | viewCount | helpfulCount | title
sortOrderstringdescasc | desc
Response
200 — Blog list
{
  "success": true,
  "data": {
    "blogs": [
      {
        "id":              "blog_abc",
        "slug":            "my-first-post",
        "title":           "My First Post",
        "excerpt":         "A short summary of the post.",
        "coverImageUrl":   "https://cdn.example.com/cover.jpg",
        "category":        "AI News",
        "tags":            ["ai", "productivity"],
        "types":           ["general"],
        "status":          "published",
        "featured":        false,
        "viewCount":       142,
        "helpfulCount":    38,
        "notHelpfulCount": 2,
        "focusKeyword":    "ai tools",
        "publishedAt":     "2026-04-01T10:00:00.000Z",
        "createdAt":       "2026-04-01T09:00:00.000Z",
        "user": {
          "id":       "usr_1",
          "username": "admin",
          "avatar":   "https://cdn.example.com/avatar.jpg"
        }
      }
    ],
    "pagination": {
      "total":      84,
      "page":       1,
      "limit":      20,
      "totalPages": 5
    }
  }
}
â„šī¸ The response also includes an X-Total-Count header with the raw total count.
GET

Check Slug Availability

â–ļ
GET /api/blogs/check-slug

Check whether a given slug is already taken before creating or updating a blog post.

Query Parameters
ParamTypeRequiredDescription
slugstringrequired2–160 chars, lowercase letters, numbers, and hyphens only ([a-z0-9-])
Response
200 — Available
{ "success": true, "data": { "slug": "my-post-title", "available": true } }
200 — Taken
{ "success": true, "data": { "slug": "my-post-title", "available": false } }
GET

Get Blog by Slug

â–ļ
GET /api/blogs/:slug

Fetch a single published blog post. Only posts with status = "published" are returned. Tracks unique views per visitor via a viewed_blogs cookie (24h TTL, stores up to 200 slugs). A view is only counted once per slug per cookie window.

Path Parameters
ParamTypeDescription
slugstringThe blog post slug
Response
200 — Blog post
{
  "success": true,
  "data": {
    "blog": {
      "id":              "blog_abc",
      "slug":            "my-first-post",
      "title":           "My First Post",
      "excerpt":         "A short summary.",
      "content":         "Full HTML or markdown content...",
      "coverImageUrl":   "https://cdn.example.com/cover.jpg",
      "metaTitle":       "My First Post | AI Tools",
      "metaDescription": "A short SEO description.",
      "focusKeyword":    "ai tools",
      "canonicalUrl":    null,
      "category":        "AI News",
      "tags":            ["ai", "productivity"],
      "types":           ["general"],
      "status":          "published",
      "featured":        false,
      "viewCount":       143,
      "helpfulCount":    38,
      "notHelpfulCount": 2,
      "publishedAt":     "2026-04-01T10:00:00.000Z",
      "createdAt":       "2026-04-01T09:00:00.000Z",
      "updatedAt":       "2026-04-10T08:00:00.000Z",
      "user": {
        "id":       "usr_1",
        "username": "admin",
        "avatar":   "https://cdn.example.com/avatar.jpg"
      }
    }
  }
}
404 — Not found or not published
{ "success": false, "message": "Blog post not found." }
POST

Submit Feedback

â–ļ
POST /api/blogs/:id/feedback

Submit a helpful or not-helpful vote on a blog post. Works for both authenticated users and anonymous visitors. Submitting the same vote twice is a no-op. Submitting the opposite vote flips the existing vote and adjusts counters accordingly.

Path Parameters
ParamTypeDescription
idstringBlog post ID
Request Body
FieldTypeRequiredDescription
isHelpfulbooleanrequiredtrue = helpful, false = not helpful
Deduplication
Visitor typeDedup key
AuthenticateduserId
AnonymousSHA-256 of CF-Connecting-IP + User-Agent (first 32 hex chars)
Response
200 — Updated counts
{
  "success": true,
  "data": {
    "helpfulCount":    39,
    "notHelpfulCount": 2
  }
}
â„šī¸ If the same vote is submitted again (no-op), the counts returned reflect the unchanged state at the time of the duplicate submission.
POST

Create Blog

🛡 Admin Only â–ļ
POST /api/blogs

Create a new blog post. If slug is omitted, one is auto-generated from the title. If the resolved slug is already taken, a short timestamp suffix is appended automatically.

Request Body
FieldTypeRequiredNotes
titlestringrequired5–200 characters
contentstringrequiredMin 50 characters
slugstringoptional2–160 chars, [a-z0-9-]. Auto-generated from title if omitted
excerptstringoptionalMax 300 chars
coverImageUrlURL stringoptionalMust be a valid URL
metaTitlestringoptionalMax 120 chars — SEO
metaDescriptionstringoptionalMax 160 chars — SEO
focusKeywordstringoptionalMax 100 chars — SEO
canonicalUrlURL stringoptionalMust be a valid URL — SEO
categorystringoptionalMax 80 chars
tagsstring[]optionalMax 20 items, each max 50 chars. Default []
typesenum[]optionalMax 10 items from general | video | lawyer | real-estate | marketing | healthcare. Default []
statusstringoptionaldraft | published | archived. Default draft
featuredbooleanoptionalDefault false
publishedAtISO datetimeoptionalDefaults to now when status = "published"
Response
201 — Created
{
  "success": true,
  "message": "Blog post created.",
  "data": { "blog": { /* full blog object */ } }
}
PUT

Update Blog

🛡 Admin Only â–ļ
PUT /api/blogs/:id

Partially update a blog post. All fields from Create Blog are accepted except slug (slug is immutable after creation). publishedAt is set automatically the first time status changes to "published" if it was not already set.

Path Parameters
ParamTypeDescription
idstringBlog post ID
Request Body

All fields are optional — send only the fields you want to change. Same field rules as Create Blog apply, minus slug.

Response
200 — Updated
{
  "success": true,
  "message": "Blog post updated.",
  "data": { "blog": { /* updated blog object */ } }
}
403 — Forbidden
{ "success": false, "message": "You do not have permission to edit this blog post." }
404 — Not found
{ "success": false, "message": "Blog post not found." }
DELETE

Delete Blog

🛡 Admin Only â–ļ
DELETE /api/blogs/:id

Permanently delete a blog post. Only the post author or an admin can delete. This action is irreversible.

Path Parameters
ParamTypeDescription
idstringBlog post ID
Response
200 — Deleted
{ "success": true, "message": "Blog post deleted." }
403 — Forbidden
{ "success": false, "message": "You do not have permission to delete this blog post." }
404 — Not found
{ "success": false, "message": "Blog post not found." }
POST

Bulk Import Blogs

🛡 Admin Only â–ļ
POST /api/blogs/bulk

Import up to 50 blog posts in a single request. Each post is processed individually — a failure on one entry does not abort the rest. Posts whose resolved slug already exists are silently skipped (not failed). Results are broken down into created, skipped, and failed lists.

Request Body
FieldTypeRequiredNotes
toolsobject[]required1–50 blog objects. Same schema as Create Blog
publishImmediatelybooleanoptionalDefault false. If true, all posts are created with status: "published" and publishedAt set to now
Response
201 — Bulk import complete
{
  "success": true,
  "message": "Bulk import complete. 10 created, 2 skipped, 0 failed.",
  "data": {
    "summary": {
      "total":   12,
      "created": 10,
      "skipped": 2,
      "failed":  0
    },
    "created": [ /* array of created blog objects */ ],
    "skipped": [
      { "title": "Existing Post", "reason": "Slug already exists" }
    ],
    "failed": [
      { "title": "Bad Post", "reason": "Database constraint violation" }
    ]
  }
}
âš ī¸ Posts are inserted sequentially (not in a single transaction). A partial success is possible — always check the summary and failed arrays in the response.