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.
- Public reads need no auth for published, public content.
- Admin and write need an
flg_key with the right scope. See Authentication. - Rate limits apply to both.
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:
contentis always the raw Tiptap (ProseMirror) JSON document, regardless offormat.renderedis thehtmlormarkdownstring whenformatis set to one of those, andnullwhenformat=tiptap.formatechoes which format producedrendered.
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.updatedand verify signatures. - Collections API - the REST surface for schema-driven content stores.