API reference

The full REST surface behind @floggy/cms. The SDK wraps it, but every endpoint works with plain fetch or curl.

Base URL: https://floggy-api.pehcastro.workers.dev

Everything is namespaced by project (your Floggy username). Replace :project with that username, e.g. projecta.

If you would rather not hand-roll requests, the SDK reference covers the same surface with types.


Public reads

No key required. These serve published, public, non-scheduled posts. Responses are edge-cached and carry Cache-Control; list, single-post, and tag responses also carry an ETag, so send If-None-Match to get a 304.

An unknown project returns 404 {"error":"User not found"}.

List posts

GET /api/posts/by-username/:project

SDK: posts.list(options?).

Param Type Default Notes
format tiptap | html | markdown tiptap Output of the rendered field. Unknown values fall back to tiptap.
page number 1 Presence switches the response to the paginated envelope.
perPage number 10 Capped at 50.
sort publishedAt:desc | publishedAt:asc publishedAt:desc
tag string - Filter by tag slug.

The list covers the project's own published public posts plus any post the project owner co-authored and chose to show on their blog. Private and link-only posts never appear here.

Response shape depends on params. Without page or perPage, you get a bare Post[] (legacy shape). With either present, you get the envelope:

{
  "posts": [ /* Post[] */ ],
  "pageInfo": { "total": 42, "page": 1, "perPage": 10, "hasMore": true }
}

hasMore is page * perPage < total.

# Paginated, newest first, HTML rendered
curl "https://floggy-api.pehcastro.workers.dev/api/posts/by-username/projecta?page=1&perPage=10&format=html"

# Filter by tag, oldest first
curl "https://floggy-api.pehcastro.workers.dev/api/posts/by-username/projecta?tag=changelog&sort=publishedAt:asc&page=1"

Sample item from posts[] (trimmed; format=html):

{
  "id": "V1StGXR8_Z5jdHi6B-myT",
  "slug": "hello-world",
  "title": "Hello World",
  "subtitle": "A first post",
  "excerpt": "A short summary.",
  "content": { "type": "doc", "content": [ /* raw Tiptap JSON */ ] },
  "rendered": "<p>A short summary and the body...</p>",
  "format": "html",
  "coverImage": "https://cdn.floggy.xyz/cover.jpg",
  "featured": false,
  "tags": [ { "name": "Changelog", "slug": "changelog" } ],
  "coauthors": [],
  "user": { "id": "u_1", "username": "projecta", "displayName": "Project A", "avatar": null },
  "readingTime": 3,
  "publishedAt": "2026-06-01T10:00:00.000Z",
  "updatedAt": "2026-06-02T08:00:00.000Z"
}

Get one post

GET /api/posts/by-username/:project/:slug

SDK: posts.get(slug, options?).

Param Type Default Notes
format tiptap | html | markdown tiptap
includeDrafts true - Owner only, and only with a logged-in Floggy session cookie. An flg_ key does not unlock drafts on this route.

This route also serves link-only posts: they are hidden from the list, search, and tag endpoints, but readable by anyone who knows the slug. Only private and draft posts return 404.

Returns the post plus the server-computed extras: adjacent, seo, jsonLd, format, rendered.

curl "https://floggy-api.pehcastro.workers.dev/api/posts/by-username/projecta/hello-world?format=markdown"
{
  "id": "V1StGXR8_Z5jdHi6B-myT",
  "slug": "hello-world",
  "title": "Hello World",
  "subtitle": "A first post",
  "content": { "type": "doc", "content": [ /* raw Tiptap JSON */ ] },
  "rendered": "# Hello World\n\nA short summary and the body...",
  "format": "markdown",
  "tags": [ { "name": "Changelog", "slug": "changelog" } ],
  "readingTime": 3,
  "publishedAt": "2026-06-01T10:00:00.000Z",
  "updatedAt": "2026-06-02T08:00:00.000Z",
  "adjacent": {
    "prev": { "slug": "older-post", "title": "Older Post" },
    "next": { "slug": "newer-post", "title": "Newer Post" }
  },
  "seo": {
    "title": "Hello World",
    "description": "A short summary.",
    "canonical": "https://projecta.floggy.xyz/hello-world",
    "robots": "index, follow",
    "keywords": ["changelog"],
    "openGraph": {
      "type": "article",
      "title": "Hello World",
      "description": "A short summary.",
      "url": "https://projecta.floggy.xyz/hello-world",
      "siteName": "Project A",
      "images": [ { "url": "https://cdn.floggy.xyz/cover.jpg" } ],
      "publishedTime": "2026-06-01T10:00:00.000Z",
      "modifiedTime": "2026-06-02T08:00:00.000Z"
    },
    "twitter": {
      "card": "summary_large_image",
      "title": "Hello World",
      "description": "A short summary.",
      "images": ["https://cdn.floggy.xyz/cover.jpg"]
    }
  },
  "jsonLd": [ { "@context": "https://schema.org", "@type": "Article" } ]
}

