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