Skip to content
Alvo is pre-v0.1: the image runs from its edge tag, and no NuGet package or release is published yet.Pre-v0.1: no release yet.Roadmap and status

List 'owners' rows

GET
/api/owners
curl --request GET \
--url 'http://localhost:8080/api/owners?select=label%3Amake%2Cmodel&order=id.desc&limit=50' \
--header 'Prefer: count=exact'

Reads a page of rows the caller’s policy admits.

Filtering follows PostgREST’s spelling: every query parameter that is not one of the reserved names (order, limit, offset, after, select, or, and, not) names a field, and its value is <operator>.<operand> — ?year=gte.2020. The operators are eq, neq, gt, gte, lt, lte, like, ilike, in, is. in takes a bracketed candidate list (in.(skoda,vw)) and is takes exactly null, true or false; like/ilike take a pattern. An unrecognised operator is refused with 422, never quietly read as equality, and an ordering operator applied to a type this API defines no total order over is refused too — so one filter means the same thing on every engine.

Several parameters are one conjunction (AND), which is the only reading that keeps a filter narrowing as terms are added. or=(…) and and=(…) group terms explicitly and may nest, and prefixing any parameter name or group keyword with not. negates it (not.color=eq.red, not.or=(…)).

A field that is unavailable to the caller is refused exactly like one that does not exist, in filter, order and select alike — the refusal names the parameter’s role and never the field, so filtering, ordering or selecting cannot be used to discover a hidden field’s name. The one exception is a field the descriptor also marks required: its name is published in the write schemas below, because a mandatory field a caller cannot see could not be supplied — see the overview.

The response is an envelope — { "items": [ … ], "next": <cursor or null>, "count": <total or null> } — and never a bare array. All three members are always present: next is the cursor for the page after this one and is null on the last, and count is null unless the request opted into it. next is the only place that cursor appears: there is deliberately no Link or Content-Range header, so an agent reading the body never has to parse HTTP headers to keep paging.

A caller whose rule excludes every row is answered 200 with an empty page, not 403. A rule compiles to a row-level USING predicate, so a caller who fails it receives an allow carrying a predicate that matches nothing. A 403 here means something else entirely: the operation is unconfigured for this entity, the entity is unknown to the applied descriptor, the caller has no tenant on a tenant-scoped entity, or the policy reads a caller value this caller does not carry.

Neither precondition header is honoured on a list. A page has no version of its own to compare, so If-Match and If-None-Match are ignored here — not refused, as they would be on a write. Condition a single row’s read or write instead.

A nullable field is a sort key like any other, and nullslast is what it gets if you do not say otherwise. Where a null sorts is never left to the database: SQLite and PostgreSQL disagree on the default for a given direction, so the placement is always explicit in the statement Alvo emits and nullsfirst/nullslast are how you change it. Paging honours the same placement, so a cursor walks the null-keyed rows too — which was not true before: such a read used to be refused with 422 rather than answered, because a keyset boundary that compared the value alone dropped rows silently.

Sorting by a nullable field costs more than sorting by a required one. The null placement is emitted as a CASE expression over the key, which an index on that key cannot serve. Page by a required column where latency matters.

A Prefer: count preference is the only thing that fills the envelope’s count. It is the number of rows the query matches in total — narrowed by your policy and your filter, and not by limit, offset or after — so it does not shrink as you page. It is opt-in because it costs a second scan of the matching set on every request, and count is null on a request that did not ask. count=planned and count=estimated are accepted and degrade to an exact count: a planner estimate exists on one supported engine and not the other, and this API answers identically on both. What was applied comes back in Preference-Applied, and per RFC 7240 a preference this server does not recognise is ignored rather than refused — its absence from Preference-Applied is how that is reported.

The count is taken in a second statement over the same filtered set, not in the page’s own, because the page’s statement carries the cursor boundary and a count composed into it would report the rows after the cursor. So exact means “not an estimate”, not “atomically consistent with items”: a write landing between the two can make the number differ by one.

Prefer
string

