Collections
Collections are custom, freely definable data structures (blog, team, products, FAQ …) that are used in templates via data("slug").
Wherever {ref} appears, you can use the ID or slug: /collections/3/items ≙ /collections/blog/items.
Collection schema
{
"id": 1,
"slug": "blog",
"name": "Blog",
"folder": "Website A",
"public_api": true,
"live": false,
"singleton": false,
"detail_page_id": 7,
"detail_field": "slug",
"lang": "de",
"fields": [
{ "name": "titel", "label": "Titel", "type": "text", "required": true },
{ "name": "slug", "label": "URL", "type": "text", "required": true },
{ "name": "datum", "label": "Datum", "type": "date", "required": false },
{ "name": "bild", "label": "Bild", "type": "image", "required": false },
{ "name": "text", "label": "Text", "type": "markdown", "required": false },
{ "name": "kategorie", "label": "Kategorie", "type": "select", "required": false, "options": ["News", "Technik"] },
{ "name": "autor", "label": "Autor", "type": "reference", "required": false, "collection": 2 },
{ "name": "tags", "label": "Tags", "type": "references", "required": false, "collection": 3 }
],
"created_at": 1791191665,
"updated_at": 1791191665,
"item_count": 12
}
| Field | Description |
|---|---|
slug |
a-z0-9_-, starts with a letter. Unique across collections and REST sources |
folder |
Folder in the admin list, for organization only (empty = none) |
public_api |
true = read endpoints reachable without authentication (published entries only), also cross-origin (CORS: Access-Control-Allow-Origin: *) |
live |
true = live data: the entries are created on the live server (e.g. by a workflow from a form) and are preserved when importing a bundle |
singleton |
true = exactly one entry (website settings, color codes …): GET …/items, /data/{slug} and data() in the template return it as an object (or null) instead of a list. A second entry (also via batch) returns 409, the CSV import 400; the easiest way to create and change it is PUT …/item. Switching is only possible with at most one entry (409) |
detail_page_id |
Detail page: ID of a (catch-all) page under which the entries appear – path = page + / + value of detail_field. The admin shows the preview of this page when editing an entry. 0 = none; unknown page: 400 |
detail_field |
Field containing the URL part (default slug, must be a field – otherwise 400) |
lang |
Language of all entries (BCP 47, e.g. de, en; empty = no language). Required to link entries as language versions |
fields[].name |
Field name in the template (item.titel): [a-zA-Z_][a-zA-Z0-9_]*, unique, not id |
fields[].label |
Display name in the admin (default: name) |
fields[].required |
Required field |
fields[].options |
Allowed values for select |
fields[].collection |
For reference/references: ID of the linked collection (must exist; can be the collection itself) |
item_count |
Number of entries (read-only) |
Field types
| Type | Stored as | Notes |
|---|---|---|
text |
string | |
textarea |
string | Multi-line |
html |
string | Output in the template with |safe |
markdown |
string | Markdown source; output as HTML in the template with |markdown. The API returns the source |
number |
number | Strings such as "3.5" are converted |
boolean |
bool | If the value is missing, false is stored |
date |
string | Recommended: ISO YYYY-MM-DD (sortable) |
url |
string | |
select |
string | Must be one of the options (if any are defined) |
image |
number | File ID → in the template {{ image(item.bild) }} |
file |
number | File ID → in the template file(item.datei).url |
reference |
number | Entry ID of the collection collection. Also as a string |
references |
number[] | List of entry IDs, also as a string "1, 2". Duplicates are dropped, the order is kept |
blocks |
array | Block tree (also as JSON text). Strictly validated on create and on partial updates that contain the field (400 with a block.* code and params.field). In the template {% blocks item.field %}. CSV export as JSON text; on import, invalid trees are left empty with a warning |
The items endpoints return references as IDs. In the template (data(), item()) and via GET /data/{slug} they are resolved: instead of the ID, the linked entry appears, one level deep. Deleted entries and – without drafts – unpublished entries are dropped. Filters compare the IDs; for references, equality means “contains” (data("blog", "tags", 3)), __ne means “does not contain”. The CSV import accepts IDs or the text of a target entry (preferably slug), the export writes IDs separated by commas.
Empty values ("", null, missing) are stored as null. Fields that are not in the schema are discarded.
| Endpoint | Access | Description |
|---|---|---|
GET /collections |
🔑 | All collections |
POST /collections |
✏️ | Create → 201 |
GET /collections/{ref} |
🔑 / 🌐 with public_api |
Single collection |
PUT /collections/{ref} |
✏️ | Merge update. fields – if specified – is completely replaced |
DELETE /collections/{ref} |
✏️ | Deletes the collection with all entries → 204 |
Note on changing the schema: existing entries are not migrated. Values of renamed or removed fields remain stored until the entry is saved the next time – then they are discarded.
Without authentication, non-public collections return 404 (not 401), so that their existence cannot be detected.
Entries
Entry object
{
"id": 5,
"collection_id": 1,
"data": {
"titel": "Hallo Welt",
"slug": "hallo-welt",
"datum": "2026-10-05",
"bild": 7,
"text": "Erster **Beitrag**",
"kategorie": "News"
},
"sort": 0,
"published": true,
"translation_group": "",
"created_at": 1791191665,
"updated_at": 1791191665
}
GET /collections/{ref}/items 🔑 / 🌐
All entries in manual order (sort). With authentication including drafts, publicly only published: true. For a singleton, the one entry as an object (or null).
For filtering, sorting and limiting, use GET /data/{slug}.
GET /collections/{ref}/items/{id} 🔑 / 🌐
Single entry. Publicly: drafts return 404.
POST /collections/{ref}/items ✏️
{
"data": { "titel": "Hallo Welt", "slug": "hallo-welt", "bild": 7 },
"published": true
}
published is optional (default true). The entry is appended at the end. 201, 400 on validation errors, e.g. field "Titel" is required.
Several entries at once: send an array instead of an object (max. 1000 entries per request):
[
{ "data": { "titel": "Erster", "slug": "erster" } },
{ "data": { "titel": "Zweiter", "slug": "zweiter" }, "published": false }
]
All or nothing: if one entry is invalid, none is created; the message names the index (starting at 0), e.g. item 1: field "Titel" is required. The entries are appended at the end in the order of the array. Response 201 with the array of created entries (including id). The page cache is cleared once; the created event fires per entry, as with a single POST. An empty or too large array returns 400.
PUT /collections/{ref}/items/{id} ✏️
Partial update: only the fields specified in data are changed, the rest is kept.
{ "data": { "titel": "Neuer Titel" }, "published": false }
GET /collections/{ref}/export 🔑
All entries (including drafts) as CSV, Content-Disposition: attachment; filename="<slug>-<datum>.csv". Columns: id, published, then the fields in schema order (header row = field names). UTF-8 with BOM, comma as delimiter; yes/no as true/false, numbers with a decimal point, images/files as file ID.
POST /collections/{ref}/import ✏️
CSV import into an existing collection. multipart/form-data:
| Field | |
|---|---|
file |
CSV file (UTF-8, optionally with BOM, max. 32 MB / 50,000 rows). The delimiter , ; or tab is detected from the header row. |
mapping |
optional, JSON array with one entry per column: field name, "" (ignore), "_published" (yes/no → published), "_draft" (yes/no → draft) or "_id" (entry ID, only for key=_id). Without it: mapping by field name/label, column id → _id, Webflow columns Draft/Archived → _draft. |
key |
optional, matching: a mapped field (e.g. slug) or _id. Rows whose value already belongs to an entry update it (only the mapped fields; status only with a status column), otherwise a new entry is created. Empty value → new entry. Errors: value duplicated in the file, value in several entries, unknown _id. Without key, all rows are appended. |
Query: dry_run=1 (write nothing – returns columns, mapping, preview, errors and the counters), replace=1 (without key: delete all existing entries; with key: only those that don’t appear in the file – the others keep their ID). Everything in one transaction; new entries are created before deleting so that no deleted ID is reassigned.
{
"delimiter": ";", "rows": 2, "columns": [{ "name": "Titel", "samples": ["Yoga", "Pilates"] }],
"mapping": ["titel", "preis", "_published", ""], "key": "titel",
"preview": [{ "data": { "titel": "Yoga", "preis": 1234.5 }, "published": true, "action": "update", "id": 12 }],
"errors": [], "error_count": 0,
"warnings": [{ "row": 3, "column": "Bild", "message": "no file for \"https://…\"", "code": "csv.noFileFor", "params": { "value": "https://…" } }], "warning_count": 1,
"created": 1, "updated": 1, "deleted": 0, "imported": 2
}
Conversion per field type: number (1234.5, 1.234,5, 1,234.5; comma only = decimal comma), yes/no (true/false, ja/nein, yes/no, 1/0, x), date → JJJJ-MM-TT (ISO, TT.MM.JJJJ, RFC 1123, Webflow format), image/file (file ID or source URL of an imported file – otherwise empty with a warning), otherwise text. After that, the usual checks apply (required fields, select options). row is the row in the file (header row = 1); empty rows are skipped.
Without dry_run: all or nothing in one transaction – on errors 400 with error (code csv.rowError/csv.cellError for a single error, otherwise csv.errors), errors and error_count. Entries in errors/warnings carry code and params where available. Success 200 with created, updated, deleted and imported (= new + updated). Does not trigger created events (unlike POST …/items). Dry runs don’t clear the page cache.
POST /collections/import ✏️
New collection from a CSV file, otherwise as above. Form fields: file, name (only for the suggestion), optionally collection (JSON as for POST /collections: name, slug, fields) and mapping. Without collection, the server suggests slug, field names and types (response field collection) – types based on all values of the column: yes/no, number (without leading zero), date, URL, HTML, multi-line text, text; Webflow management columns and an id column are ignored. With dry_run=1, only suggestion and preview; without it, the server creates the collection and entries (201, collection contains the new id). key and replace are not available here.
curl -X POST -H "Authorization: Bearer $TOKEN" -F file=@blog.csv -F name=Blog "https://example.com/api/v1/collections/import"
POST /collections/{ref}/items/reorder ✏️
Sets the order. Body: array of entry IDs in the desired order. Entries not listed keep their sort value. 204.
[5, 2, 9, 1]
DELETE /collections/{ref}/items/{id} ✏️
204.
PUT /collections/{ref}/item ✏️
For singletons only: creates or changes the one entry (partial update as with PUT …/items/{id}, with base_updated_at conflict check). Body { "data": { "primaerfarbe": "#7c5cff" }, "published": true }, response: the entry. For other collections 400.
Language versions of entries
As for pages: GET/POST/DELETE /collections/{ref}/items/{id}/translations, link with { "collection": "blog-en", "item_id": 12 } (collection = ID or slug, missing = the same collection; optionally "move": true). The language of an entry is that of its collection (lang) – a collection has one language; for several languages create separate collections (blog + blog-en). Entries of collections without a language cannot be linked (400 translation.noLang). url/path point to the collection’s detail page (detail_page_id + value of detail_field), empty without a detail page. Changing a collection’s language checks the groups of its entries (409 translation.langTaken; removing it while entries are linked: 409 translation.collectionLinked).