# Entries API

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 |

```shell
curl "https://app.example.com/api/v1/acme/entries?contentBucket=content&contentType=pages&locale=en-US" \
  -H "Authorization: Bearer $VIRESSO_API_KEY"
```

Response:

```json
[
  {
    "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.

```shell
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:

```http
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.

```json
{
  "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
}
```

```json
{
  "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:

```json
{
  "type": "tickets",
  "filter": { "status": { "eq": "open" } },
  "aggregate": { "count": true }
}
```

```json
{ "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:

```json
{
  "type": "tickets",
  "aggregate": { "count": true },
  "groupBy": ["status"],
  "limit": 20
}
```

```json
{
  "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:

```json
{
  "type": "orders",
  "filter": { "status": { "eq": "paid" } },
  "aggregate": {
    "count": true,
    "sum": ["amount"],
    "avg": ["amount"],
    "min": ["amount"],
    "max": ["amount"]
  },
  "groupBy": ["status"]
}
```

```json
{
  "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.

- `sum` is exact and can exceed the input range as values accumulate. Its decimal string omits trailing fractional zeroes (`"31"`, `"0.3"`).
- `avg` divides 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.
- `min` and `max` compare 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. `count` counts all matching root entries regardless of missing numeric fields.
- An ungrouped query always returns one result row, including for no matches (`count: 0` when requested and numeric results `null`). Omit `offset` and `limit`. An empty grouped result returns `data: []` 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:

```json
{
  "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"
  ]
}
```

```json
{
  "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 `field` and `interval`; `timezone` is optional and defaults to `UTC`. No other object properties are accepted.
- Intervals are `day`, `week`, `month`, `quarter` and `year`. 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 or `UTC`; 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 by `customer.name` combines customers with equal names; use `customer.@id` when 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](https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html). 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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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](#conditional-operations) to evaluate supported conditions inside an atomic batch.

## Batch queries

Load several independent results in one request:

```http
POST /api/v1/{workspace}/{bucket}/entries/query/batch
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
```

```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:

```json
{
  "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:

```json
{
  "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 `*` |

```shell
curl "https://app.example.com/api/v1/acme/entries/01HY8M7M7D4RT5N6P2Q3R4S5T6?locale=en-US" \
  -H "Authorization: Bearer $VIRESSO_API_KEY"
```

Response:

```json
{
  "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 |

```shell
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. |

```shell
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 |

```shell
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

```http
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.

```json
{
  "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`:

```json
{
  "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):

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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`](/docs/api/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`).

```json
{
  "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](/docs/content/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](/docs/api/entry-localization) reference.

## Related pages

- [Entries](/docs/content/entries) — Entry lifecycle and management.
- [Forms](/docs/platform/forms) — Hosted forms that collect answers and can trigger pipelines.
- [Localization](/docs/content/localization) — Managing per-locale field values.
- [API Overview](/docs/api/overview) — Base URL, headers, and error handling.
- [API Authentication](/docs/api/authentication) — API keys and hosted authentication.