count=exact fills the page envelope’s count with the number of rows the query matches in total. Opt-in, because it is a second scan of the matching set on every request; a request sending no recognised count preference gets null there.

count=planned and count=estimated are accepted and degrade to an exact count, so they fill count too — a planner estimate exists on one supported engine and not the other, and this API answers identically on both. The response says which was applied in Preference-Applied, and it is always count=exact. Per RFC 7240 a preference this server does not recognise is ignored rather than refused, and its absence from Preference-Applied is how that is reported.

Example

count=exact
select
string

Comma-separated field names to return, in the order named, each optionally renamed as alias:field. It narrows the read as well as the response: a field the projection does not name is not read from the row. Two groups of columns are read regardless — the framework-managed ones, and any field named in order, because no engine can sort by a column it did not read — but neither appears in the response unless the projection named it. A field the caller may not read is refused exactly as an undeclared one is. An alias is lower snake_case, is not the name of a framework-managed column, and cannot be claimed twice; and a projection cannot name more distinct keys than there are fields this caller can read.

Example

label:make,model
order
string

<field>[.asc|.desc][.nullsfirst|.nullslast], comma-separated for several keys, outermost first. The modifiers must appear in that order and each at most once, so one sort key has one spelling; an unrecognised modifier is refused rather than ignored. A nullable field is a sort key like any other and defaults to nullslast; paging honours the same placement — see the operation description for what it costs.

Example

id.desc
limit
integer format: int32
default: 50 >= 1 <= 200

How many rows this page carries. A value past the maximum is refused, not clamped: a client that asked for more and silently received fewer computes its paging from a number no response ever told it. Zero is refused too — it is a read that can never return a row.

offset
integer format: int32

How many rows to skip. Prefer after: an offset re-scans the skipped rows and shifts under concurrent writes, where a keyset cursor does neither.

after
string
>= 1 characters <= 512 characters

The keyset cursor a previous page returned as next, sent back verbatim. It is opaque and only the provider that issued it may interpret it, so it must not be decoded or constructed. A forged one yields an empty page rather than an error.

or
string

A bracketed, comma-separated list of terms combined as a disjunction (OR): or=(color.eq.red,make.in.(skoda,vw)). Groups may nest, and either the keyword or any member may carry the not. prefix. Repeating the parameter conjoins the groups.

and
string

A bracketed, comma-separated list of terms combined as a conjunction (AND): and=(color.eq.red,make.in.(skoda,vw)). Groups may nest, and either the keyword or any member may carry the not. prefix. Repeating the parameter conjoins the groups.

id
string

Filter on id, as <operator>.<operand>. See the operation description for the operators, the not. prefix and how several parameters combine.

name
string

Filter on name, as <operator>.<operand>. See the operation description for the operators, the not. prefix and how several parameters combine.

email
string

Filter on email, as <operator>.<operand>. See the operation description for the operators, the not. prefix and how several parameters combine.

phone
string

Filter on phone, as <operator>.<operand>. See the operation description for the operators, the not. prefix and how several parameters combine.

created_at
string

Filter on created_at, as <operator>.<operand>. See the operation description for the operators, the not. prefix and how several parameters combine.

created_by
string

Filter on created_by, as <operator>.<operand>. See the operation description for the operators, the not. prefix and how several parameters combine.

updated_at
string

Filter on updated_at, as <operator>.<operand>. See the operation description for the operators, the not. prefix and how several parameters combine.

updated_by
string

Filter on updated_by, as <operator>.<operand>. See the operation description for the operators, the not. prefix and how several parameters combine.

A page of rows the caller’s policy admits.

Media typeapplication/json
ownersPage

One page of rows, the cursor that reads the page after it, and — when the request opted in with a Prefer: count preference — how many rows the query matches in total.

object
items
required

The rows in this page, in the requested order.

Array<object>
ownersPageItem

A person or company a vehicle is registered to.

One row inside a list’s page. Every field is present unless the request narrowed the projection with select; a field with no value is present and null. Fields marked read-only are written by the framework and refused in a request body.

