The entries API provides full CRUD for entries within a content bucket and content type.
List published entries
GET /api/v1/{workspace}/entries?contentBucket={contentBucket}&contentType={contentType}
Returns all published entries for the specified content type. Draft entries are excluded.
| Parameter | Required | Default | Description |
|---|---|---|---|
contentBucket |
Yes | — | Content Bucket name (e.g. content) |
contentType |
Yes | — | Content Type name (e.g. pages) |
locale |
No | en-US |
Locale code or * for all locales |
curl "https://app.example.com/api/v1/acme/entries?contentBucket=content&contentType=pages&locale=en-US" \
-H "Authorization: Bearer $VIRESSO_API_KEY"
Response:
[
{
"id": "01HY8M7M7D4RT5N6P2Q3R4S5T6",
"api_url": "https://app.example.com/api/v1/acme/entries/01HY8M7M7D4RT5N6P2Q3R4S5T6",
"name": "Homepage",
"status": "published",
"status_label": "Published",
"publish_at": null,
"tags": ["homepage"],
"locales": ["en-US"],
"fields": {
"title": "Homepage",
"slug": "home",
"body": "<p>Welcome.</p>",
"featured": true
},
"created_at": "2026-05-20T09:30:00.000000Z",
"updated_at": "2026-05-20T09:45:00.000000Z",
"lock_version": 0
}
]
For large content types, request a bounded page with per_page (1–100, default 50 when paging). The response then uses Laravel cursor pagination with data, next_page_url, and prev_page_url. Follow the returned URL to continue. view=summary returns id, api_url, name, status, and updated_at without expanding fields. The original response remains available when none of per_page, cursor, or view is supplied.
curl "https://app.example.com/api/v1/acme/entries?contentBucket=content&contentType=pages&per_page=50&view=summary" \
-H "Authorization: Bearer $VIRESSO_API_KEY"
The additional GET /api/v2/{workspace}/entries endpoint always returns a cursor page, with view=summary and per_page=50 by default. Use view=full for expanded fields. Its authentication and workspace scoping are the same as v1.
Query entries
Use a JSON request to select fields, filter related content, and paginate results:
POST /api/v1/{workspace}/{bucket}/entries/query
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
The workspace and bucket are resolved from the URL. The key needs entries:query and its creator needs list-entries. Queries include draft and published entries by default, including related entries used in filters, sorting, expanded fields and bare relation IDs. All references remain restricted to accepted types in the same bucket. This endpoint supports SQLite and MySQL.
entries:query grants no write or delivery permission. Wildcard keys include this ability. The GET /api/v1/{workspace}/entries and GET /api/v1/{workspace}/entries/{entry} delivery endpoints still require entries:read and return only published entries, even for wildcard keys.
Existing query integrations using entries:read must explicitly enable entries:query; otherwise queries return 403. Integrations using the former management endpoint must switch to this endpoint and replace entries:manage:read with entries:query. Published-only keys are not automatically upgraded.
{
"type": "products",
"fields": ["@id", "title", "price", "brand.name"],
"filter": {
"price": { "lt": 100 },
"brand.tier": { "eq": "premium" }
},
"sort": [{ "field": "price", "direction": "asc" }],
"locale": "en-US",
"offset": 0,
"limit": 20
}
{
"data": [
{
"@id": "01HY8M7M7D4RT5N6P2Q3R4S5T6",
"title": "Studio Headphones",
"price": 89.5,
"brand": { "name": "North Audio" }
}
],
"pagination": { "offset": 0, "limit": 20, "hasMore": false }
}
Request properties
| Property | Behavior |
|---|---|
type |
Required content type name in the bucket. |
fields |
Up to 100 field paths. Omitted: own content fields, with visible relation/file IDs and no expansion. Empty list: an empty object per result. |
filter |
An object of field/operator conditions; omitted means no additional filter. |
sort |
Up to five { "field": "price", "direction": "asc" } objects in precedence order; direction is asc or desc. |
locale |
One configured locale, default en-US. Applies to field values throughout filtering, sorting and expansion. No implicit fallback or *. |
aggregate |
Select count: true and/or numeric sum, avg, min, max field-name arrays. Runs in SQL over matching entries. Cannot combine with fields or sort. |
groupBy |
With aggregate, up to three string, boolean or date fields, including single-relation paths and calendar bucket objects. Returns one row per combination. |
offset |
Entries (or aggregate groups) to skip, default 0, maximum 10,000. |
limit |
Maximum entries (or aggregate groups) returned, default 20, range 1–100. |
Aggregations and grouped reporting
Count every matching entry, including drafts, without fetching entry pages:
{
"type": "tickets",
"filter": { "status": { "eq": "open" } },
"aggregate": { "count": true }
}
{ "data": [{ "count": 1240 }] }
An ungrouped count always returns one row, including 0 for an empty match. Omit offset and limit for this form. For a breakdown, add a grouping field:
{
"type": "tickets",
"aggregate": { "count": true },
"groupBy": ["status"],
"limit": 20
}
{
"data": [
{ "group": { "status": "closed" }, "count": 830 },
{ "group": { "status": "open" }, "count": 1240 }
],
"pagination": { "offset": 0, "limit": 20, "hasMore": false }
}
Counts cover the entire filtered set before group pagination. Each root entry counts once, even when multiple related entries satisfy a some filter. Existing permission, bucket, locale and filter rules apply. There is no locale fallback. Missing and null values share a null group; an empty grouped result has no rows. Groups sort lexicographically in the requested field order, ascending with null last for each dimension. Strings group case-sensitively, including trailing whitespace. Every matching string key must fit in 512 UTF-8 bytes; an oversized key rejects the entire request, even beyond the requested page. MySQL must have max_sort_length of at least 512. Date/time keys use the field's usual output format; datetime keys are in UTC. The nested group object avoids collisions with content fields named count.
Grouping supports one to three string, boolean or date fields, including compatible metadata such as @status and paths through single relations such as customer.name. Numeric grouping, arrays, JSON paths and paths through multiple relations return 422. Group fields must not repeat, even with different bucket intervals. Aggregate requests cannot also select fields or supply sort.
Aggregation runs in the database; it does not load matching entries into application memory. limit bounds the number of returned groups, not the work needed to filter and group matching records. Large reports still require appropriate indexes and measured query plans. Counts are a statement-time view of committed data, not a reservation or write guard.
Numeric sums, averages, minimums and maximums
Use field-name arrays for any combination of sum, avg, min and max. count is optional. The same format works in single queries and named batch queries:
{
"type": "orders",
"filter": { "status": { "eq": "paid" } },
"aggregate": {
"count": true,
"sum": ["amount"],
"avg": ["amount"],
"min": ["amount"],
"max": ["amount"]
},
"groupBy": ["status"]
}
{
"data": [
{
"group": { "status": "paid" },
"count": 3,
"sum": { "amount": "31" },
"avg": { "amount": "15.500000000000" },
"min": { "amount": "10.5" },
"max": { "amount": "20.5" }
}
],
"pagination": { "offset": 0, "limit": 20, "hasMore": false }
}
In this example, two entries contribute 10.5 and 20.5; the third has a null/missing amount. Each operation appears only when requested, and its keys are the requested field names. Select at most 10 distinct numeric fields across all operations. Arrays must be nonempty and must not repeat a field within one operation. Each selected path must end at a single numeric content field, at the root or through up to three single/multiple relations (for example, items.amount). Numeric metadata, JSON paths and multiple-value numeric fields are rejected. The limit counts distinct full paths across all operations.
Decimal contract: stored contributors must be exactly representable as DECIMAL(38,12): at most 26 integer digits and 12 digits after the decimal point, ignoring leading integer zeroes and trailing fractional zeroes. Inputs are never silently rounded to fit. Numeric strings and stored JSON numbers are accepted, including scientific notation. Numeric text is limited to 128 characters after trimming ASCII spaces, and an explicit exponent must be between -100 and 100. Invalid, nonnumeric, excessive-precision or out-of-range contributors reject the query with 422, even if their group lies beyond the requested page. Boolean values and empty strings are invalid, not zero.
sumis exact and can exceed the input range as values accumulate. Its decimal string omits trailing fractional zeroes ("31","0.3").avgdivides the exact sum by the number of non-null contributors for that field, then rounds once to 12 fractional digits, with ties away from zero. It always includes 12 fractional digits ("0.333333333333"). The field's display precision does not change this rule.minandmaxcompare numbers numerically and return exact decimal strings, with trailing fractional zeroes omitted. They do not use string ordering or round values.- Null and missing field values are ignored by all four numeric operations. With no numeric contributors, each requested numeric result is
null; an actual zero is a decimal string.countcounts all matching root entries regardless of missing numeric fields. - An ungrouped query always returns one result row, including for no matches (
count: 0when requested and numeric resultsnull). Omitoffsetandlimit. An empty grouped result returnsdata: []with normal group pagination. Aggregations cover the entire filtered set before group pagination; relation filters never multiply root contributions.
All four numeric results use JSON strings to avoid losing precision in clients. For exact values on input, submit decimal strings when writing numeric fields. Values already rounded by a client, PHP floating-point conversion or the database's JSON-number representation cannot be recovered. Existing filter comparison semantics remain unchanged; numeric filter operands and ordinary numeric field projections still follow the query API's existing JSON-number precision limits.
Aggregation runs in the database query: MySQL uses exact decimal arithmetic; SQLite uses registered exact-decimal SQL aggregate functions. Viresso retrieves aggregate rows rather than loading every matching entry and summing it in application code. The existing bucket, locale (without fallback), draft/publication, filter and grouping rules apply.
Calendar buckets and multiple grouping fields
Mix field names with date bucket objects. This example reports order and item totals per month, customer and order status:
{
"type": "orders",
"filter": { "status": { "eq": "paid" } },
"aggregate": {
"count": true,
"sum": ["amount", "items.amount"],
"avg": ["items.amount"]
},
"groupBy": [
{ "field": "@created_at", "interval": "month", "timezone": "Europe/Amsterdam" },
"customer.name",
"status"
]
}
{
"data": [
{
"group": { "@created_at": "2026-10-01", "customer.name": "Alice", "status": "paid" },
"count": 2,
"sum": { "amount": "30", "items.amount": "30" },
"avg": { "items.amount": "10.000000000000" }
}
],
"pagination": { "offset": 0, "limit": 20, "hasMore": false }
}
- Bucket objects require
fieldandinterval;timezoneis optional and defaults toUTC. No other object properties are accepted. - Intervals are
day,week,month,quarterandyear. Weeks start on Monday; quarters start in January, April, July and October. Buckets follow calendar boundaries, not fixed durations. - Datetime values are stored/interpreted in UTC and converted to the requested IANA timezone before bucketing, including daylight-saving transitions. Bucket labels are the local start date (
YYYY-MM-DD), not UTC instants. Each bucket includes its start and excludes the next bucket's start. Date-only fields use their calendar date and accept only omitted timezone orUTC; time-only fields cannot be bucketed. - Missing/null dates join the null group. Only observed combinations are returned: there is no empty-period filling. Exact grouping by a plain field name retains its existing behavior.
- Results use the full field path as the key in
group. Grouping bycustomer.namecombines customers with equal names; usecustomer.@idwhen distinct customer identity matters. Grouping through multiple relations is rejected to keep each root in one group. - Pagination applies after all matching roots and related contributors have been aggregated. The complete grouping tuple determines stable ascending order, with null last in each dimension. The 512-byte string limit applies to each dimension, including values beyond the requested page.
MySQL named timezone bucketing requires populated, current timezone tables. An unavailable timezone returns 422 on groupBy.N.timezone; UTC works without these tables. Non-UTC dates outside the database's supported timezone-conversion range return 422 on groupBy.N, even outside the group page, rather than silently producing an unconverted bucket. MySQL 5.7 has a narrower range than newer MySQL versions. SQLite uses a registered timezone-aware SQL function. Keep application and database timezone data up to date.
Related numeric contributors
The same sum, avg, min and max arrays accept numeric relation paths without additional request syntax:
{
"type": "orders",
"aggregate": {
"count": true,
"sum": ["amount", "items.amount", "extras.amount"],
"avg": ["items.amount"],
"min": ["items.amount"],
"max": ["items.amount"]
},
"groupBy": ["customer.@id"]
}
For each requested path, a terminal related entry contributes once per matched root. Duplicate IDs and multiple routes to the same terminal entry within that path do not add extra contributions. A terminal entry shared by two roots contributes twice. Different paths are independent: adding extras.amount cannot multiply items.amount or the root's own amount. count always counts matched root entries, including roots without related values.
For example, two orders referencing amounts [10, 20] and [10] produce a related sum of 40 and an average of 13.333333333333, even if both orders share the same entry holding 10. The average uses all non-null terminal contributions, not an average of per-order averages. Missing/deleted references and references to unaccepted types or other buckets are ignored; related drafts and published entries are both included. Root groups are retained when no related values contribute, with numeric results null.
Relation filters qualify the roots, not individual aggregate contributors. An order matching items: { some: { label: { eq: "selected" } } } contributes all its accepted items to items.amount, including items with other labels. To total only selected items, query the item type as the root and filter its own fields. All relation hops and terminal numeric values use the requested locale without fallback. Related values use the same decimal validation and rounding contract as root values; invalid contributors fail the entire query, including outside group pages.
These capabilities also work in named batch queries. They do not use the projection's first-25-reference or response-expansion limits: SQL aggregates cover all valid references. Request path/depth limits still apply, and group pagination bounds response size rather than database work. Scope large reports with filters and measure query plans against representative data.
Selection and value types
Content fields use their own names. Entry metadata has reserved selectors: @id, @lock_version, @status, @locales, @created_at, @updated_at, @publish_at, and @published_at. Metadata retains its @ key in the response. For example, status is a custom order status while @status is its publication status.
Dot paths follow relations and attachments: brand.name, items.brand.name, hero_image.download_url, or brand.@id. Single references expand to objects; multiple references expand to arrays, preserving stored reference order. A bare brand selects its visible reference ID. Selecting both brand and brand.name is rejected because the output shapes conflict. Attachment properties use the existing file API property names and URL authorization behavior.
JSON object fields can select scalar properties through paths such as profile.region; entire JSON fields retain their stored JSON shape. Relation target paths must exist with compatible definitions across every permitted target content type. This also applies when a relation allows every type in the bucket.
Field definitions determine values: strings, JSON numbers, booleans, arrays and stored JSON retain those types. Dates use the configured field format (date: YYYY-MM-DD, time: HH:mm, datetime: ISO 8601). Unlike the older entry renderer, numeric fields are JSON numbers, so trailing decimal zeroes are not presentation formatting. Consumers should format decimals for display; JSON numbers do not promise arbitrary precision.
Known selected fields without stored values return null. The query does not synthesize current timestamps or boolean defaults. Unknown fields fail validation. Unavailable single references return null; multiple references return []. Unselected properties are omitted.
Filtering
Conditions in the same object are ANDed. Operator objects are required: use { "eq": "paid" }, not a bare "paid" value.
| Operator | Meaning |
|---|---|
eq, ne |
Equality/inequality for scalar fields; null tests missing/null values. |
lt, lte, gt, gte |
Numeric or date comparison. Number fields require JSON numeric operands. |
in |
Match one of 1–100 scalar values. |
startsWith, endsWith, contains |
Literal text matching; % and _ are not wildcards. |
includes |
Exact scalar membership in an array field. |
some |
At least one visible entry in a multiple relation satisfies the enclosed conditions. |
$and, $or |
Nonempty lists of condition objects; nesting controls grouping. |
A non-null comparison does not match a missing value, including ne. Use { "ne": null } to require a value. JSON property filters accept scalar operands and compare only properties of the same scalar type (numeric integers and decimals are compatible). Attachment filters, array/object equality, and sorting by arrays, JSON or multiple relations are not supported.
For premium products below 200 or sale products below 50:
{
"type": "products",
"fields": ["title", "price", "brand.name"],
"filter": {
"$or": [
{ "brand.tier": { "eq": "premium" }, "price": { "lt": 200 } },
{ "tags": { "includes": "sale" }, "price": { "lt": 50 } }
]
},
"limit": 24
}
For paid orders containing an expensive premium product:
{
"type": "orders",
"fields": ["reference", "items.title", "items.price"],
"filter": {
"status": { "eq": "paid" },
"items": {
"some": {
"price": { "gt": 100 },
"brand.tier": { "eq": "premium" }
}
}
}
}
Both conditions inside some must match the same product. Filtering qualifies orders; it does not remove other products from their selected items array. Filters examine all stored references, not the older API renderer's first-25 expansion window.
Pagination, bounds, and errors
Missing sort values come last. The default ordering is entry ID ascending; explicit ordering gets an ID tie-breaker. hasMore checks for one further matching entry without computing a total. Offset pages can move when content changes; they are not a snapshot.
Requests are limited to 64 KiB, 100 filter groups/operators, eight nested filter groups, three traversed relation levels, and 1000 referenced entries/files across the response. Reduce the page size or selected paths when expansion is too large. Limits produce HTTP 422 rather than truncated relationship arrays.
Validation errors identify the request path:
{
"message": "Value does not match field type [number].",
"errors": {
"filter.price.lt": ["Value does not match field type [number]."]
}
}
Unknown workspaces, buckets, or content types return 404 after applicable authorization checks. Existing 403 and 429 authentication, permission, and rate-limit behavior applies.
Query drafts and publication status
Use @status to narrow results to drafts or published entries:
{
"type": "tasks",
"fields": ["@id", "@status", "@lock_version", "@locales", "title", "project.title"],
"filter": { "@status": { "eq": "draft" } },
"sort": [{ "field": "@updated_at", "direction": "desc" }],
"locale": "en-US",
"limit": 20
}
The response uses the existing data and pagination shape. Select @id and @lock_version to prepare version-checked updates, @status for publication status, and @locales for the entry's enabled locale codes. Omitted fields still selects own content fields; metadata must be selected explicitly. The root locale chooses which field values to read, without fallback; it does not filter entries by their enabled locales. Use @locales: { "includes": "nl-NL" } for that filter.
A root @status filter affects roots only: a published task can still expand its draft project. All relations remain constrained to accepted types in the same bucket. The usual query limits apply. Reading drafts does not turn a query followed by a write into a protected transaction; expected_version protects the targeted entry, not an arbitrary condition about related entries. Use per-operation checks to evaluate supported conditions inside an atomic batch.
Batch queries
Load several independent results in one request:
POST /api/v1/{workspace}/{bucket}/entries/query/batch
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"queries": {
"recentArticles": {
"type": "articles",
"fields": ["@id", "title", "author.name"],
"sort": [{ "field": "@created_at", "direction": "desc" }],
"limit": 5
},
"articleCount": {
"type": "articles",
"aggregate": { "count": true }
}
}
}
Each member is an ordinary query with its own fields, filter, locale and pagination. Queries can target different content types within the URL's bucket. Authentication requires entries:query and the creator's list-entries permission, just like a single query. Both draft and published entries participate.
HTTP 200 returns a results object with the same names. Each value is the complete single-query response:
{
"results": {
"recentArticles": {
"data": [{ "@id": "01HY8M7M7D4RT5N6P2Q3R4S5T6", "title": "Hello", "author": { "name": "Alex" } }],
"pagination": { "offset": 0, "limit": 5, "hasMore": false }
},
"articleCount": {
"data": [{ "count": 1 }]
}
}
}
Names are case-sensitive, start with an ASCII letter, and contain at most 64 letters, numbers, underscores or hyphens. Names must be unique, including JSON-escaped equivalents. The root object accepts only queries; no root locale, operations, result references or nested query batches. No idempotency key is required.
Consistency: queries execute sequentially using ordinary reads, without a shared database snapshot or write locks. Concurrent changes can make a list and count differ, including changes during relation projection. Batching reduces HTTP round trips; it does not reserve data or protect a later write. Use mutation checks or version checks when acting on query results. Query results cannot supply parameters to other members.
Limits: 1–10 queries; 64 KiB total JSON request; nesting depth 32; existing per-query limits; combined requested page sizes at most 500 rows (default page size 20, ungrouped aggregates consume one row); at most 1000 referenced entries/files across all projections; and a 2 MiB serialized result budget. Pagination applies independently to each query. These output limits do not bound database scan work for filters or aggregates.
Each query consumes one unit of the shared entries rate quota. The complete cost is reserved before execution; insufficient quota rejects the whole request with 429 and Retry-After. Failed attempts can consume quota, and an insufficient reservation can exhaust the remaining allowance. Malformed or oversized batches are charged at least one unit. Rate headers report the shared quota when rate limiting is enabled.
All query definitions are validated before reading entries. Any validation or execution-limit failure returns one error response, without a results object or partial successes. Validation errors use query names:
{
"message": "Unknown or incompatible field [missing].",
"errors": {
"queries.recentArticles.fields.0": ["Unknown or incompatible field [missing]."]
}
}
Invalid query shapes, names, fields, filters or combined budgets return 422. A missing type returns 404 with the query name in the message; a missing bucket also returns 404. Invalid JSON returns 400, a body over 64 KiB returns 413, and a non-JSON body returns 415. Authorization failures return 403. No read batch performs content mutations.
Read one entry
GET /api/v1/{workspace}/entries/{entry}
| Parameter | Required | Default | Description |
|---|---|---|---|
locale |
No | en-US |
Locale code or * |
curl "https://app.example.com/api/v1/acme/entries/01HY8M7M7D4RT5N6P2Q3R4S5T6?locale=en-US" \
-H "Authorization: Bearer $VIRESSO_API_KEY"
Response:
{
"id": "01HY8M7M7D4RT5N6P2Q3R4S5T6",
"api_url": "https://app.example.com/api/v1/acme/entries/01HY8M7M7D4RT5N6P2Q3R4S5T6",
"name": "Homepage",
"status": "published",
"status_label": "Published",
"publish_at": null,
"tags": ["homepage"],
"locales": ["en-US"],
"fields": {
"title": "Homepage",
"slug": "home",
"body": "<p>Welcome.</p>"
},
"created_at": "2026-05-20T09:30:00.000000Z",
"updated_at": "2026-05-20T09:45:00.000000Z"
}
The show endpoint returns published entries only. It returns 404 Not Found if the entry does not exist, is still a draft, or belongs to a different workspace.
Create an entry
POST /api/v1/{workspace}/entries?contentBucket={contentBucket}&contentType={contentType}
| Parameter | Required | Default | Description |
|---|---|---|---|
contentBucket |
Yes | — | Content Bucket name |
contentType |
Yes | — | Content Type name |
locale |
No | en-US |
Locale code or * |
Request body:
| Field | Required | Type | Description |
|---|---|---|---|
fields |
Yes | object | Field name → value pairs matching the content type fields |
status |
No | string | draft or published (default draft) |
publish_at |
No | date-time or null |
Schedule a draft for publication |
locales |
No | array | Locale codes (e.g. ["en-US", "is-IS"]). en-US is always included. |
tags |
No | array | String tags |
skipWebhooks |
No | boolean | Suppress webhook delivery |
curl -X POST "https://app.example.com/api/v1/acme/entries?contentBucket=content&contentType=pages&locale=en-US" \
-H "Authorization: Bearer $VIRESSO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "published",
"locales": ["en-US", "is-IS"],
"tags": ["homepage"],
"fields": {
"title": "Homepage",
"slug": "home",
"body": "<p>Welcome.</p>",
"featured": true
}
}'
Returns 201 Created with the rendered entry.
Datetime field precision
Content fields with type: "date" and format: "datetime" accept YYYY-MM-DD HH:mm, YYYY-MM-DD HH:mm:ss, or the same forms with T instead of a space. An optional Z or numeric offset (±HH:mm, up to ±14:00) is accepted. Offset-free values are interpreted as UTC. For example, 2026-03-02T00:00:37+02:00 is stored as 2026-03-01 22:00:37 UTC.
Single-entry writes, batch writes and the editor preserve seconds. Legacy minute-only values retain their minute-only representation in the existing entry renderer; the query API returns ISO 8601 timestamps. Invalid calendar dates and fractional seconds return 422 rather than being rounded. These rules apply to content datetime fields; date-only, time-only and scheduled-publication fields retain their existing formats.
Update an entry
PATCH /api/v1/{workspace}/entries/{entry}
The update endpoint accepts partial payloads — only include the fields that changed.
| Parameter | Required | Default | Description |
|---|---|---|---|
locale |
No | en-US |
Locale code or * |
Request body:
| Field | Required | Type | Description |
|---|---|---|---|
fields |
No | object | Field name → value pairs. Omitted fields are not modified. |
status |
No | string | draft or published |
publish_at |
No | date-time or null |
Set, replace, or clear scheduled publication |
locales |
No | array | Replaces the existing locale list. Removed locales are deleted with their translations. |
tags |
No | array | Replaces the existing tags |
skipWebhooks |
No | boolean | Suppress webhook delivery |
expected_version, check |
No | integer | Reject the update if the entry has changed since it was read. Send the lock_version returned by the API. |
curl -X PATCH "https://app.example.com/api/v1/acme/entries/01HY8M7M7D4RT5N6P2Q3R4S5T6?locale=en-US" \
-H "Authorization: Bearer $VIRESSO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fields": {
"title": "Updated homepage title"
},
"skipWebhooks": true
}'
Warning: When updating
locales, the array replaces the existing locale list entirely. Any locale removed from the array will have its translations permanently deleted.
Delete an entry
DELETE /api/v1/{workspace}/entries/{entry}
| Parameter | Required | Default | Description |
|---|---|---|---|
skipWebhooks |
No | false | Suppress webhook delivery |
curl -X DELETE "https://app.example.com/api/v1/acme/entries/01HY8M7M7D4RT5N6P2Q3R4S5T6?skipWebhooks=true" \
-H "Authorization: Bearer $VIRESSO_API_KEY"
Returns 204 No Content. The entry and its stored field values are permanently deleted.
If any content type fields have cascade_on_delete enabled, related entries or files are also deleted.
Batch create, update and delete
POST /api/v1/{workspace}/{bucket}/entries/batch
Authorization: Bearer YOUR_API_KEY
Idempotency-Key: article-import-001
Content-Type: application/json
Send 1–100 ordered operations for one bucket and one locale (default en-US). The entire batch commits or rolls back. Field definitions determine validation and value formats, just as on the existing write endpoints.
{
"locale": "en-US",
"operations": [
{
"action": "create",
"type": "articles",
"fields": { "title": "Introducing Viresso", "slug": "/blog/viresso" }
},
{
"action": "update",
"id": "01HY8M7M7D4RT5N6P2Q3R4S5T6",
"expected_version": 3,
"fields": { "title": "Updated title" }
},
{
"action": "delete",
"id": "01HY8M7M7D4RT5N6P2Q3R4S5T7",
"expected_version": 2
}
]
}
A successful request returns HTTP 200. Results follow operation order; create and update results contain the persisted lock_version:
{
"data": [
{ "index": 0, "action": "create", "id": "01HY8M7M7D4RT5N6P2Q3R4S5T8", "lock_version": 0 },
{ "index": 1, "action": "update", "id": "01HY8M7M7D4RT5N6P2Q3R4S5T6", "lock_version": 4 },
{ "index": 2, "action": "delete", "id": "01HY8M7M7D4RT5N6P2Q3R4S5T7" }
]
}
Operation properties
| Action | Required | Optional |
|---|---|---|
create |
action, type (content type name) |
ref, fields, status, publish_at, locales, tags, check |
update |
action, id (existing entry ULID) |
fields, status, publish_at, locales, tags, expected_version, check |
delete |
action, id (existing entry ULID) |
expected_version, check |
Required content fields still apply to creates. Creates default to draft. Unique fields are enforced by the shared field-write path, including single-entry writes and duplication; duplicating populated unique fields is rejected without creating a partial entry. On update, omitted properties remain unchanged; supplied field values, arrays and JSON values replace those values. locale chooses the language to write. Supplying locales replaces the entry's enabled locales (always retaining en-US) and removes values for removed locales, following existing update behavior. tags replaces the entry's tags. Publishing clears publish_at.
expected_version is optional for both update and delete. A mismatch rejects the entire batch with HTTP 422. Read versions using lock_version on existing entry responses or select @lock_version in the query API. Without a version, updates use the current stored entry and overwrite supplied properties. Entries with pending reviews cannot be updated or deleted.
Every action is authorized independently: creates require entries:write and create-entry, updates require entries:write and update-entry, deletes require entries:delete and delete-entry. Creator permission ceilings apply. An unauthorized operation rejects the entire request before mutation. Draft entries can be targeted by ID as well as published entries.
Conditional operations
Add an optional check to any create, update or delete operation. It uses the query API's filter syntax: exists: true requires a matching entry, and exists: false requires no matching entry. Send a one-operation batch when only one conditional write is needed.
For example, create a booking only when no active booking for the same room overlaps it (including bookings with no end):
{
"operations": [
{
"action": "create",
"type": "bookings",
"check": {
"filter": {
"room": { "eq": "A" },
"active": { "eq": true },
"start": { "lt": "2026-10-05 11:00" },
"$or": [
{ "end": { "gt": "2026-10-05 10:00" } },
{ "end": { "eq": null } }
]
},
"exists": false
},
"fields": {
"room": "A",
"active": true,
"start": "2026-10-05 10:00",
"end": "2026-10-05 11:00"
}
}
]
}
The check runs immediately before its operation, against the operation's own content type and the batch locale, including drafts and published entries. There is no locale fallback. It sees earlier writes and deletions in the same batch. Two guarded, conflicting creates therefore cannot both pass against an initially empty set. An update/delete target participates in the check: add "@id": { "ne": "ENTRY_ID" } when an overlap query should exclude the entry being updated. Checks describe existing data; clients must also validate the proposed interval and make the filter correspond to the supplied values.
Checks require entries:query and the creator's list-entries permission, in addition to the operation's write/delete permissions. Authorization is rechecked on successful idempotent replays, but the saved result is returned without reevaluating the check. The check is part of the idempotency payload; changing it requires a new key.
Initial filters support own scalar string, number, boolean and date fields, plus @id, using the existing operators, $and and $or. A check is at most 64 KiB with the query API's 100-condition and eight-level group limits. An empty filter object checks whether any entry of this type exists. Related-field traversal, relation/attachment fields, JSON/array fields, publication metadata, cross-type checks, aggregation and $ref operands are not supported in checks. These remain separate from the broader read-query capabilities. Unknown or unsupported properties return 422 with paths such as operations.1.check.filter.room.
A failed check returns 409, with a zero-based operation index and no matching entry data:
{
"message": "Operation check failed. No changes were committed.",
"code": "condition_failed",
"index": 1,
"committed": false
}
The entire batch rolls back, and the failed request does not reserve its idempotency key. Revise the operation or reload relevant data after a condition failure. Database lock contention also returns 409, distinguished by Retry-After.
Viresso holds the content-type write lock until commit and uses current locking reads for the check and its field values. Matching entries remain locked against deletion. This protects the decision and write together against concurrent supported entry-service writes, including empty matches. Later operations can change the condition; checks are evaluated in order, not reasserted at commit.
Checks apply only when supplied. Ordinary writes, imports, manager edits and operations without a check do not inherit another request's conditions. Every writer that must prevent overlap must supply the appropriate check. There is no stored overlap-rule configuration. Direct SQL or code bypassing the entry-writing services is outside this concurrency guarantee.
Create related entries together
Give a create operation an optional ref, then use { "$ref": "name" } where a later operation expects a relation entry ID. Both entries commit together, including when they are drafts:
{
"operations": [
{
"action": "create",
"type": "projects",
"ref": "newProject",
"fields": { "title": "Office renovation" }
},
{
"action": "create",
"type": "tasks",
"fields": {
"title": "Measure meeting rooms",
"project": { "$ref": "newProject" }
}
}
]
}
The first result includes "ref": "newProject" beside its permanent id; results without a supplied reference omit ref. Reference names are case-sensitive, unique within a request, and 1–64 ASCII characters: begin with a letter, followed by letters, digits, underscores or hyphens. They are not stored as entry identifiers.
References resolve only to earlier creates, and work in relation fields on both create and update operations. Multiple relations accept a list mixing existing IDs and reference objects, for example "projects": ["EXISTING_ENTRY_ID", { "$ref": "newProject" }]. Each reference object must contain only $ref. Existing target-type, multiplicity, distinctness and bucket rules still apply. Ordinary JSON fields containing $ref retain their data; attachments do not accept entry references.
Unknown, forward, self, duplicate or malformed references reject the entire batch. Errors identify paths such as operations.1.fields.project.$ref; resolved references with incompatible target types use the existing relation-field validation errors. Provisional IDs are not returned on failure. The idempotency key identifies the original symbolic request, and replay returns the same resolved IDs and references. Changing a reference name changes the request even if the resulting relationships would be equivalent.
Retry behavior
Idempotency-Key is required: 1–128 visible ASCII characters, without spaces. Generate a new key for each intended batch and reuse it when retrying that same batch. Keys are scoped to the authenticated API key and bucket for this endpoint. Successful results are retained for 24 hours; after expiry, the key can execute a new request. Expired records are pruned hourly.
Within that window, the same key and request return the original response, including created IDs and versions, even if entries have since changed or been deleted. JSON object property order does not matter; operation and array order do. Omitting locale is equivalent to "en-US". Reusing a key with a different payload returns 409. Current authentication and permissions are checked before replay. A failed batch does not reserve the key. If a connection is lost or a server error leaves the outcome uncertain, retry with the same key.
Successful batch responses include a Server-Timing header: batch is total action time, batch_work includes locking, validation and writes, batch_finish includes commit and synchronous after-commit listeners, and batch_sql measures database queries across both phases. Durations are milliseconds; SQL time overlaps the other metrics. These diagnostics exclude routing, authentication and network latency and are recomputed on replay.
Concurrent batches in a bucket wait for the running transaction. If a database lock cannot be acquired, the API returns 409 with Retry-After; retry with the same key. The successful response and content changes are saved in the same database transaction. Webhook events are dispatched after commit; webhook delivery failures do not undo committed content.
Errors and initial limits
Validation failures return HTTP 422, without successful operation results:
{
"message": "Batch rejected. No changes were committed.",
"errors": {
"operations.1.fields.title": ["The Title field is required."]
}
}
Unknown properties, repeated target IDs, stale versions and pending reviews reject the batch. Missing entries, missing content types and targets outside the bucket return 404. Missing permissions return 403. Malformed JSON returns 400, non-JSON bodies 415, and bodies over 1 MiB return 413. JSON nesting is limited to 32 levels.
Deletes that would cascade into another entry or file are rejected with an indexed validation error. Configured cascades are never silently disabled. Ordinary entry deletion remains supported; uploading files and deleting shared files use their own endpoints.
The initial API does not support filter-based mutation, upsert, partial success, webhook suppression, per-operation locales, client-assigned persistent IDs, forward references, or cycles among newly created entries. Mutually referencing entries require a later update request after creation.
Attachments and relations
Upload a file with POST /api/v1/{workspace}/files first, then use the returned ID. Attachment fields accept file IDs. Relation fields accept entry ULIDs. Multiple-value fields accept arrays.
Relation responses expand related entries in their stored order. To keep responses bounded, each relation field examines only its first 25 stored references, skips invalid references within that window, expands nested relations to at most three levels, and fully expands at most 100 related entries per root entry. A cycle, deeper reference, or reference beyond that shared expansion budget is returned as a compact entry reference with id, api_url, name, status, status_label, and _relation metadata describing why expansion stopped (cycle, depth, or budget).
{
"fields": {
"hero_image": 18,
"gallery": [18, 19, 20],
"author": "01HY8M7M7D4RT5N6P2Q3R4S5T6",
"related_posts": ["01HY8M7M7D4RT5N6P2Q3R4S5T7", "01HY8M7M7D4RT5N6P2Q3R4S5T8"],
"categories": ["news", "company"]
}
}
Locale support
Entries can store per-locale field values. See Localization for detailed examples covering single-locale and multi-locale reads and writes.
For a quick reference of which endpoints support the locale parameter, see the Entry Localization reference.
Related pages
- Entries — Entry lifecycle and management.
- Forms — Hosted forms that collect answers and can trigger pipelines.
- Localization — Managing per-locale field values.
- API Overview — Base URL, headers, and error handling.
- API Authentication — API keys and hosted authentication.