﻿# Working with entities

Create, read, update, and search entities via the cyoda-go API — worked examples.

<FromTheBinary topic="crud" />

This page shows the patterns for interacting with entities through the
platform API. Examples assume a local cyoda-go instance running on the default
port with SQLite persistence; the same requests work against Cyoda Cloud with
the cloud endpoint and an issued token.

The complete endpoint catalogue — parameters, response shapes, error codes —
lives in the [API reference](/reference/api/). Keep that open as you work.

## The shape of the API

Cyoda speaks REST for CRUD, search, and workflow invocation, gRPC for external
processors, and Trino SQL for analytics. This page covers REST; see
[Build → client compute nodes](/build/client-compute-nodes/) for gRPC and the
[APIs and surfaces](/concepts/apis-and-surfaces/) overview for the decision
framework.

Every request is authenticated with a bearer token. Every response includes
the entity's current revision, state, and timestamps.

For durable inbound payloads that are not yet entities — a staging buffer at the platform edge — see [edge messages](/build/edge-messages/).

## Create

Post an entity to its model. The first time you post, Cyoda discovers the
schema from what you send:

```bash
curl -X POST http://localhost:8080/api/entity/JSON/orders/1 \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "orderId": "ORD-42",
    "customerId": "CUST-7",
    "amount": 120.00,
    "currency": "EUR",
    "lines": [
      { "sku": "AX-1", "qty": 2, "price": 60.00 }
    ]
  }'
```

The path is `/api/entity/{format}/{entityName}/{modelVersion}` — here `JSON`,
`orders`, and version `1`. The response carries an array whose first element
contains `entityIds[0]`, the **system-assigned UUID** of the new entity, plus
its current state and revision number. Capture the UUID — downstream reads,
updates, and transitions address the entity by that UUID, not by the business
key `orderId`.

## Read

Fetch the current revision by id. The `{entityId}` in these URLs is the UUID
returned in `entityIds[0]` from the create response, not a business key like
`orderId`:

```bash
curl http://localhost:8080/api/entity/${ENTITY_ID} \
  -H "Authorization: Bearer $TOKEN"
```

List every entity in a model with `GET /api/entity/{entityName}/{modelVersion}`:

```
GET /api/entity/orders/1
```

For filtered reads — predicates, pagination, result caps, historical reads —
see [searching entities](/build/searching-entities/). The list endpoint does
not accept ad-hoc field filters; those belong to search.

## Update