object
id
string format: uuid
name
string
<= 120 characters
email
null | string format: email
/^(?:[^@\s]+@[^@\s.]+(\.[^@\s.]+)+)$/
phone
null | string format: phone
/^(?:\+?[0-9][0-9 ()./\-]{3,30})$/
created_at
string format: date-time
created_by
null | string format: uuid
updated_at
string format: date-time
updated_by
null | string format: uuid
next
required

The opaque cursor for the next page, or null when this page is the last. Send it back verbatim as after; it is the provider’s to interpret and must not be decoded.

null | string
<= 512 characters
count
required

How many rows the query matches in total — not the size of this page — or null unless the request sent a recognised Prefer: count preference — exact, or planned/estimated, which degrade to an exact count. It is narrowed by the caller’s policy and by the filter, and not by limit, offset or after, so it does not shrink as you page. Opt-in because an exact count is a second scan of the matching set on every request. Exact means “not an estimate”, not “consistent with items”: it is taken in a second statement, so a write landing between the two can make it differ from the rows by one.

null | integer format: int64

Example

{
"items": [
{
"id": "3f8d6c1e-9b47-4a5f-8c21-0d7e5a2b6f04",
"email": "someone@example.com",
"phone": "+421 900 123 456",
"created_at": "2026-01-31T09:30:00+00:00",
"created_by": "3f8d6c1e-9b47-4a5f-8c21-0d7e5a2b6f04",
"updated_at": "2026-01-31T09:30:00+00:00",
"updated_by": "3f8d6c1e-9b47-4a5f-8c21-0d7e5a2b6f04"
}
]
}
Cache-Control
string

Always no-store. These representations are policy-masked per caller and the ETag is minted over the row’s version rather than over the response bytes, so no cache — shared or private — may keep the body. Keeping the tag alone is not caching a representation, which is what makes a conditional write still possible.

Example
no-store
ETag
string

The row’s version as a strong entity tag. Send it back verbatim as If-Match to make a later write conditional; it is compared octet-for-octet, so it must not be reformatted. Absent when the row has no version this caller can be given.

Example
"638712345678900000"
Preference-Applied
string

What was done with the request’s Prefer header (RFC 7240 §3). Present only when a preference was applied, and always count=exact — count=planned and count=estimated degrade to a real count, and this is where a caller who asked for an estimate learns they received the exact one. Absent means no preference was applied, which is how RFC 7240 reports one this server does not recognise.

Example
count=exact

A credential was presented and cannot be used — unknown, revoked, expired, malformed, or issued for another tenant. One wording for all of them, so key ids cannot be enumerated one request at a time. ‘WWW-Authenticate’ names the scheme and the header to send. Presenting no credential at all is not a 401: an anonymous caller is judged by policy like any other.

Media typeapplication/problem+json
problemDetails

An RFC 9457 problem document. Branch on type; detail is prose and, per §3.1.1, ought not be parsed. A type keys on the kind of refusal and never on its reason — one value covers every policy refusal, and one covers both an absent row and a row the caller may not see.

object
type
required

The classification to branch on. Every value is listed here, so a client needs no prose: the namespace is https://alvo.dev/errors/ and the slug names the kind of refusal.

string format: uri
Allowed values: https://alvo.dev/errors/validation https://alvo.dev/errors/malformed-query https://alvo.dev/errors/forbidden https://alvo.dev/errors/out-of-scope https://alvo.dev/errors/not-found https://alvo.dev/errors/precondition-failed https://alvo.dev/errors/precondition-required https://alvo.dev/errors/idempotency-conflict https://alvo.dev/errors/conflict https://alvo.dev/errors/destructive-change https://alvo.dev/errors/unauthenticated https://alvo.dev/errors/unreadable-request https://alvo.dev/errors/unsupported-media-type https://alvo.dev/errors/internal https://alvo.dev/errors/function-failed
title
required

The status code’s standard reason phrase. Carries no Alvo-specific information.

string
status
required

