Posts API

The Posts API is the most commonly used surface — it's how n8n creates drafts, how mobile apps pull the feed, and how external publishers push content in. Every endpoint is under /api/v1/posts and requires a Bearer token with the relevant posts:* scope.

List posts

GET /api/v1/posts

Required scope: posts:read.

Query parameters:

  • statusdraft, scheduled, published (default), archived
  • category — slug or ID
  • author — user ID
  • search — keyword search across title and content
  • page — 1-indexed page number (default 1)
  • per_page — items per page, max 100 (default 20)
  • order_bypublished_at, updated_at, title (default published_at)
  • orderasc, desc (default desc)

Example:

curl -H "Authorization: Bearer YOUR_TOKEN" \
  "https://yoursite.com/api/v1/posts?status=published&category=nutrition&per_page=50"

Response:

{
    "data": [
        {
            "id": 123,
            "title": "10 high-protein breakfasts",
            "slug": "high-protein-breakfasts",
            "status": "published",
            "excerpt": "Start your day with...",
            "content_html": "<p>...</p>",
            "content_markdown": "...",
            "author": { "id": 1, "name": "Ada Lovelace" },
            "categories": [ { "id": 3, "slug": "nutrition", "name": "Nutrition" } ],
            "tags": [ "protein", "breakfast" ],
            "featured_image": { "id": 45, "url": "https://.../cover.jpg", "width": 1600, "height": 900 },
            "published_at": "2026-04-10T09:00:00Z",
            "content_modified_at": "2026-05-02T14:22:00Z",
            "reviewed_at": null,
            "updated_at": "2026-04-10T09:00:00Z",
            "url": "https://yoursite.com/blog/high-protein-breakfasts"
        }
    ],
    "pagination": { "page": 1, "per_page": 50, "total": 237, "total_pages": 5 }
}

Date field semantics: published_at is set once, at first publish, and never changes afterward. content_modified_at is the last time the reader-facing content actually changed (title, body, excerpt, or featured image) — this is the field sitemap lastmod and structured-data dateModified are based on, so it's the one to watch if you're syncing external caches or search indexes. reviewed_at marks the last editorial review pass and is independent of content edits — it's null until an editor explicitly marks the post reviewed. updated_at is an internal system-touch signal (view counters, cache rebuilds, bulk SEO jobs) and should not be treated as a content-change indicator. Both content_modified_at and reviewed_at return null if unset, or if the installation predates the editorial date model.

Get a single post

GET /api/v1/posts/{id}

Required scope: posts:read.

Returns the same shape as the list items but with full content_html and content_markdown. Accepts either the numeric ID or the slug:

curl -H "Authorization: Bearer YOUR_TOKEN" \
  https://yoursite.com/api/v1/posts/high-protein-breakfasts

Create a post

POST /api/v1/posts
Content-Type: application/json

Required scope: posts:write.

Request body:

{
    "title": "My new post",
    "content_markdown": "# Hello\n\nThis is the body...",
    "status": "draft",
    "categories": [ "nutrition" ],
    "tags": [ "protein", "breakfast" ],
    "featured_image_id": 45,
    "published_at": null,
    "seo": {
        "title": "Optional SEO title",
        "description": "Optional SEO description"
    }
}

Required fields: title, content_markdown (or content_html). All others optional.

Valid status values: draft, scheduled, published. If scheduled, you must supply a future published_at.

Response: 201 Created with the full post object (same shape as GET). The id, slug, content_html (rendered), and url fields are populated server-side.

Update a post

PUT /api/v1/posts/{id}
Content-Type: application/json

Required scope: posts:write.

Accepts the same body as create. Partial updates work — send only the fields you want to change. Unspecified fields stay untouched.

To move a draft to published:

{ "status": "published" }

Response: 200 OK with the updated post.

Delete a post

DELETE /api/v1/posts/{id}

Required scope: posts:write.

Default: soft delete (sets status to archived, hides from public). Pass ?hard=true for a permanent delete (removes the DB row; media and comments remain).

Response: 204 No Content.

Error codes

| Code | Meaning | |------|---------| | 400 | Invalid request body (missing required field, wrong type, invalid status) | | 401 | Missing / invalid / expired Bearer token | | 403 | Token lacks required scope, or IP restricted | | 404 | Post not found | | 409 | Slug collision on create (auto-resolved by server unless you override) | | 422 | Validation failed (field-level errors) | | 429 | Rate limit exceeded — respect Retry-After header | | 500 | Server error — include the X-Request-Id header value if reporting to support |

Error response body:

{
    "error": "validation_failed",
    "message": "one or more fields are invalid",
    "fields": {
        "title": "required",
        "status": "must be one of: draft, scheduled, published"
    }
}

n8n integration example — create draft from webhook

A complete n8n flow that accepts an inbound webhook with { title, content }, calls the jekcms API to create a draft, and returns the new post URL:

  1. Webhook node — method POST, path /new-draft
  2. HTTP Request node:

- Method: POST - URL: https://yoursite.com/api/v1/posts - Authentication: Header Auth → name Authorization, value Bearer {{$credentials.jekcmsToken}} - Body content type: JSON - Body:

{
    "title": "={{ $json.title }}",
    "content_markdown": "={{ $json.content }}",
    "status": "draft",
    "categories": ["auto-import"],
    "tags": ["from-webhook"]
}
  1. Respond to Webhook node — return {{ $node["HTTP Request"].json.url }} so the caller gets the draft preview URL immediately

Turn on Retry on fail in the HTTP Request node (3 attempts, exponential backoff) so transient network errors don't drop submissions.

Be the first to know

New features, release notes & CMS guides — a couple of emails a month, no spam.