Direct updates use `PUT /api/entity/{format}/{entityId}` (loopback update — stores a new revision without a named transition) or `PUT /api/entity/{format}/{entityId}/{transition}` (update with a named transition). `PUT` has **wholesale-replace** semantics: any field you omit from the body is destroyed. To change only some fields, use [`PATCH`](#partial-update-patch) instead.

**Mutations that move the entity between lifecycle states should go through a
named transition**, not a bare loopback update. Invoking the `submit` transition
records it in the audit trail and runs any attached processors. The transition
carries the new entity JSON in the request body (the platform stores the updated
entity and records the named transition in one call):

```bash
curl -X PUT http://localhost:8080/api/entity/JSON/${ENTITY_ID}/submit \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  -d '{ "orderId": "ORD-42", "status": "submitted" }'
```

See [Build → workflows and processors](/build/workflows-and-processors/) for
how to declare transitions.

### Partial update (PATCH)

When you want to change only the fields that moved — without resending (and risking clobbering) the rest of the entity — use `PATCH`:

```http
PATCH /api/entity/{format}/{entityId}
PATCH /api/entity/{format}/{entityId}/{transition}
```

The body is a sparse JSON object applied to the **stored** entity with RFC 7386 (`application/merge-patch+json`) semantics: a non-null key overwrites, an explicit `null` deletes that key, and an omitted key is left untouched.

```bash
curl -X PATCH http://localhost:8080/api/entity/JSON/${ENTITY_ID} \
  -H 'Content-Type: application/merge-patch+json' \
  -H "If-Match: ${LAST_TRANSACTION_ID}" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{ "amount": 130.00, "note": null }'
```

Unlike `PUT`, **`If-Match` is required** — the merge is applied relative to the base you read, so patching a stale base risks a lost update. The token is the `meta.transactionId` from your last `GET`; a missing header returns `428 PRECONDITION_REQUIRED` and a stale one returns `412`. The merged result is validated strictly against the model schema — a `PATCH` never extends the model, even in an extend-permitting mode. The request is JSON-only (XML ⇒ `415`); RFC 6902 JSON Patch is recognised but returns `501` for now.

## Bounding a write

Every entity write accepts `transactionTimeoutMillis`. It bounds the time that
the server spends before the **first commit**:

```
POST /api/entity/JSON/orders/1?transactionTimeoutMillis=10000
```

When the time elapses, the server rolls the transaction back and fails the
request with `408 TRANSACTION_TIMEOUT`. It commits nothing. If you omit the
parameter, the write has no server-side timeout.

A chunked write is an array body that `transactionWindow` splits into chunks.
The parameter covers the first chunk only. If the time elapses after that, the
server reports a `TRANSACTION_TIMEOUT` element in the per-chunk response
instead of an HTTP `408`.

The server rejects the parameter with `400` on a request that
[joins an open transaction](/build/client-compute-nodes/#1031-transaction-joined-callbacks).
The same rule applies to `transactionSize` on a delete and to `timeoutMillis`
on a search.

`503 STORAGE_UNAVAILABLE` reports a problem in the storage layer, and it is
**retryable**. It covers three conditions: the connection pool could not supply
a connection, the database aborted the transaction at its idle ceiling, and the
connection to the database went away.

## When a write is visible

A `2xx` response to a write means that the write is **visible to every later
read on every node**. A caller can write and then read its own data back. Every
storage engine delivers this contract — see
[storage engines](/run/storage-engines/#one-application-contract).

## What the platform will not store

A payload can be valid JSON and still be unstorable. The server rejects five
forms at the boundary with `400`, on every storage backend and over both REST
and gRPC:

- **A NUL character** (U+0000) at any position in the payload.
- **Unpaired UTF-16 surrogates and invalid UTF-8.** The server reads the raw
  request bytes for this check. A JSON decoder replaces both forms with U+FFFD,
  which would store a character that you did not send.
- **A name that occurs more than once in the same object.** Different parts of
  the system read a different occurrence, so the payload has no single meaning.
- **Content after the JSON value**, such as `{"x":1}}}`.
- **A number outside the `numeric` range of PostgreSQL.**

## Delete

Delete a single entity by id, or delete a matched subset of a model with a condition:

```bash
# Delete a single entity by id
curl -X DELETE http://localhost:8080/api/entity/${ENTITY_ID} \
  -H "Authorization: Bearer $TOKEN"
```

`DELETE /api/entity/{entityId}` removes exactly the entity with that UUID and
returns its id, model key, and the transaction id of the delete.

```bash
# Delete entities matching a condition (empty body ⇒ every entity in the model)
curl -X DELETE http://localhost:8080/api/entity/orders/1?verbose=true \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "type": "simple",
    "jsonPath": "$.status",
    "operation": "EQUALS",
    "value": "cancelled"
  }'
```

The `DELETE /api/entity/{entityName}/{modelVersion}` endpoint honours an `AbstractConditionDto` request body and removes only the matching entities; an **empty body still means all**. `verbose=true` returns the deleted ids, and the response reports matched-vs-removed counts separately. A malformed condition returns `400 INVALID_CONDITION`.

Supplying the condition matters: earlier releases ignored it and wiped the entire model. Always send the condition you intend (or omit the body deliberately when you really do mean "all").

Both forms accept `pointInTime`, so you can scope a delete to the data as it
was at a given instant.

### Deleting in batches

By default, the server removes the whole selection in one transaction. Add
`transactionSize` to remove it in independent, version-guarded batches:

```
DELETE /api/entity/orders/1?transactionSize=500
```

A batch that is already committed stays durable if a later batch fails. The
server records two conditions per id in `deleteResult.idToError`, and does not
retry either:

- A version mismatch. The entity changed after the server selected it.
- A commit failure for the batch.

Without `pointInTime`, the server selects the matching entities again before
each batch. If entities are created at least as fast as the server removes
them, the selection never becomes empty. In that case the server stops at the
batch cap and fails the request with a retryable
**`409 DELETE_NOT_CONVERGED`**. The batches that are already committed stay
deleted. To make a batched delete terminate, supply `pointInTime`.

The server rejects `transactionSize` with `400` on a request that joins an open
transaction.

## Search

Cyoda supports two query modes:

- **Direct** (synchronous, bounded-or-fail) — API term `direct`.
  Streams results right away as NDJSON. Good for UI lookups and short
  operations. `limit` caps the *matched set* (default 1000, max 10000) and a
  query matching more than that returns `400 SEARCH_RESULT_LIMIT` rather than a
  truncated page — so `direct` suits queries you know produce a bounded, small
  result set.
- **Async** (background, unbounded, paged) — API term `async`.
  Queued as a job, returns a handle you can poll. Result size is
  unbounded; results are paged. Good for large result sets, periodic
  reports, and exports. On the Cassandra-backed tier (Cyoda Cloud, or
  a licensed Enterprise install), `async` search runs distributed
  across the cluster and scales horizontally: query throughput for a
  fixed shape grows roughly linearly with the number of nodes.

Both accept the same filter grammar over entity fields, metadata, and
workflow state. Pick `direct` by default; switch to `async` when a
query would exceed the `direct` limit, would time out, or would hold
resources you need elsewhere. Ordered top-N over a large model belongs on
`async` too. For predicates, pagination, and worked
examples, see [searching entities](/build/searching-entities/).

## Temporal queries

Every entity's history is queryable. Add a `pointInTime` parameter to any read
or search request to retrieve the world as of that timestamp:

```
GET /api/entity/{entityId}?pointInTime=2026-03-01T00:00:00Z
```

Point-in-time reads apply a single canonical rule across every storage engine and read path: **inclusive of the requested instant** (`<=`), compared at native precision with no millisecond round-up. The model-scoped list read (`GET /api/entity/{entityName}/{modelVersion}`) also honours `pointInTime` and stamps `meta.pointInTime` on the result.

This is the primary way to answer regulatory and audit questions: *what did
this customer's balance look like at quarter close?* For the same
parameter applied to searches, see
[searching entities → historical reads](/build/searching-entities/#historical-reads-with-pointintime);
for analytical reads expressed as SQL, see
[analytics with SQL](/build/analytics-with-sql/).

## Change history

Where `pointInTime` answers *what did this look like then*, the change-history
endpoint answers *who changed it, and when*:

```
GET /api/entity/{entityId}/changes
```

Each entry carries `changeType` (`CREATE`, `UPDATE`, or `DELETE`),
`timeOfChange`, and a `user` — the principal the change is **attributed** to,
always present. `transactionId` and `fieldsChangedCount` appear when the
underlying entity version is available.

As of cyoda-go v0.8.3 an entry also carries who actually performed it:

- `attributedKind` — the kind of the attributed `user`: `user`, `service`, or
  `system`.
- `executedBy` — `{id, kind}` for the caller that actually executed the change,
  independent of attribution.

The two differ whenever an action happened *because of* someone but not *as*
them. A processor cascading writes off a user's transition is attributed to
that user while executing as a service; a scheduled transition firing hours
later is attributed to whoever armed it. Reading `user` alone cannot tell a
service-executed cascade from a direct user action — read both when that
distinction matters. See
[attribution is not authorization](/concepts/authentication-and-identity/#attribution-is-not-authorization)
for how the platform captures it.

Both fields are **absent** on rows written before v0.8.3 rather than present
and `null`, so treat missing as "not recorded" rather than as a value.

## From a compute node

When your code is reacting to a transition — running a processor or
evaluating a criterion — talk to the platform over **gRPC**, not REST. The
gRPC path preserves the audit association between the transition and the
compute call, brokers identity, and supports streaming. See
[Build → client compute nodes](/build/client-compute-nodes/) for the
implementation pattern.