adjacent.prev is the older neighbour, adjacent.next the newer one, within the project owner's published public timeline. Either can be null.

Search posts

GET /api/posts/by-username/:project/search?q=<query>

SDK: posts.search(query, options?).

Param Type Default Notes
q string required Minimum 2 characters, else 400. Substring match on title and excerpt.
format tiptap | html | markdown tiptap

Returns a bare Post[] (no pagination envelope), newest first. Search covers the project's own posts only, not co-authored ones, and is cached for 60 seconds with no ETag.

curl "https://floggy-api.pehcastro.workers.dev/api/posts/by-username/projecta/search?q=release&format=html"

List tags

GET /api/posts/by-username/:project/tags

SDK: tags.list().

Returns tags with the count of published public posts that carry each, sorted by count descending, then by name. Counts cover the project's own posts only.

curl "https://floggy-api.pehcastro.workers.dev/api/posts/by-username/projecta/tags"
[
  { "name": "Changelog", "slug": "changelog", "count": 12 },
  { "name": "Guides", "slug": "guides", "count": 5 }
]

The body contract

Across every read endpoint:

  • content is always the raw Tiptap (ProseMirror) JSON document, regardless of format.
  • rendered is the html or markdown string when format is set to one of those, and null when format=tiptap.
  • format echoes which format produced rendered.

So format=tiptap means "give me structured JSON, I will render it myself" (rendered is null, content is the document). format=html or format=markdown means "render it for me" (rendered is the string, content stays raw alongside it).


Admin and write

All write endpoints require an flg_ key:

Authorization: Bearer flg_xxxxxxxx...

Sessions from the Floggy web app also authorize these (they are unscoped). Scope requirements below apply to API keys. A key missing the scope gets 403 {"error":"Insufficient scope","required":[...]}. No key or an invalid/revoked key gets 401. See Authentication.

Method Path Scope Purpose SDK
GET /api/posts/mine posts:read All of your posts (drafts included). posts.mine(options?)
GET /api/posts/:id posts:read One of your posts by id (for editing). Supports ?format=. posts.getById(id, options?)
POST /api/posts posts:write Create a post. posts.create(input)
PATCH /api/posts/:id posts:write Update a post. posts.update(id, patch), posts.publish(id), posts.unpublish(id)
POST /api/posts/bulk posts:write Bulk delete / publish / unpublish. posts.bulk(action, ids)
DELETE /api/posts/:id posts:delete Delete a post. posts.delete(id)

/api/posts/mine always returns raw Tiptap content; it does not accept ?format=. It is also unpaginated, so it returns every post's body in one response. Every write endpoint is scoped to your own posts: another user's id returns 404.

These endpoints answer as the owner of the key, not as :project. A key belonging to another account reads and writes that account's posts, whatever :project you used on the public routes.

There is no endpoint for "one of my drafts by slug". The SDK's posts.draft(slug) composes the two read endpoints above: /api/posts/mine to resolve the slug to an id, then /api/posts/:id?format=html for the body.

Create a post

POST /api/posts

SDK: posts.create(input). It fills slug from the title when you omit it, defaults status to draft, and accepts a Markdown string for content.

Body fields:

