API reference
The full REST surface for Agent Collections. The SDK (@floggy/cms) and CLI (floggy) wrap this, but every endpoint works with plain fetch or curl.
Base URL: https://floggy-api.pehcastro.workers.dev
Collections require the Pro plan.
Auth
Every collection endpoint needs an flg_ key:
Authorization: Bearer flg_xxxxxxxx...
Scopes: collections:read, collections:write, collections:delete. A key missing the scope gets 403 {"error":"Insufficient scope","required":[...]}. No key or an invalid/revoked key gets 401. Rate limit is 300 req/min per key; over it returns 429 with a Retry-After header.
Errors
| Status | Meaning |
|---|---|
400 |
Malformed body, schema validation failure, or bad params. |
401 |
Missing, invalid, or revoked key. |
403 |
Key lacks the required scope, or account is not on Pro. |
404 |
Collection, entry, version, or label not found. |
409 |
Optimistic-lock conflict: ifVersion did not match the current version. |
413 |
Entry exceeds 256KB, or an account limit was hit. |
429 |
Rate limited. Honour Retry-After. |
Collections
List collections
GET /api/collections
Scope: collections:read. Returns your collections with their schemas and entry counts.
curl "https://floggy-api.pehcastro.workers.dev/api/collections" \
-H "Authorization: Bearer $FLOGGY_API_KEY"
[
{ "id": "landing-pages", "schema": { "title": "Landing pages", "fields": [] }, "entryCount": 12 }
]
Create a collection
POST /api/collections
Scope: collections:write. Body: { name, slug, schema? }. name is the display name; slug is lowercase, digits, hyphens, and is what you address the collection by. schema is the schema object. Up to 10 collections per account (else 400).
fields and indexes may also be sent at the top level and are folded into schema. Any other key is a 400 - a schema definition that silently vanished would return 201 on a collection that indexes nothing, and every later write would project null for slug/title/summary.
curl -X POST "https://floggy-api.pehcastro.workers.dev/api/collections" \
-H "Authorization: Bearer $FLOGGY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Landing pages", "slug": "landing-pages", "schema": { "fields": [ { "name": "slug", "type": "string" } ], "indexes": { "slug": "slug" } } }'
Returns 201 with the created collection, including the schema as stored. Check it back: an empty schema means nothing was indexed.
Get a collection
GET /api/collections/:id
Scope: collections:read. Returns the collection and its schema.
Update a collection
PATCH /api/collections/:id
Scope: collections:write. Body: { name?, schema? } (or top-level fields / indexes); at least one is required. schema is replaced, not merged.
Changing the indexes mapping re-projects existing entries and the response carries reprojected: { updated, skipped, truncated }. skipped counts rows whose recomputed slug was invalid or already taken (those keep their old columns); truncated: true means the sweep hit its per-request bound and the remaining entries need a re-save to pick up the new mapping.
Delete a collection
DELETE /api/collections/:id
Scope: collections:delete. Deletes the collection and all its entries and versions. Irreversible.
Entries
List entries
GET /api/collections/:id/entries
Scope: collections:read. Lists every entry in the collection, drafts included. Unlike the single-entry read, this endpoint does not resolve labels for you. Pass ?label=production to list only published entries.
| Param | Type | Default | Notes |
|---|---|---|---|
page |
number | 1 |
|
perPage |
number | 10 |
Capped at 50. |
slug |
string | - | Filter on the indexed slug column. |
tag |
string | - | Filter on the indexed tag, if mapped. |
label |
string | - | Only entries carrying that label. production is the published set. |
q |
string | - | Text search across indexed title/summary. |
full |
1 |
- | Include each entry's data. Omitted entirely without it. |
full=1 on its own returns the entry's current data, which may be an unpublished draft. Combined with label, it returns the version that label points at, and version on each entry is the labelled version. Listing published content is therefore ?label=production&full=1.
Returns an envelope:
{
"entries": [
{
"id": "V1StGXR8_Z5jdHi6B-myT",
"version": "v_7",
"title": "40% off, this week only",
"summary": "Our biggest sale of the year.",
"slug": "black-friday",
"data": { "slug": "black-friday", "headline": "40% off, this week only" },
"updatedAt": "2026-06-29T10:00:00.000Z"
}
],
"pageInfo": { "total": 12, "page": 1, "perPage": 20, "hasMore": false }
}
Without ?full=1, the entry carries only its indexed columns and no data. hasMore is page * perPage < total.
curl "https://floggy-api.pehcastro.workers.dev/api/collections/landing-pages/entries?slug=black-friday&label=production&full=1" \
-H "Authorization: Bearer $FLOGGY_API_KEY"
Create an entry
POST /api/collections/:id/entries
Scope: collections:write. Body: { data, label? }. data is validated against the schema and must be under 256KB (else 413). The first write creates version v_1.
A create with no label is a draft. Default reads (GET /entries, GET /entries/:entryId) skip it, so it never reaches your site until a label points at it. This is the default, and it is the most common reason freshly written content does not show up.
Pass label to point a label at v_1 in the same call. label is a string, 1-100 characters. "production" publishes on create:
# draft: created, not served
curl -X POST "https://floggy-api.pehcastro.workers.dev/api/collections/landing-pages/entries" \
-H "Authorization: Bearer $FLOGGY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "data": { "slug": "black-friday", "headline": "40% off" } }'
# published: created and live in one call
curl -X POST "https://floggy-api.pehcastro.workers.dev/api/collections/landing-pages/entries" \
-H "Authorization: Bearer $FLOGGY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "data": { "slug": "black-friday", "headline": "40% off" }, "label": "production" }'
Any label name works, not just production. { "label": "variant-a" } creates the entry and opens that variant channel on v_1 while the entry stays unpublished.
The response carries a labels object. It is {} for a draft, so you can assert on it instead of guessing:
{ "id": "<entryId>", "version": 1, "labels": { "production": { "version": 1, "meta": null } } }
A create with a label fires entry.created and then label.updated, the same two events in the same order as the old create-then-set-label pair. Subscribers need no special case.
Over the SDK, label needs @floggy/cms 0.2.1 or newer. Older versions strip the field before sending, the call succeeds, and you get a draft with no error. Check your lockfile before assuming a publish failed on the server.
await floggy.collections.entries.create("landing-pages", {
data: { slug: "black-friday", headline: "40% off" },
label: "production", // requires @floggy/cms >= 0.2.1
});
Get an entry
GET /api/collections/:id/entries/:entryId
Scope: collections:read. Defaults to the production version. Select another with ?label= or a specific ?version=.
| Param | Notes |
|---|---|
label |
Serve the version this label points at (e.g. variant-a). |
version |
Serve this exact version id. Version snapshots are immutable. |
A request for the default (production) version of a draft entry returns 404.
curl "https://floggy-api.pehcastro.workers.dev/api/collections/landing-pages/entries/$ID?label=variant-a" \
-H "Authorization: Bearer $FLOGGY_API_KEY"
Update an entry
PATCH /api/collections/:id/entries/:entryId
Scope: collections:write. Body: { data, ifVersion? }. Each update snapshots a new version. Pass ifVersion with the version you read to guard against a concurrent write: if the current version differs, you get 409 and nothing is written.
curl -X PATCH "https://floggy-api.pehcastro.workers.dev/api/collections/landing-pages/entries/$ID" \
-H "Authorization: Bearer $FLOGGY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "data": { "slug": "black-friday", "headline": "40% off, extended" }, "ifVersion": "v_7" }'
Returns { "id": "<entryId>", "version": "v_8" }. On a mismatch: 409 { "error": "version_conflict", "current": "v_9" }.
There is no label on PATCH. An update writes a new version and leaves every label where it was, so editing a published entry does not republish it: production still points at the old version and your site keeps serving the old copy. To make the edit live, set the label afterwards (Set a label) or use the CLI's --publish, which does that second call for you.
Delete an entry
DELETE /api/collections/:id/entries/:entryId
Scope: collections:delete. Removes the entry and its versions and labels.
Bulk entries
POST /api/collections/:id/entries/bulk
Scope: collections:write (or collections:delete for the delete action). Up to 50 items per call. One of three body shapes:
{ "entries": [ { "data": { "slug": "a" } }, { "data": { "slug": "b" }, "label": "production" } ] }
{ "updates": [ { "id": "id1", "data": { "slug": "a" }, "ifVersion": "v_3" } ] }
{ "ids": ["id1", "id2"], "action": "delete" }
Returns per-item results, including any 409 conflicts in the updates shape without failing the whole batch.
label is per item in the entries (create) shape, with the same rules as a single create: no label means a draft, "production" publishes that item on create. One batch can mix the two, so an agent can push a run where the reviewed entries go live and the rest stay drafts.
The updates shape takes no label. Updates write versions and never move a label, so republishing an edited entry is a separate label call per entry.
curl -X POST "https://floggy-api.pehcastro.workers.dev/api/collections/landing-pages/entries/bulk" \
-H "Authorization: Bearer $FLOGGY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "entries": [ { "data": { "slug": "a", "headline": "A" }, "label": "production" }, { "data": { "slug": "b", "headline": "B" } } ] }'
Versions
Version snapshots are immutable, so they are safely cacheable. Responses carry an ETag; send If-None-Match for a 304.
List versions
GET /api/collections/:id/entries/:entryId/versions
Scope: collections:read. Returns the retained versions (last 10, plus any pinned by a label), newest first.
[
{ "version": "v_8", "createdAt": "2026-06-29T10:05:00.000Z", "labels": ["production"] },
{ "version": "v_7", "createdAt": "2026-06-29T10:00:00.000Z", "labels": ["variant-a"] }
]
Get one version
GET /api/collections/:id/entries/:entryId/versions/:version
Scope: collections:read. Returns that version's data. Immutable and cacheable.
Labels
Labels are named pointers to versions. production is the published version; the rest are yours to define.
Get a label
GET /api/collections/:id/entries/:entryId/labels/:name
Scope: collections:read. Returns the version the label points at and its meta.
{ "label": "variant-a", "version": "v_7", "meta": { "weight": 50 } }
Set a label
PUT /api/collections/:id/entries/:entryId/labels/:name
Scope: collections:write. Body: { version, meta? }. Points the label at version. meta is free-form JSON that Floggy stores and returns verbatim, never interprets. Pointing production at a version publishes it; pointing it at an older version is a rollback. A version referenced by any label is exempt from the 10-version retention prune.
curl -X PUT "https://floggy-api.pehcastro.workers.dev/api/collections/landing-pages/entries/$ID/labels/production" \
-H "Authorization: Bearer $FLOGGY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "version": "v_7", "meta": { "note": "promoted variant-a" } }'
Setting a label fires the label.updated webhook.
Remove a label
DELETE /api/collections/:id/entries/:entryId/labels/:name
Scope: collections:write. Removes the pointer. Removing production turns the entry back into a draft. The version it pointed at loses its pin and becomes eligible for pruning again.
Webhooks
Collection changes fire webhooks managed in Settings -> Developer, signed the same way as Floggy post webhooks (X-Floggy-Signature: sha256=<hex>, HMAC-SHA256 over the raw body).
| Event | Fires when |
|---|---|
entry.created |
An entry is created. |
entry.updated |
An entry gets a new version. |
entry.deleted |
An entry is deleted. |
label.updated |
A label is set or removed (includes promotion to production). |
A create carrying a label fires both: entry.created first, then label.updated. That is identical to the two-call create-then-publish flow, so a subscriber written against the old flow keeps working unchanged.
Verify the signature exactly as in the post webhooks guide.
Next
- Versioning and labels - the model behind versions, labels, and rollback.
- A/B testing - the identifier triple and promotion loop.