Reading Aggregates
GET aggregates to fetch current state. j17 replays events and applies handlers on demand. Handlers are declarative rules defined in your spec -- for example, a was_created event might use {"set": {"target": "", "value": "$.data"}} to set initial state, and a had_name_updated event might merge a new name into it.
Basic query
GET /{aggregate_type}/{aggregate_id}
curl https://myapp.j17.dev/order/ord_123 \
-H "Authorization: Bearer $API_KEY"
Response:
{
"ok": true,
"data": {
"items": [
{ "sku": "WIDGET-1", "quantity": 2, "price": 29.99 }
],
"status": "paid",
"customer_id": "550e8400-e29b-41d4-a716-446655440003",
"total": 79.97,
"placed_at": 1705312800,
"paid_at": 1705312900
},
"metadata": {
"length": 5,
"created_at": 1705312800,
"updated_at": 1705312900
}
}
| Field | Description |
|---|---|
data |
Current aggregate state after applying all events through handlers |
metadata.length |
Total number of events in this aggregate |
metadata.created_at |
Timestamp of the first event (Unix seconds) |
metadata.updated_at |
Timestamp of the most recent event (Unix seconds) |
Status codes
| Status | When |
|---|---|
| 200 OK | Aggregate exists (metadata.length > 0); or the id is a singleton |
| 404 Not Found | Aggregate id is well-formed but no events have ever been written to it |
| 422 Unprocessable Entity | Aggregate id is malformed — see Identifiers |
Singletons always return 200, even before the first write — they're conceptually "one well-known instance, possibly empty." Branch on metadata.length > 0 rather than status code to detect missingness on a singleton.
Stream length (for OCC)
Get just the stream length without computing the full aggregate. This is an O(1) operation, useful for optimistic concurrency control.
GET /{aggregate_type}/{aggregate_id}/length
curl https://myapp.j17.dev/order/ord_123/length \
-H "Authorization: Bearer $API_KEY"
Response:
{
"ok": true,
"length": 5
}
Use this length as previous_length in your next write:
const { length } = await fetch('/order/ord_123/length').then(r => r.json());
await writeEvent('/order/ord_123/was_updated', {
data: { ... },
metadata: { actor: { ... }, previous_length: length }
});
If another write happened between your read and write, you'll get a 409 Conflict.
Query parameters
Synchronous reads
By default, j17 may serve cached aggregate state. To bypass the cache and compute fresh from events:
curl "https://myapp.j17.dev/order/ord_123?synchronous=true"
Use when you need guaranteed-fresh state, such as immediately after a write.
A synchronous read rebuilds the aggregate from its whole event log, so it is the one read that gets more expensive as an aggregate grows. Requests that would have to read more than 32 MB of events are refused with a 422 rather than served; the default cached read has no such limit.
Get events
Get the raw event stream for an aggregate:
GET /{aggregate_type}/{aggregate_id}/events
curl "https://myapp.j17.dev/order/ord_123/events" \
-H "Authorization: Bearer $API_KEY"
Response:
{
"ok": true,
"data": [
{
"id": "1234567890123-0",
"hash": "9f2c...64 hex chars...a1",
"event": {
"key": "order:ord_123",
"type": "was_placed",
"data": { ... },
"metadata": {
"actor": { "type": "user", "id": "..." },
"timestamp": 1705312800
}
}
},
{
"id": "1234567890124-0",
"event": {
"key": "order:ord_123",
"type": "had_item_added",
"data": { ... },
"metadata": { ... }
}
}
],
"count_limit": 500,
"truncated": true,
"truncated_by": "count",
"next_start": "1234567890124-0"
}
Each element of data pairs the stream ID with the event itself. The id is the cursor value to pass back as start; the event body lives under event; hash is the event's chain hash — with it, every entry on a page is verifiable against its predecessor.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
start |
string | Cursor — exclusive in both directions |
count |
integer | Events per page. Defaults to 500, capped at 5,000. |
direction |
string | forwards (default) or backwards |
raw |
boolean | raw=true returns each event's stored bytes as a JSON string under raw, in place of the decoded event. |
Verifying events as you read them
Chain hashes are computed over the event's bytes as stored. A decoded JSON object re-serialized by your language's encoder will not, in general, reproduce those bytes (key order differs), so hashing a decoded event back into the chain is a trap. raw=true exists for exactly this:
curl "https://myapp.j17.dev/order/ord_123/events?raw=true" \
-H "Authorization: Bearer $API_KEY"
{
"id": "1234567890123-0",
"hash": "9f2c...a1",
"raw": "{\"key\":\"order:ord_123\",\"type\":\"was_placed\",...}"
}
To verify: the first event's hash is SHA-256(raw); every later event's is SHA-256(previous_hash <> raw) (hex hash string concatenated with the raw bytes). Any page whose hashes recompute is untampered relative to itself, and the last hash ties into the Merkle proofs and signed anchors for verification against a signed root.
Response fields alongside data:
| Field | Description |
|---|---|
count_limit |
The page size actually applied |
truncated |
true when there are more events in the direction you are reading |
truncated_by |
"count" or "bytes" — which limit ended the page. Absent when not truncated. |
next_start |
Cursor for the next page. Absent when there is none, so its absence is how you know you have reached the end. |
count_clamped |
true when your count exceeded the maximum and was reduced. Absent otherwise. |
Cursor semantics:
- Events are always returned oldest-first, in stream order, whichever direction you ask for
startis exclusive — an event whoseidequalsstartis never returned- There is always a page size. Omitting
countgives you 500 events, not the whole stream. Follownext_startto read the rest. truncatedis exact, not inferred: a page that ends precisely on the last event of the stream reportstruncated: false, so you never have to fetch an empty page to discover you were doneidvalues are opaque — treat them as cursors, not as data to parse. The format may change, and differs between the managed platform and local development tools.- An unusable
count(0, negative, non-numeric) or an unrecogniseddirectionis a400, not a silently ignored parameter
Page size
A request that names no count gets 500 events. A count above 5,000 is reduced to 5,000 and the response sets count_clamped: true — your request is answered, just not at the size you asked for.
There is also a 4 MB ceiling on a single page, and whichever limit binds first ends the page. Event size varies enormously between specs, so a count on its own is a poor description of what a page costs: 500 small events and 500 fat ones are the same number and wildly different responses. When the byte ceiling is what stopped the page, truncated_by reads "bytes" and you will get fewer events than you asked for. Follow next_start exactly as you would otherwise — an event larger than the entire ceiling is still returned on its own, so a stream of large events stays walkable.
Changed in 0.15.0. There was previously no page size at all: omitting count returned the entire stream, and count limited the response without limiting the work behind it. A client that relied on a single unparameterized call returning everything now needs to follow next_start.
Direction
direction selects which window of the stream you get. It does not change the order of the returned array — that is always oldest-first.
| Request | Returns |
|---|---|
?count=3 |
The oldest 3 events |
?direction=backwards&count=3 |
The newest 3 events, still oldest-first |
?start=ID&count=3 |
The 3 events after ID |
?direction=backwards&start=ID&count=3 |
The 3 events before ID |
Keeping the array oldest-first in both directions is deliberate. An event stream is a fold — state is rebuilt by applying events in order — so a page that arrives reversed is a page you cannot replay, and the bug it causes surfaces far from its origin. Direction moves the window; order stays fold order.
# The five most recent events
curl "https://myapp.j17.dev/order/ord_123/events?direction=backwards&count=5"
Paging forwards (catch-up consumer): keep the last id you processed, pass it as start, and you resume exactly where you stopped. next_start gives you that cursor directly.
curl "https://myapp.j17.dev/order/ord_123/events?count=50"
curl "https://myapp.j17.dev/order/ord_123/events?count=50&start=1705312800999-0"
Read a whole stream by following next_start until it stops coming back:
cursor=""
while :; do
page=$(curl -s "https://myapp.j17.dev/order/ord_123/events?count=500${cursor:+&start=$cursor}")
echo "$page" | jq -c '.data[]'
cursor=$(echo "$page" | jq -r '.next_start // empty')
[ -z "$cursor" ] && break
done
Paging backwards (walking history from the present): take the first id of each window as the next cursor, since the array is oldest-first. next_start already accounts for direction, so following it works either way.
curl "https://myapp.j17.dev/order/ord_123/events?direction=backwards&count=50"
curl "https://myapp.j17.dev/order/ord_123/events?direction=backwards&count=50&start=1705312800111-0"
# First page
curl "https://myapp.j17.dev/order/ord_123/events?count=50"
# Next page — pass the last id from the previous response
curl "https://myapp.j17.dev/order/ord_123/events?count=50&start=1705312800999-0"
List aggregates
GET /{aggregate_type}
curl https://myapp.j17.dev/order \
-H "Authorization: Bearer $API_KEY"
Returns known aggregate IDs for a type with cursor-based pagination.
| Parameter | Type | Description |
|---|---|---|
resolve |
flag | Return full aggregate data instead of IDs (?resolve or ?resolve=true) |
synchronous |
boolean | Skip cache when resolving, read from event stream (?synchronous=true) |
limit |
integer | Page size (default 50, max 200 — a lowered limit is reported via limit_clamped: true). Malformed values are a 400, not silently defaulted. |
start |
string | Return IDs after this value (exclusive, lexicographic). cursor is accepted as a legacy alias. |
The response speaks the same pagination vocabulary as /events: the applied
limit is echoed back, truncated says whether there is more, and next_start
is present exactly when there is — its absence alone answers "am I done". (The
legacy cursor response field carries the same value as next_start.)
Cursor semantics:
- IDs are returned in lexicographic (byte-wise) order
- The cursor is exclusive — an ID equal to the cursor value is not returned
- To page through, pass
next_startfrom the previous response back asstart - When the response omits
next_start, you've reached the end - The cursor is stable across requests as long as no aggregate IDs are deleted; new aggregates with IDs greater than your current cursor will appear on subsequent pages
- All four identifier kinds — UUIDs, humane codes, singletons (including
global), tagged UUIDs — appear in the list once any event has been written to them. They sort lexicographically by raw string, so singleton names (lowercase letters) appear before UUIDs (hex digits) in the natural order.
IDs only (default):
{"ok": true, "data": ["order-123", "order-456", "order-789"], "limit": 50, "truncated": false}
With ?resolve — full aggregate data:
{
"ok": true,
"data": [
{"id": "order-123", "data": {"status": "pending", ...}, "metadata": {...}},
{"id": "order-456", "data": {"status": "shipped", ...}, "metadata": {...}}
]
}
Resolved aggregates use the cache when available, falling back to sync reads from the event stream. Use ?synchronous=true to always read from the stream. Resolved entries are exactly as current as a GET of the same aggregate — a listing and a single read never disagree.
If one aggregate in the page cannot be read (for example, recomputing it would exceed the fold byte budget), its entry carries an error field instead of data — the rest of the page is unaffected:
{"id": "order-789", "error": "fold_budget_exceeded"}
Pagination:
# First page
curl "https://myapp.j17.dev/order?limit=25"
# Response includes cursor: {"ok": true, "data": [...], "cursor": "order-xyz"}
# Next page
curl "https://myapp.j17.dev/order?limit=25&cursor=order-xyz"
Projections
Request a named projection:
curl "https://myapp.j17.dev/_projections/user_dashboard/cust_456" \
-H "Authorization: Bearer $API_KEY"
Projections are pre-computed views maintained by the platform. Use ?synchronous=true for a fresh computation instead of the cached value.
Export as CSV: GET /_projections/:name/export.csv
Query with SQL: POST /_projections/query with {"sql": "SELECT ...", "params": [...]}
See the projections guide for full details including limits and restrictions.
Singleton aggregates
Use the literal global as the aggregate ID for one-per-type aggregates:
curl https://myapp.j17.dev/config/global \
-H "Authorization: Bearer $API_KEY"
If you've defined custom singletons in your spec ("singletons": ["all"]), use them the same way:
curl https://myapp.j17.dev/company_audit/all \
-H "Authorization: Bearer $API_KEY"
Reads of singletons return 200 OK even before the first write — branch on metadata.length > 0 if you need missingness detection.
See Identifiers § Singletons for the full reference and Aggregates: Singleton aggregates for the conceptual overview.
Response codes
200 OK
Aggregate exists. Returns state.
404 Not Found
Aggregate doesn't exist (no events yet) or aggregate type not in spec.
{
"ok": false,
"error": "Aggregate not found"
}
An aggregate with no events returns 404, not empty state.
422 Unprocessable Entity
The request is valid, but answering it this particular way would cost more than a single
request is allowed to spend. Returned by ?synchronous=true on an aggregate whose event
log exceeds the rebuild budget.
{
"ok": false,
"error_type": "fold_budget_exceeded",
"error": "Recomputing this aggregate from its event log would read 41943040 bytes, over the 33554432 byte budget for a single request. ...",
"bytes": 41943040,
"limit": 33554432
}
The aggregate itself is fine. Drop ?synchronous=true to read the cached state, or use
GET /:type/:id/events to page through the log — neither is affected by this budget.
401 Unauthorized
Invalid or missing API key.
403 Forbidden
Valid key, but wrong environment (staging key on prod) or insufficient scope.
Performance
A default read is served from cached state and costs the same regardless of how many events an aggregate has. The cache is kept current in the background by folding on only the events added since it was last written, so neither reads nor writes get slower as an aggregate accumulates history.
Rebuilding from the full log is the operation that scales with size, at roughly 5–7 µs per
event. It happens on ?synchronous=true, and in the background after a spec change:
- 1,000 events: ~5ms
- 10,000 events: ~60ms
- 100,000 events: ~600ms
Requests that would rebuild more than 32 MB of events are refused (422); background rebuilds are not, because they run in bounded chunks. Checkpoints snapshot state periodically and are still useful for recovery and export.
Optimistic reads
For optimistic UI patterns:
// 1. Get stream length (O(1))
const { length } = await fetch('/order/ord_123/length').then(r => r.json());
// 2. Fetch current state
const { data } = await fetch('/order/ord_123').then(r => r.json());
// 3. Render UI
renderOrder(data);
// 4. User makes change -- write with OCC
try {
await writeEvent('/order/ord_123/was_shipped', {
data: { ... },
metadata: { actor: { ... }, previous_length: length }
});
} catch (err) {
// 409 Conflict -- refetch and retry
const fresh = await fetch('/order/ord_123').then(r => r.json());
renderOrder(fresh.data);
}
Comparing to traditional databases
| Operation | SQL | j17 |
|---|---|---|
| Read by ID | SELECT * FROM orders WHERE id = ? |
GET /order/ord_123 |
| Read length | SELECT COUNT(*) FROM events WHERE key = ? |
GET /order/ord_123/length |
| Read full history | Multiple queries/joins | GET /order/ord_123/events |
| List/filter | SELECT * FROM orders WHERE status = 'paid' |
Not directly (use projections) |
The trade-off: you lose ad-hoc queries, you gain immutable history.
Best practices
Store aggregate IDs. When you create an order, save the ID in your database. You'll need it to query later.
Use projections for lists. Don't try to "list all orders." Create a projection that maintains the list.
Use ?synchronous=true sparingly. Default reads may use cached state, which is faster. Only force synchronous when you need guaranteed freshness.
Handle 404 gracefully. A 404 just means no events yet. It's not an error condition.
See also
- Writing events - POST to modify state
- Batch operations - Atomic multi-event writes
- Admin API - Checkpoints, backups, data export