The HTTP status code, repeated in the body as RFC 9457 §3.1.2 allows.

integer format: int32
detail
required

What went wrong, in prose. Built from constants and server-owned values only — it never echoes a caller-supplied field name or value, because a refusal is answered before authorization on some paths and would otherwise be the cheapest oracle in the API.

string
violations

Every itemised reason the request was refused, not only the first — so one round trip is enough to repair it. Present on a refusal that has itemised reasons (a malformed query string, a body the entity’s declared shape refuses) and absent on one that does not.

Array<object>
problemViolation

One machine-readable reason a request was refused.

object
pointer
required

An RFC 6901 JSON Pointer into the request body, or the role of the query-string parameter concerned (filter, order, limit, offset, after, select). A role rather than a name, because in this filter grammar a parameter’s name is a field name.

string
code
required

A stable kebab-case code for the kind of violation, safe to branch on.

string
message
required

One sentence, free of caller-supplied text.

string
fixSuggestion

What to change. Every refusal Alvo itself raises carries one; it is nullable for a violation forwarded from a source that has none to offer, where an empty string would be indistinguishable from a blank suggestion.

null | string

Example

{
"type": "https://alvo.dev/errors/validation",
"violations": [
{
"pointer": "/name",
"code": "max-length"
}
]
}
Cache-Control
string

Always no-store. These representations are policy-masked per caller and the ETag is minted over the row’s version rather than over the response bytes, so no cache — shared or private — may keep the body. Keeping the tag alone is not caching a representation, which is what makes a conditional write still possible.

Example
no-store
WWW-Authenticate
string

The RFC 7235 challenge, naming the scheme and the request header a credential is read from — so an agent can discover how to authenticate rather than guess. The header name is host configuration, which is why the challenge states it rather than assuming a default.

The operation is refused. Two kinds, told apart by the problem ‘type’ and never by its prose: ‘out-of-scope’ means the presented key’s scopes do not cover this entity and operation (grant the key the scope), and ‘forbidden’ means policy refused it (change a rule). A policy refusal here is an operation-level one — the operation is unconfigured, the entity is unknown to the applied descriptor, the caller has no tenant on a tenant-scoped entity, or the policy reads a caller value this caller does not carry. It is never ‘your rule excluded these rows’; see the 200.

Media typeapplication/problem+json
problemDetails

An RFC 9457 problem document. Branch on type; detail is prose and, per §3.1.1, ought not be parsed. A type keys on the kind of refusal and never on its reason — one value covers every policy refusal, and one covers both an absent row and a row the caller may not see.

object
type
required

The classification to branch on. Every value is listed here, so a client needs no prose: the namespace is https://alvo.dev/errors/ and the slug names the kind of refusal.

string format: uri
Allowed values: https://alvo.dev/errors/validation https://alvo.dev/errors/malformed-query https://alvo.dev/errors/forbidden https://alvo.dev/errors/out-of-scope https://alvo.dev/errors/not-found https://alvo.dev/errors/precondition-failed https://alvo.dev/errors/precondition-required https://alvo.dev/errors/idempotency-conflict https://alvo.dev/errors/conflict https://alvo.dev/errors/destructive-change https://alvo.dev/errors/unauthenticated https://alvo.dev/errors/unreadable-request https://alvo.dev/errors/unsupported-media-type https://alvo.dev/errors/internal https://alvo.dev/errors/function-failed
title
required

The status code’s standard reason phrase. Carries no Alvo-specific information.

string
status
required

The HTTP status code, repeated in the body as RFC 9457 §3.1.2 allows.

integer format: int32
detail
required

What went wrong, in prose. Built from constants and server-owned values only — it never echoes a caller-supplied field name or value, because a refusal is answered before authorization on some paths and would otherwise be the cheapest oracle in the API.

string
violations

Every itemised reason the request was refused, not only the first — so one round trip is enough to repair it. Present on a refusal that has itemised reasons (a malformed query string, a body the entity’s declared shape refuses) and absent on one that does not.

Array<object>
problemViolation