Field Type Notes
title string Required.
slug string Required. Lowercase, digits, hyphens only (^[a-z0-9-]+$). Unique per project.
content Tiptap JSON Optional. The raw ProseMirror document.
excerpt string Optional.
coverImage string (URL) Optional.
visibility public | private | link-only Default public.
status draft | published Default draft.
tagNames string[] Optional. Tags are found or created by name.
publishedAt ISO string | null Optional. Future dates schedule the post.
coauthors object[] Optional. { userId } or { externalName, externalHandle, externalUrl }.
subtitle string | null Optional.
featured boolean Optional.
SEO overrides string | null seoTitle, seoDescription, canonicalUrl, ogTitle, ogDescription, ogImage, twitterTitle, twitterDescription, twitterImage, robots. All optional; each overrides the auto-computed value.
curl -X POST "https://floggy-api.pehcastro.workers.dev/api/posts" \
  -H "Authorization: Bearer $FLOGGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Launch Day",
    "slug": "launch-day",
    "excerpt": "We shipped.",
    "status": "published",
    "tagNames": ["Changelog"],
    "content": { "type": "doc", "content": [
      { "type": "paragraph", "content": [ { "type": "text", "text": "We shipped." } ] }
    ] },
    "seoTitle": "Launch Day - Project A",
    "seoDescription": "Project A is live. Here is what changed.",
    "ogImage": "https://cdn.example.com/launch-og.png",
    "canonicalUrl": "https://blog.example.com/launch-day",
    "robots": "index, follow"
  }'

Returns 201 { "id": "<postId>" }.

Slug rules to expect: a slug you already used returns 409 {"error":"Slug already used"}, and a slug that collides with a reserved Floggy path returns 400 {"error":"This slug is reserved and cannot be used"}. A body that fails validation returns 400 { "error": "Invalid input", "issues": [...] }.

Scheduling

When status is published, a publishedAt in the past or within 5 minutes of now is treated as "publish now" and clamped to the server clock, so the post is readable immediately and the webhook fires. A publishedAt further out stays scheduled: the post is stored, hidden from every public read until that time, and does not fire post.published. See the known limitation on scheduled posts.

Update a post

PATCH /api/posts/:id

SDK: posts.update(id, patch) for a general edit, posts.publish(id, { at? }) and posts.unpublish(id) for the status flip. publish without at reads the post first and re-sends the existing publishedAt, which is what keeps a backdated draft's date through its first publish.

Same fields as create, all optional. Send only what changes. tagNames and coauthors, when present, replace the existing set. Passing publishedAt: null clears the stored date, which resets it to now if the post is being published.

curl -X PATCH "https://floggy-api.pehcastro.workers.dev/api/posts/$POST_ID" \
  -H "Authorization: Bearer $FLOGGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "published", "seoDescription": "Updated summary." }'

Returns { "success": true }.

Bulk

POST /api/posts/bulk

SDK: posts.bulk(action, ids).

curl -X POST "https://floggy-api.pehcastro.workers.dev/api/posts/bulk" \
  -H "Authorization: Bearer $FLOGGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "ids": ["id1","id2"], "action": "publish" }'

action is delete, publish, or unpublish. Between 1 and 100 ids. Ids you do not own are skipped; if none are valid you get 404. Returns { "success": true, "affected": <n> }, where affected counts only the ids that were applied.

Three things to know. The whole endpoint sits behind posts:write, including action: "delete" - so bulk delete needs a weaker scope than DELETE /api/posts/:id, which needs posts:delete. Bulk operations do not fire webhooks; use PATCH /api/posts/:id when you need a post.published or post.updated delivery. And action: "publish" stamps publishedAt to now on every post that was not already published, with no way to preserve a backdated date. Publish those one at a time.

Delete

DELETE /api/posts/:id

SDK: posts.delete(id). Note the scope: this one endpoint needs posts:delete, which no other post endpoint requires.

curl -X DELETE "https://floggy-api.pehcastro.workers.dev/api/posts/$POST_ID" \
  -H "Authorization: Bearer $FLOGGY_API_KEY"

Returns { "success": true }.


Rate limits

Surface Limit Keyed by
API key requests 300 req / min the key
Public read endpoints 60 req / min client IP

The per-key limit applies to requests authorized with an flg_ key, not to web-app sessions. Over the limit returns 429:

{ "error": "rate_limited", "retryAfter": 60 }

with a Retry-After header (seconds). Edge caching is the primary defense on public reads; the per-IP limit just stops uncached scraping. Honour Retry-After and back off. The SDK surfaces this as a RateLimitError with retryAfter.


Next

  • SDK reference - the same surface, typed, with Next.js and TanStack Start examples.
  • Authentication - creating flg_ keys, the scope table, presets, and key-safety rules.
  • Webhooks - subscribe to post.published / post.updated and verify signatures.
  • Collections API - the REST surface for schema-driven content stores.