The HTTP API
What a client of a blendx app sees. The replies below come from examples/expenses.
Requests
- Bodies are JSON, sent with
content-type: application/json. An action whose method is GET reads its input from the query string instead. - Input is strict. A key the action's rules do not know is refused with 422, so a client cannot set a column it was not meant to, such as the
user_idortotalof an expense claim. - A body that is not valid JSON is refused with 400. An empty body counts as
{}.
Routes
| Action | Route | Reply |
|---|---|---|
| index | GET /<table> | 200, a page |
| store | POST /<table> | 201, the record |
| show | GET /<table>/:id | 200, the record |
| update | PATCH /<table>/:id | 200, the record |
| destroy | DELETE /<table>/:id | 204, no body |
| restore | POST /<table>/:id/restore | 200, the record |
| member action | POST /<table>/:id/<name> | 200, the record |
| collection action | POST /<table>/<name> | 200, what it calculated |
Custom actions can use another method (Blends). Only the actions a blend lists have routes; any other request is a 404.
Records
A record holds the row's columns as the database returns them, minus the blend's hidden columns (unless the action reveals them), under their names in the schema:
{
"id": 1,
"user_id": 1,
"description": "Team lunch",
"category": "meals",
"amount": "40.00",
"tax": "4.00",
"total": "44.00",
"status": "draft",
"spent_on": "2026-09-01",
"review_note": null,
"created_at": "2026-09-13 12:48:15.212",
"updated_at": "2026-09-13 12:48:15.212",
"deleted_at": null
}
numeric columns are strings, and timestamps are in PostgreSQL's text form, not ISO 8601 (The schema).
Listing
GET /expenses?user_id=1 answers a page:
{
"data": [{ "id": 1, "user_id": 1, "description": "Team lunch", "...": "..." }],
"meta": { "page": 1, "per_page": 25, "total": 1 }
}
The query parameters:
| Parameter | |
|---|---|
page | a positive integer; 1 by default |
per_page | a positive integer; 25 by default, at most 100 (the app can change both: paging) |
sort | a filterable column, or -column for descending; the primary key, ascending, by default |
| a column | an exact match, for primary key, unique, foreign key and indexed columns: ?status=submitted |
trashed | with or only, when the blend enables it (index options) |
Soft-deleted rows are left out unless trashed says otherwise. Hidden columns can neither filter nor sort. Any other parameter is refused with 422. An index with a scope lists only the rows in it: in the expenses API, a claimant lists only their own claims, and asking for someone else's (?user_id=2) answers an empty page.
Errors
Every error is an RFC 9457 Problem Details object, served as application/problem+json:
{
"type": "about:blank",
"title": "Unprocessable Content",
"status": 422,
"detail": "The request did not pass validation.",
"errors": [
{ "pointer": "/amount", "detail": "Invalid input: expected string, received number" },
{ "pointer": "/total", "detail": "is not an accepted field" }
]
}
-
errorslists every problem, not only the first. In a body, each entry has a JSONpointerto the field; in a query string, it names theparameter:{ "parameter": "category", "detail": "Invalid option: expected one of \"travel\"|\"meals\"|\"office\"|\"other\"" } -
typeisabout:blank, or a URI under the app'sproblems.typeBasewhen it sets one, such ashttps://api.example.com/problems/validation-error(The app). -
titleis the status's standard reason phrase.
| Status | When |
|---|---|
| 400 | The body is not valid JSON. |
| 401 | The action's policy needs an identity, and the request has none. |
| 403 | The policy or an authorize hook refuses. |
| 404 | No route matches, or a member action's record does not exist (a soft-deleted one included). |
| 409 | A unique value already exists, or a destroy hits a row other rows still reference. |
| 422 | The input is invalid, or the database refuses a value: a reference to a missing row, a required column left empty, a value its column cannot hold. |
| 500 | Anything else. The client gets no details; the server's onError gets the error. |
The order of failures
A request stops at the first stage that fails, so the statuses come in a fixed order: 401, then 422, 404, 403, and finally 409 or 422 from the database. A client without a token learns nothing about the input or the record; a client whose input is invalid learns nothing about whether the record exists. The full rules are in packages/spec/errors.md and pipeline.md.
OpenAPI
blendx generate writes src/generated/openapi.json, an OpenAPI 3.1 document of every route: its parameters, its request body, and each reply by status, Problem Details included. Each operation's id is <table>.<action>, such as expenses.submit.
The document is a file. To serve it, add a route to the server:
import openapi from './src/generated/openapi.json' with { type: 'json' };
const server = createServer({ app, db: database.db, routes });
server.get('/openapi.json', (c) => c.json(openapi));
(The import needs resolveJsonModule in tsconfig.json.) Any OpenAPI tool can then read it: documentation, clients in other languages, contract tests.
The typed client
For a TypeScript client, AppType from the generated routes gives a Hono RPC client each route's input, and its replies by status. From examples/expenses/test/client.test.ts:
import { hc } from 'blendx/client';
import type { AppType } from './server.ts';
const client = hc<AppType>('http://localhost:3000', {
headers: { authorization: `Bearer ${token}` },
});
const filed = await client.expenses.$post({
json: { description: 'Team lunch', category: 'meals', amount: '40.00', spent_on: '2026-09-01' },
});
if (filed.status === 201) {
const claim = await filed.json(); // claim.status is 'draft' | 'submitted' | 'approved' | 'rejected'
await client.expenses[':id'].submit.$post({ param: { id: String(claim.id) } });
}
const quoted = await client.expenses.quote.$get({ query: { amount: '10', category: 'office' } });
- Input is typed by the action's rules:
category: 'food'is a type error. An action whose rules accept nothing, likesubmit, takes only its path parameter. - Checking
statusnarrows the body: the record for 201, a problem for 422. - An action the blend does not list is not on the client at all.
Import hc from blendx/client, not from hono/client: AppType is built with the Hono that blendx uses, and the client must use the same one.
For a React app, The React client turns this client into TanStack Query options for every action.