One machine-readable reason a request was refused.

object
pointer
required

An RFC 6901 JSON Pointer into the request body, or the role of the query-string parameter concerned (filter, order, limit, offset, after, select). A role rather than a name, because in this filter grammar a parameter’s name is a field name.

string
code
required

A stable kebab-case code for the kind of violation, safe to branch on.

string
message
required

One sentence, free of caller-supplied text.

string
fixSuggestion

What to change. Every refusal Alvo itself raises carries one; it is nullable for a violation forwarded from a source that has none to offer, where an empty string would be indistinguishable from a blank suggestion.

null | string

Example

{
"type": "https://alvo.dev/errors/validation",
"violations": [
{
"pointer": "/name",
"code": "max-length"
}
]
}
Cache-Control
string

Always no-store. These representations are policy-masked per caller and the ETag is minted over the row’s version rather than over the response bytes, so no cache — shared or private — may keep the body. Keeping the tag alone is not caching a representation, which is what makes a conditional write still possible.

Example
no-store

The request could not be acted on: a query string or body that is malformed, a body the entity’s declared shape refuses, or a header this API cannot honour. The ‘violations’ array carries every reason at once — a pointer, a stable code and a fix suggestion each — never only the first, so one round trip is enough to repair the request.

Media typeapplication/problem+json
problemDetails

An RFC 9457 problem document. Branch on type; detail is prose and, per §3.1.1, ought not be parsed. A type keys on the kind of refusal and never on its reason — one value covers every policy refusal, and one covers both an absent row and a row the caller may not see.

object
type
required

The classification to branch on. Every value is listed here, so a client needs no prose: the namespace is https://alvo.dev/errors/ and the slug names the kind of refusal.

string format: uri
Allowed values: https://alvo.dev/errors/validation https://alvo.dev/errors/malformed-query https://alvo.dev/errors/forbidden https://alvo.dev/errors/out-of-scope https://alvo.dev/errors/not-found https://alvo.dev/errors/precondition-failed https://alvo.dev/errors/precondition-required https://alvo.dev/errors/idempotency-conflict https://alvo.dev/errors/conflict https://alvo.dev/errors/destructive-change https://alvo.dev/errors/unauthenticated https://alvo.dev/errors/unreadable-request https://alvo.dev/errors/unsupported-media-type https://alvo.dev/errors/internal https://alvo.dev/errors/function-failed
title
required

The status code’s standard reason phrase. Carries no Alvo-specific information.

string
status
required

The HTTP status code, repeated in the body as RFC 9457 §3.1.2 allows.

integer format: int32
detail
required

What went wrong, in prose. Built from constants and server-owned values only — it never echoes a caller-supplied field name or value, because a refusal is answered before authorization on some paths and would otherwise be the cheapest oracle in the API.

string
violations

Every itemised reason the request was refused, not only the first — so one round trip is enough to repair it. Present on a refusal that has itemised reasons (a malformed query string, a body the entity’s declared shape refuses) and absent on one that does not.

Array<object>
problemViolation

One machine-readable reason a request was refused.

object
pointer
required

An RFC 6901 JSON Pointer into the request body, or the role of the query-string parameter concerned (filter, order, limit, offset, after, select). A role rather than a name, because in this filter grammar a parameter’s name is a field name.

string
code
required

A stable kebab-case code for the kind of violation, safe to branch on.

string
message
required

One sentence, free of caller-supplied text.

string
fixSuggestion

What to change. Every refusal Alvo itself raises carries one; it is nullable for a violation forwarded from a source that has none to offer, where an empty string would be indistinguishable from a blank suggestion.

null | string

Example

{
"type": "https://alvo.dev/errors/validation",
"violations": [
{
"pointer": "/name",
"code": "max-length"
}
]
}
Cache-Control
string

Always no-store. These representations are policy-masked per caller and the ETag is minted over the row’s version rather than over the response bytes, so no cache — shared or private — may keep the body. Keeping the tag alone is not caching a representation, which is what makes a conditional write still possible.

Example
no-store