The admin surface is where publishers create and manage collections and entries. It is JWT
authenticated (you are already signed in when using the Vlozi dashboard) and gated by the gateway
through the collections entitlement and an active serving subscription.
All admin routes live under:
https://api.vlozi.app/collections/admin/…The same operations are available to AI agents over the MCP transport (see below) — the handler behavior is identical.
Authentication & authorization
Requests are authenticated by the gateway via your signed-in session. The downstream worker then enforces two things:
- A tenant scope — every request must carry a tenant ID; data is always scoped to that tenant (a collection/entry from another tenant can never be read or modified by id).
- Role-based permissions on top of the
collectionsentitlement:
| Permission | Grants |
|---|---|
collections:read |
View collections and entries |
collections:write |
Create, update, delete, reorder, bulk-delete collections and entries |
collections:entries.publish |
Publish / unpublish entries (kept separate so an editor role can author without making content live) |
Failed permissions return 403; a missing/invalid tenant returns 400.
Collections
List collections
GET /collections/admin/collectionsReturns all non-deleted collections for the tenant, each with a live entry count.
{
"data": [
{
"id": "col_abc123",
"name": "Team Members",
"slug": "team-members",
"cardinality": "many",
"status": "active",
"entryCount": 12,
"publishedCount": 8,
"createdAt": "2026-08-01T00:00:00.000Z",
"updatedAt": "2026-08-20T00:00:00.000Z"
}
]
}Create a collection
POST /collections/admin/collections
Content-Type: application/json
{
"name": "Team Members",
"cardinality": "many",
"schema": {
"fields": [
{ "name": "name", "type": "text", "required": true },
{ "name": "role", "type": "select", "options": ["Engineer", "Designer"] }
]
},
"settings": { "titleField": "name" },
"allowedOrigins": []
}Returns the created collection with 201. The slug is auto-generated from the name and kept
unique within the tenant (a -2, -3… or random suffix is appended on collision). Optionally you
can enforce any field type constraints, relations, and groups — see
Field types.
Get a collection
GET /collections/admin/collections/:idUpdate a collection
PUT /collections/admin/collections/:id
If-Match: 4
Content-Type: application/json
{
"name": "Team",
"status": "active"
}Accepts any subset of name, schema, settings, allowedOrigins, and status
(active | archived). cardinality is immutable and is rejected if included.
Delete a collection
DELETE /collections/admin/collections/:id
If-Match: 4Soft deletes (archives) the collection and every non-deleted entry in it. The data is no longer served publicly, but the rows are kept internally. This is irreversible from the API.
If any other live collection still has a relation field targeting this one, the response
includes a strandedRelationFields array ({ collectionId, fieldName }[]) — those references now
resolve to nothing on public reads, so re-point or remove them as intended:
Optimistic concurrency (If-Match)
PUT/DELETE on a collection or entry accept an optional If-Match header set to the
resource's version (a positive integer returned on every read). The worker compares it
atomically with the stored value and only applies the write if they match:
- Match → write succeeds normally and the resource's
versionincrements. - Stale (the resource changed since you last read it) →
409with message"was modified by someone else. Current version: <n>"so you can re-fetch and retry. This prevents two editors (or an agent + a human) from silently clobbering each other. If-Match: *→ "must already exist" (the write's 404 check covers that).- Malformed
If-Match(not a positive integer) →400validation_error.
Omitting If-Match keeps the previous behavior (last-write-wins). Version increments on every
write, so edits within the same second are still distinguished — unlike the second-granularity
updatedAt timestamp.
Entries
Entries are managed either nested under a collection:
GET /collections/admin/collections/:id/entries # list
POST /collections/admin/collections/:id/entries # createor directly by id under /collections/admin/entries:
GET /collections/admin/entries/:id
PUT /collections/admin/entries/:id
DELETE /collections/admin/entries/:idList entries
GET /collections/admin/collections/:id/entries?status=published&page=1&limit=25| Param | Type | Default | Description |
|---|---|---|---|
status |
draft | published |
both | Filter by publish status |
page |
number | 1 | 1-based page |
limit |
number | 25 | Per page (max 100) |
Create an entry
POST /collections/admin/collections/:id/entries
Content-Type: application/json
{
"data": { "name": "Ada Lovelace", "role": "Engineer" }
}- The entry is created as a draft, and its
sortOrderappended to the end of the collection. - For a
singlecollection, a second entry is rejected (409) — edit the existing one instead. datais validated against the collection's schema.relationreferences are existence-checked (and must belong to the declared target collection). Rich-text is sanitized at write time.
Update an entry
PUT /collections/admin/entries/:id
If-Match: 7
Content-Type: application/json
{
"data": { "name": "Ada Lovelace", "role": "Principal Engineer" }
}Replaces the entry's data. Publish status is unchanged.
Publish / unpublish
POST /collections/admin/entries/:id/publish
POST /collections/admin/entries/:id/unpublish- Publish flips a
draft→published(setspublishedAt). Requirescollections:entries.publish. - Unpublish flips
published→draftand removes it from the public surface. - Both are idempotent from the caller's perspective: publishing an already-published entry succeeds silently rather than erroring.
Reorder
PATCH /collections/admin/entries/reorder
Content-Type: application/json
{
"collectionId": "col_abc123",
"ids": ["entry_a", "entry_b", "entry_c"]
}The index of each ids entry becomes its new sortOrder. This is exactly what the embed
widget's default render uses. Only the ids you pass are moved; omitted entries keep their position.
Requires collections:write.
Response: { updated: number, missingIds: string[] } — updated counts the ids actually
reordered; missingIds lists any requested id that wasn't (already deleted or from another
collection), so you can detect drift.
Bulk delete
DELETE /collections/admin/entries/bulk
Content-Type: application/json
{
"ids": ["entry_a", "entry_b"]
}Soft-deletes multiple entries at once (up to 200). Returns { deleted, requested }.
NOTE
/bulk and /reorder are intentionally distinct routes from /entries/:id; a literal request
can never be swallowed as a resource id.
File uploads
Upload a file for an entry field
POST /collections/admin/uploads
Content-Type: multipart/form-data
file: <binary file>Used by the dashboard's entry editor to store an image/file field before the entry itself is
saved. Returns a FileRef that you embed into the entry's data on the next save:
{
"__file": true,
"mediaId": "media_…",
"name": "ada.jpg",
"size": 204123,
"type": "image/jpeg",
"url": "https://cdn.vlozi.app/…/ada.jpg"
}Uploads are stored with public visibility (unlike form lead attachments) because they render
unauthenticated on your site. Limits: 1 file per request, up to 25 MB. Use the returned url
wherever you render the file.
Responses & errors
Admin success responses return the raw resource (or { data: […] } for lists, 201 for creates,
{ status: "deleted" } for deletes). Errors come in three shapes depending on where they fail:
1. Business-logic errors from a handler (collection/entry not found, conflicts, domain validation, server errors) use a structured envelope:
{ "error": "…", "code": "validation_error", "request_id": "…" }| Status | Code | When |
|---|---|---|
400 |
validation_error |
Invalid input, or a malformed If-Match header |
400 |
bad_request |
Missing/invalid parameter |
404 |
not_found |
Collection or entry missing |
409 |
conflict |
e.g. second entry in a single collection; a stale If-Match version |
422 |
validation_error |
Entry data violates the schema; a relation pointing at a missing/wrong-collection entry |
500 |
internal_error |
Server error |
2. Guard failures (authentication/authorization middleware) return a plain two-field object with
no code or request_id:
| Status | Body |
|---|---|
400 |
{ "error": "Bad Request", "message": "Tenant ID is required" } — tenant context missing |
403 |
{ "error": "Forbidden", "message": "Missing required permission: …" } — insufficient role/permission |
3. Schema-validation failures at the router boundary (via Zod) return Hono's validator error
shape with 400.
The MCP transport
The exact same operations are exposed to AI agents through the MCP gateway, under
/collections/mcp/tools/…, with identical permission guards and the same { data, error } envelope.
Tool names use snake_case (list_collections, create_collection, create_entry,
publish_entry, …). The gateway injects the same tenant context and permissions, so an agent is
bound by the same role rules as a human. Adding a tool updates three places in unison: the
service-catalog descriptor, the MCP route handler, and the permission guard.