API reference

Admin API

The authenticated dashboard and agent surface for managing collections and entries.

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:

  1. 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).
  2. Role-based permissions on top of the collections entitlement:
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/collections

Returns 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/:id

Update 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: 4

Soft 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 version increments.
  • Stale (the resource changed since you last read it) → 409 with 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) → 400 validation_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      # create

or directly by id under /collections/admin/entries:

GET    /collections/admin/entries/:id
PUT    /collections/admin/entries/:id
DELETE /collections/admin/entries/:id

List 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 sortOrder appended to the end of the collection.
  • For a single collection, a second entry is rejected (409) — edit the existing one instead.
  • data is validated against the collection's schema. relation references 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 draftpublished (sets publishedAt). Requires collections:entries.publish.
  • Unpublish flips publisheddraft and 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.

Collections · API referenceEdit on GitHub