Summary#
app.Apis publishes your application over HTTP for other systems to call. The guiding idea is that you expose what
you already have rather than write a web layer: you do not author controllers, routes, DTOs or handlers. Two things
can be published — an entity, as a set of CRUD routes, and a function, mapped to a single route. Each API has a
Route prefix and an Auth gate.
app.Apis = [
new RestApi("Orders") {
Route = "orders",
Auth = new ApiAuth { ApiKey = true },
Expose = [ new Crud<Order>() { Operations = [CrudOp.Read, CrudOp.Create] } ],
Endpoints = [ new Endpoint(SubmitOrder) { Method = HttpMethod.Post, Path = "/submit" } ],
},
];Signature#
app.Apis = [
new RestApi("Name") { // one entry per published API
Route = "orders", // the URL prefix for every route below
Version = "1.0", // optional — omit and it defaults to v1
Auth = new ApiAuth { ApiKey = true, Bearer = true }, // who may call
Expose = [ // entities → CRUD routes
new Crud<Order>() { Operations = [CrudOp.Read, CrudOp.Create, CrudOp.Update, CrudOp.Delete] },
],
Endpoints = [ // functions → one route each
new Endpoint(SubmitOrder) { Method = HttpMethod.Post, Path = "/submit", SuccessStatus = 201 },
],
},
];app.Apis is a list — an app may publish several independent APIs, each with its own Route and Auth.
Description#
A RestApi has:
Route— the URL prefix under which its routes live.Version,Deprecated,Sunset(optional) — API lifecycle metadata. Every route lives under a/v{major}/prefix; an omittedVersiondefaults to v1, so the simple case needs no version at all. A multi-major API gives eachRestApiits ownVersion(e.g."1.0","2.0").Auth(REQUIRED) — anApiAuth { ApiKey = true, Bearer = true, OAuth = true }, orApiAuth { Anonymous = true }to open the API to everyone deliberately. Those four flags are its WHOLE vocabulary; it names no secret and takes no other member. Absent, the API is unauthenticated. Who each of them makes the caller is the next section — read it before you write asecurity {}rule for an API.Expose— a list ofnew Crud<Entity>() { Operations = [CrudOp.…] }. Each entry turns one entity into REST routes;Operationschooses which ofRead/Create/Update/Deleteexist. The entity must be defined in your app — aCrud<Unknown>is a compile error that lists the entities you do define.Endpoints— a list ofnew Endpoint(Function) { Method = HttpMethod.…, Path = "…" }. Each maps one of your functions to a route. The function must be declared — anEndpoint(NoSuchFn)is a compile error that lists your functions.
You write no request parsing, no serialization, and no routing table: the shape of the entity and the signature of the function are the contract.
Where an API is served: /api/rest/v{major}/{route}/…#
Every API is served at ONE address shape, on the app's own host:
https://<app-host>/api/rest/v{major}/{Route}{Path} an Endpoint
https://<app-host>/api/rest/v{major}/{Route}/entities/{Entity} a Crud<T>So new RestApi("MailEvents") { Route = "mail-events", Endpoints = [ new Endpoint(Reported) { Path = "/resend" } ] }
is called at https://<app-host>/api/rest/v1/mail-events/resend — that is the address to give a webhook provider.
osy model prints each API's address (basePath, and each endpoint's url), so copy it from there rather than
writing it by hand. An address you build in code for a provider to call back (a payment's status callback) is
$"{App.BaseUrl}/api/rest/v1/{route}{path}".
Nothing else is an API's address. A request for one of your API's routes at a shorter spelling —
/api/mail-events/resend, /api/v1/mail-events/resend, /api/rest/mail-events/resend, or a POST to
/mail-events/resend — is answered 404 with a JSON body that names the address the API IS served at, so a
provider's delivery log tells you what to type. No page is ever served under /api/.
What an endpoint binds its parameters from#
Exactly two places, and the list is closed:
- A
{placeholder}in that endpoint's ownPath.new Endpoint(Settle) { Path = "/settle/{paymentId}" }fills the function'spaymentIdparameter from the matched segment, coerced to its declared type. - A property of the JSON request body, matched by name (
camelCaseorPascalCase). On a name collision the body wins.
string Settle(Guid paymentId, string reference, string status) { … }
new Endpoint(Settle) { Method = HttpMethod.Post, Path = "/payments/{paymentId}/settle" }
// → POST /api/rest/v1/bank/payments/9f3…/settle {"reference":"wire-9001","status":"settled"}A parameter that is an entity takes a row. A string id binds the existing row, read as the caller, so a row they
may not read is a 404. An object without an id builds a new row from its fields.
An [Action] as an endpoint — listed, never automatic#
An [[function-action|[Action]]] is the verb a macro, a rule or an agent runs on a record: its first parameter is
that record. It becomes part of your API only when you list it, like any other function. The path's {id} then
binds the record:
[Action("Close")]
public string CloseJob(Job job, string note) { … }
new Endpoint(CloseJob) { Method = HttpMethod.Post, Path = "/jobs/{id}/close" }
// → POST /api/rest/v1/jobs/jobs/4b1…/close {"note":"Tiles replaced"} → {"result":"Closed Fix the roof."}The other parameters bind from the body as usual, and an entity among them takes its row's id
({"agent": "<id>"}). An id that names no row the caller may read is a 404; nothing is ever created from the
request. ⛔ An action you do not list is not reachable over the API, by any path. Being an action makes a verb
available to the app's own macros, rules and agents, never to a caller outside it.
⛔ The query string is not bindable, and [Header]/[Query] are not how you read one. Those two mark where an
outgoing argument travels, and an outgoing client is a class that builds its own URL and header
map — so they bind nothing anywhere, and are refused on a function you declare.
The request AS RECEIVED — the raw bytes and named headers — is a separate surface, and it is declared on the endpoint rather than bound to a parameter. See the next section.
Reading the request as it arrived: Raw and Headers#
A signed webhook — Slack, Stripe, GitHub, Shopify, Twilio, DocuSign — signs its headers over the body bytes. A body re-serialised from bound parameters is not those bytes, so verifying against the bound model is not awkward, it is wrong: it fails for whitespace the sender chose and you did not.
So an endpoint may ask for what it needs, and the function reads it back through Request:
| On the endpoint | In the function | Answers |
|---|---|---|
Raw = true | Request.RawBody | the body as text, before any JSON parse |
Headers = ["X-Sig", …] | Request.Header("X-Sig") | that header's value, or null if the sender sent none |
Both are declared, never ambient, and the declaration is the grant: a header this endpoint did not name is not readable from this function, so what an app can see of its callers is visible in its own source. Header names are matched case-insensitively, because HTTP header names are.
⛔ A credential header may not be declared at all — Authorization, Proxy-Authorization, Cookie,
Set-Cookie, X-API-Key. Those are how the caller authenticates to the platform, and handing one to a handler
would let an app replay its callers' credentials elsewhere. The compiler refuses the declaration by name. A webhook
signature never travels in one of these anyway: the sender has no account here, which is why it signs.
⛔ Reading one you did not declare does not compile. Request.RawBody without Raw = true, or
Request.Header("X") where X is not in Headers, is refused by the compiler, and the refusal lists the names the
endpoint does declare — because the usual cause is two strings written minutes apart that differ by a character.
The declaration and the read are one contract held in two places and joined by a string literal, so the compiler
checks them against each other rather than leaving it to the first real request.
⚠ The same mistake one call deeper is a fault at run time, not a compile error. The compile-time check reads the
endpoint function's own body; a Request.Header(…) inside a helper it calls is beyond it, and so is a name that is
not a literal. Those still fail loudly, with the same message naming the one-line fix — a null was never on the
table. Only "the sender sent no such header" is a null, because that is the app's to refuse and it must not look
like a mistake in the app's own source. That fault also covers being reached some other way entirely (an MCP tool
call, an agent, a workflow step, a [Test] body): there is no request to read, and saying so beats answering null
to a verifier that would then "pass".
Raw is carried alongside the bound parameters, never instead of them — an endpoint can bind a typed payload
and verify the signature over what actually arrived. A Raw = true endpoint whose body is not JSON at all (a
form-encoded payload=…) is not refused; it simply binds nothing, and the handler decides.
app.Secrets = [ new Secret("SlackSigningSecret") ];
class Receipt { public bool Verified; }
[AllowAnonymous]
Receipt SlackEvents() {
var body = Request.RawBody ?? "";
var ts = Request.Header("X-Slack-Request-Timestamp") ?? "";
var sig = Request.Header("X-Slack-Signature") ?? "";
// Slack's own scheme: v0={hmac of "v0:{timestamp}:{body}"}, over the bytes it sent.
var expected = "v0=" + Crypto.HmacSha256Hex(Secret.SlackSigningSecret, "v0:" + ts + ":" + body);
return new Receipt { Verified = Crypto.FixedTimeEquals(expected, sig) };
}
app.Apis = [
new RestApi("Slack") {
Route = "slack",
Auth = new ApiAuth { Anonymous = true },
Endpoints = [
new Endpoint(SlackEvents) {
Method = HttpMethod.Post, Path = "/events",
Raw = true, Headers = ["X-Slack-Signature", "X-Slack-Request-Timestamp"],
},
],
},
];Who is asking: Request.ClientAddress#
Request.ClientAddress is the end user's address — the first hop of X-Forwarded-For behind the gateway, else
the connection's. It is what an identity provider asks for (BankID's endUserIp), and the one mistake it exists to
prevent is sending the SERVER's own address, which works in development, where the two are one machine, and blinds the
provider's fraud checks in production.
Unlike a header it is not declared, and it is readable from any function a request reached — a page's action
as much as an endpoint — because an address is neither the caller's text nor a credential, and the function that needs
it is usually a checkout step, which no endpoint reaches. It is "" when no request did: a workflow step on a timer,
a resumed continuation, a [Test] body.
var order = new BankIdClient { BaseUrl = "https://appapi2.test.bankid.com/rp/v6.0" }.Auth(Request.ClientAddress);⚠ A label, never an authority. Behind a proxy the header is the client's to write, so use the address for what a provider asks it for and never to decide what someone may do. It runs on the server only: a browser does not know its own public address.
Refusals map to the right status. When an endpoint's function refuses — it throws a typed exception, or a
declared security policy denies the caller — the response carries the matching 4xx, not a blanket 500, and the client
sees the refusal's own message:
| The function… | Response |
|---|---|
throw new ValidationException("…") | 400 — the request was invalid |
throw new NotFoundException("…") | 404 — the target does not exist / is not visible |
throw new ConflictException("…") | 409 — conflicts with current state |
| is denied by a security / authorization policy | 403 — the caller may not do this |
| hits a genuine, unexpected error | 500 — a generic message; the details stay in the server log, never the response |
So a business rule like "a backup can only be downloaded once it is ready" is a throw new ValidationException(…)
that reaches the caller as a 400 with your wording — not a server crash.
What a value looks like on the wire#
JSON has no literal for most of what your entities hold, so each type has one agreed spelling — the same one going out as coming in, which is what makes a response postable straight back:
| Declared as | On the wire |
|---|---|
enum | its member name — "machine": "Washer", never the ordinal 0 |
enum with [Value("…")] | that value, because you chose it — "risk": "risk-high" |
Guid, DateTime, DateOnly, TimeOnly, TimeSpan | a string in the canonical form — "2026-09-04", "01:30:00" |
int, decimal, bool | the JSON number or boolean, unquoted |
An enum also accepts its stored key inbound — the ordinal, or the [Value] of a string-backed enum — so a client
written against an older payload keeps working. The name is what a read ANSWERS and what the published OpenAPI
advertises; the key is a compatibility affordance.
Anything else is a 400 naming the field and what it accepts, with an enum's whole closed set spelled out:
{ "error": { "code": "VALIDATION_FAILED", "details": [
{ "field": "machine", "code": "INVALID_ENUM_VALUE",
"message": "'machine' expects one of Machine: Washer, Dryer — received a string ('Toaster')." } ] } }…and what the response is WRAPPED in#
The table above is about one VALUE. This is about the object it arrives inside, which is not always the value itself — and every key in it is camelCase, whatever the member is called in your source.
| The route | What comes back |
|---|---|
an Endpoint whose function returns an entity | that row, flat — {"id": "…", "accession": "ACC-1"} |
an Endpoint whose function returns a class | its fields, flat — {"accession": "ACC-1", "species": "Rana"} |
an Endpoint whose function returns void | {"status": "ok"} |
an Endpoint whose function returns anything else — a List<T>, a Dictionary<K,V>, an int, a string | wrapped: {"result": …} |
Crud<T> read-many (GET /entities/T) | {"items": [ … ], "total": 12, "top": 50, "skip": 0, "hasMore": false} — plus "included" when you asked to expand |
Crud<T> read-one / create / update | that row, flat |
Crud<T> delete | 204, no body |
⚠ The result wrapper is the one that catches people, because the obvious DTO is the one that does not work. A
route whose function returns List<Receipt> does not answer a bare JSON array, so
JsonSerializer.Deserialize<List<Receipt>>(body) fails — the body is an object. Declare the wrapper instead, and
read through it:
class ReceiptPage { public List<Receipt> Result; } // `Result` binds the body's `result`
var page = JsonSerializer.Deserialize<ReceiptPage>(r.Body);
Assert.Equal(2, page.Result.Count);Calling your own REST API from a test has this as a compiled example, and JsonSerializer is the verb that reads it.
Reading many: $filter, $orderby, $top, $skip, $expand#
GET /entities/T takes OData's query options. A mistake in any of them is a 400 that names it, never a 500 and
never a silently different answer.
| Option | What it takes | When it is wrong |
|---|---|---|
$filter | eq ne gt ge lt le, and / or, eq null, and contains(p,'…'), startswith(p,'…'), endswith(p,'…'). Those three are literal: contains(name,'50%') finds the text "50%", and '' inside the quotes is one quote | 400 INVALID_FILTER, quoting your filter and where it stopped parsing (name eq with no value, a trailing and, an unsupported function) |
$orderby | name, name desc, comma-separated for more keys | 400, naming the term |
$top | rows per page; 50 when omitted, at most what fits about 2 MB of this entity's rows (How many rows a page is sent) | a larger number is served at that size, and the answer's top says so. Rows larger than estimated end the answer early, with hasMore |
$skip | rows to skip — continue an answer from $skip plus the items it held | — |
$expand | relations, comma-separated; lines.product follows one further | 400 UNSUPPORTED_EXPAND_OPTION, naming the member and listing the relations you could have named. A value ($expand=name) is refused the same way |
Who is user?#
Every rule you already wrote applies to an API call unchanged, because an authenticated API caller is a user of your
app — not a special anonymous "machine" identity. This is the one thing about app.Apis worth reading before you
design anything: guessing it the other way leads you to open your entities to anonymous callers so the API "works",
which opens them to the whole world on every other surface too.
An API key is a PER-USER credential. There is no app-wide API key. A key is minted against exactly one
[Principal] row, and a request carrying it runs as that user:
Auth | the request carries | user | IsAuthenticated | IsAnonymous |
|---|---|---|---|---|
new ApiAuth { ApiKey = true } | X-API-Key: pk_… | the key OWNER's principal row | true | false |
new ApiAuth { Bearer = true } | Authorization: Bearer … | that token's principal row | true | false |
new ApiAuth { OAuth = true } | an OAuth-issued bearer token | that token's principal row | true | false |
new ApiAuth { Anonymous = true } | anything, or nothing | null | false | true |
an authenticating Auth | nothing | — | — | — → the request is 401 and never reaches your data |
So an API key and a bearer token for the same person produce the same context: the same user, the same role
grants, the same row filters. A rule like allow read where OwnerEmail == user.Email returns that person's rows over
the API exactly as it does in the app's own UI, and allow read, create when IsAuthenticated admits them.
Three consequences, each of which is a mistake if you assume the opposite:
- You do not need
IsAnonymousto make an entity reachable over an authenticated API. The caller is somebody. Anallow … when IsAnonymousrule is in fact the one thing an API-key caller can never satisfy. - A missing credential is a
401, not an anonymous request.ApiAuth { ApiKey = true }never falls through to anonymous access; the request stops at the door. - Only
Anonymous = trueruns anonymously, and thereuseris null — so a row filter correlating tousermatches nothing and denies, which is the correct answer for a caller who is nobody.
⚑ You can check every row of that table against your own app rather than take it from here. Calling your own REST API from a test
sends a real request to your published route from inside a [Test], with or without a credential, and hands you the
status back — so "an API key caller is authenticated" is a claim you can make the app answer:
Assert.Equal(401, Api.Get("/api/rest/v1/catalog/entities/Product").StatusCode); // nobody
Assert.Equal(200, Api.Get("/api/rest/v1/catalog/entities/Product", apiKey: key).StatusCode); // somebodyWhere the key lives#
The platform stores each user's key as a hash on that user's own principal row, in two columns your [Principal]
entity must declare by these exact names:
[Principal] entity User {
[Unique, MaxLength(200)] string Email;
[MaxLength(255)] string? ApiKeyHash; // the key in force
[MaxLength(255)] string? ApiKeyHash2; // a second, so a key can be rotated without a gap
security {
allow read when IsAuthenticated;
// ⛔ BOTH SLOTS. A stored key hash with no field-level `deny read` rides `Session.CurrentUser` to the
// browser — the platform ships the whole [Principal] minus its MASKED properties.
deny read ApiKeyHash when !IsAuthenticator;
deny read ApiKeyHash2 when !IsAuthenticator;
}
}IsAuthenticator is a policy you declare over your own grant table — it is not built in. See
auth bootstrap (login, before anyone is signed in) for the three lines that declare it, and acting for another principal for
the whole shape worked through on an app whose API caller is a machine.
Declaring Auth = new ApiAuth { ApiKey = true } on an app whose principal lacks them is a compile error naming
both columns — without them no key can be minted and none can be verified, so the published route would be a 401
forever.
Every API says who may call it#
Auth is required, and an API that omits it is a compile error listing the four things it could have said. The
reason is what silence used to mean: a RestApi with no Auth published an open route, reachable by anyone who
found the URL — and nothing in the source said so.
That is the inverse of how the rest of the platform reads silence, which is what made it dangerous:
| you write nothing about… | and you get |
|---|---|
an entity's security { } | denied — nobody may read or write it |
| a routed component's access | denied — until it says [AllowAnonymous] |
RestApi's Auth |
Anonymous is still perfectly legal, and for a public catalogue or a health check it is the right answer. What is refused is arriving there by saying nothing:
new RestApi("PublicCatalogue") {
Route = "catalogue",
Auth = new ApiAuth { Anonymous = true }, // deliberate, and visible in the diff that added it
Expose = [ new Crud<Product>() { Operations = [CrudOp.Read] } ],
}⚠ Anonymous = true cannot be combined with ApiKey/Bearer/OAuth — that would say two opposite things about
one route — and it does not weaken your entities. Their security { } blocks still decide what an anonymous
caller may read or write, which for most entities is nothing.
You never write a key into source. The platform mints one for a named user and prints the plaintext pk_… once;
only its hash is stored, and the caller then presents it in the X-API-Key header:
osyrin app user apikey generate [email protected] # prints the pk_… once
osyrin app user apikey revoke [email protected] # --secondary to drop only the rotation keyThe app's own administrators can do the same from inside the app with [[security-people-kit#api-keys|the People
kit's API keys page]]: issue a key to a teammate (shown once), see when each key was last used, and revoke one. When
the principal also declares DateTime? ApiKeyUsedAt; and DateTime? ApiKey2UsedAt;, the platform records when each
slot's key was last presented, to the minute. A revoked key is refused from the next request.
Because there are two columns, rotation has no gap: generate a second key while the first still works, move the callers over, then revoke the old one.
⚑ In a test, ask for one by naming the principal — [[testing-api-calls#credentials|Api.KeyFor(P)]]. "You never
write a key into source" is about your APP's source and stays true; a [Test] mints a real one against its own
throwaway branch, so the gated route above can actually be exercised rather than only refused.
⚠ app.Secrets is a different thing and is not involved. app.Secrets declares secrets your app consumes — an
LLM key, an OAuth client secret. ApiAuth has no member that names a Secret, so there is nothing to declare there
for an API key, and declaring one does not gate anything.
Examples#
A complete app that publishes one entity as read/create CRUD and one function as a POST route:
entity Order {
[Required, MaxLength(200)] string CustomerEmail;
decimal Total;
}
decimal SubmitOrder(decimal total, decimal taxRate) {
return total + total * taxRate;
}
app.Apis = [
new RestApi("Orders") {
Route = "orders",
// Every API says who may call it. This one is open on purpose; the next example authenticates.
Auth = new ApiAuth { Anonymous = true },
Expose = [ new Crud<Order>() { Operations = [CrudOp.Read, CrudOp.Create] } ],
Endpoints = [
new Endpoint(SubmitOrder) { Method = HttpMethod.Post, Path = "/submit", SuccessStatus = 201 },
],
},
];Require an API key on every route. The key belongs to a USER, so the principal declares the two columns that hold
it — and the entity's security {} then governs the API caller exactly as it governs that same person signed in:
[Role] enum AppRole { Authenticator, Member }
[RoleGrant] entity RoleGrant {
[Required("Name the user this grant belongs to.")] User Grantee;
[Required("Choose the role this grant confers.")] AppRole Level;
security { allow read when IsAuthenticated; }
}
policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator);
[Principal] entity User {
[Unique, MaxLength(200)] string Email;
[MaxLength(255)] string? ApiKeyHash;
[MaxLength(255)] string? ApiKeyHash2;
security {
allow read when IsAuthenticated;
// ⛔ BOTH SLOTS, or the stored key hash rides `Session.CurrentUser` to the browser.
deny read ApiKeyHash when !IsAuthenticator;
deny read ApiKeyHash2 when !IsAuthenticator;
}
}
entity Product {
[Required, MaxLength(200)] string Name;
decimal Price;
security {
allow read when IsAuthenticated; // the API-key caller IS authenticated — no anonymous grant needed
}
}
app.Apis = [
new RestApi("Catalog") {
Route = "catalog",
Auth = new ApiAuth { ApiKey = true },
Expose = [ new Crud<Product>() { Operations = [CrudOp.Read] } ],
},
];See also#
- principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads —
IsAuthenticated/IsAnonymous, the predicates the table above resolves - [AuthMethod] — a function an unauthenticated visitor may call — signing a user in, for bearer-authenticated routes
- function — the functions an
Endpointpublishes - declaring secrets (app.Secrets) —
app.Secrets, for the secrets your app CONSUMES; an API key is not one of them - Calling your own REST API from a test —
Api.*: calling these routes from your own.test.osy, and asserting the status each refusal answers